api-quality-spectral-ruleset 1.7.0 → 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/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,6 +54,9 @@ 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."
@@ -849,8 +852,8 @@ rules:
849
852
  then:
850
853
  function: apq-numeric-parameter-integrity
851
854
  apiq:OAR075:
852
- description: "String parameters should have minLength, maxLength, pattern (regular expression), or enum restriction."
853
- message: "OAR075: String parameters should have minLength, maxLength, pattern, or enum restriction."
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."
854
857
  severity: error
855
858
  resolved: false
856
859
  given:
@@ -863,7 +866,7 @@ rules:
863
866
  - "$.components.parameters[*]"
864
867
  - "$.parameters[*]"
865
868
  then:
866
- function: apq-string-parameter-integrity
869
+ function: apq-string-parameter-schema-integrity
867
870
  functionOptions:
868
871
  parameter_integrity: "minLength,maxLength,pattern,enum"
869
872
  apiq:OAR076:
@@ -1232,3 +1235,53 @@ rules:
1232
1235
  function: apq-forbidden-title-pattern
1233
1236
  functionOptions:
1234
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
+ };
@@ -1,3 +1,5 @@
1
+ const { pathsIncludeExclude } = require('./paths-include-exclude.js');
2
+
1
3
  const DEFAULT_PAGING_SCHEMA = {
2
4
  type: 'object',
3
5
  properties: {
@@ -189,35 +191,6 @@ const isJsonMediaType = (mediaType) => {
189
191
  return subtype.endsWith('+json');
190
192
  };
191
193
 
192
- const pathPatterns = (raw) => {
193
- let items = [];
194
- if (raw === undefined) items = DEFAULT_PATHS.split(',');
195
- else if (typeof raw === 'string') items = raw.split(',');
196
- else if (Array.isArray(raw)) items = raw;
197
- return items
198
- .filter((item) => typeof item === 'string')
199
- .map((item) => item.trim())
200
- .filter(Boolean)
201
- .map((pattern) => pattern.split('/'));
202
- };
203
-
204
- const isIncludeStrategy = (value) => typeof value === 'string' && value.trim().replace(/^\//, '').toLowerCase() === 'include';
205
-
206
- const matchesPattern = (pattern, segments) => {
207
- const match = (patternIndex, segmentIndex) => {
208
- if (patternIndex === pattern.length) return segmentIndex === segments.length;
209
- if (pattern[patternIndex] === '**') {
210
- for (let next = segmentIndex; next <= segments.length; next += 1) {
211
- if (match(patternIndex + 1, next)) return true;
212
- }
213
- return false;
214
- }
215
- if (segmentIndex === segments.length) return false;
216
- return (pattern[patternIndex] === '*' || pattern[patternIndex] === segments[segmentIndex]) && match(patternIndex + 1, segmentIndex + 1);
217
- };
218
- return match(0, 0);
219
- };
220
-
221
194
  const isDetailPath = (pathKey) => pathKey.slice(pathKey.lastIndexOf('/') + 1).startsWith('{');
222
195
 
223
196
  const isUnresolvedRef = (value) => isObject(value) && typeof value.$ref === 'string';
@@ -273,9 +246,7 @@ module.exports = (response, options = {}, context) => {
273
246
 
274
247
  const pathKey = typeof responsePath[1] === 'string' ? responsePath[1] : '';
275
248
  if (isDetailPath(pathKey)) return [];
276
- const segments = pathKey.split('/');
277
- const isListed = pathPatterns(opts.paths).some((pattern) => matchesPattern(pattern, segments));
278
- if (isIncludeStrategy(opts.pathValidationStrategy) ? !isListed : isListed) return [];
249
+ if (!pathsIncludeExclude(opts, DEFAULT_PATHS).isEvaluatedPath(pathKey)) return [];
279
250
 
280
251
  const pagingSchema = parsePagingSchema(opts['paging-schema']);
281
252
  const pagingPropertyName = typeof pagingSchema.pagingPropertyName === 'string' && pagingSchema.pagingPropertyName.trim() !== ''
@@ -0,0 +1,194 @@
1
+ const { pathsIncludeExclude } = require('./paths-include-exclude.js');
2
+
3
+ const DEFAULT_ALLOWED_FLOWS = 'authorizationCode,clientCredentials';
4
+ const DEFAULT_PATHS = '/me,/health,/ping,/status';
5
+ const FLOW_ALIASES = { accesscode: 'authorizationcode', application: 'clientcredentials' };
6
+
7
+ const VERBS_V2 = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'];
8
+ const VERBS_V3 = VERBS_V2.concat(['trace']);
9
+ const VERBS_V32 = VERBS_V3.concat(['query']);
10
+
11
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
12
+ const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
13
+
14
+ const documentVersion = (root) => {
15
+ if (has(root, 'swagger')) return 'v2';
16
+ if (!has(root, 'openapi')) return null;
17
+ const openapi = String(root.openapi).trim();
18
+ if (openapi.startsWith('3.2')) return 'v32';
19
+ if (openapi.startsWith('3.1')) return 'v31';
20
+ return 'v3';
21
+ };
22
+
23
+ const flowKey = (name) => {
24
+ const key = name.trim().toLowerCase();
25
+ return FLOW_ALIASES[key] || key;
26
+ };
27
+
28
+ const allowedFlows = (raw) => {
29
+ let items = [];
30
+ if (typeof raw === 'string') items = raw.split(',');
31
+ else if (Array.isArray(raw)) items = raw.filter((item) => typeof item === 'string').flatMap((item) => item.split(','));
32
+ const names = new Map();
33
+ items.map((item) => item.trim()).filter(Boolean).forEach((item) => {
34
+ if (!names.has(flowKey(item))) names.set(flowKey(item), item);
35
+ });
36
+ return names.size > 0 ? names : allowedFlows(DEFAULT_ALLOWED_FLOWS);
37
+ };
38
+
39
+ const pointerTarget = (root, ref) => {
40
+ let fragment;
41
+ try {
42
+ fragment = decodeURIComponent(ref.slice(1));
43
+ } catch (err) {
44
+ return undefined;
45
+ }
46
+ if (!fragment.startsWith('/')) return undefined;
47
+ const segments = fragment.slice(1).split('/').map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));
48
+ let value = root;
49
+ for (const segment of segments) {
50
+ if (!isObject(value) || !has(value, segment)) return undefined;
51
+ value = value[segment];
52
+ }
53
+ return isObject(value) ? { value, segments } : undefined;
54
+ };
55
+
56
+ const collectOperations = (root, version) => {
57
+ const verbs = version === 'v32' ? VERBS_V32 : (version === 'v2' ? VERBS_V2 : VERBS_V3);
58
+ const operations = [];
59
+ const visitPathItem = (pathItem, pathKey, ancestors) => {
60
+ if (!isObject(pathItem) || ancestors.has(pathItem)) return;
61
+ const chain = new Set(ancestors).add(pathItem);
62
+ const ops = verbs.filter((verb) => has(pathItem, verb)).map((verb) => pathItem[verb]);
63
+ if (version === 'v32' && isObject(pathItem.additionalOperations)) ops.push(...Object.values(pathItem.additionalOperations));
64
+ ops.filter(isObject).forEach((op) => {
65
+ operations.push({ op, pathKey });
66
+ if (version === 'v2' || !isObject(op.callbacks)) return;
67
+ Object.values(op.callbacks).filter(isObject).forEach((callback) => {
68
+ Object.keys(callback)
69
+ .filter((expression) => !expression.startsWith('x-'))
70
+ .forEach((expression) => visitPathItem(callback[expression], pathKey, chain));
71
+ });
72
+ });
73
+ };
74
+ if (isObject(root.paths)) {
75
+ Object.keys(root.paths)
76
+ .filter((pathKey) => pathKey.startsWith('/'))
77
+ .forEach((pathKey) => visitPathItem(root.paths[pathKey], pathKey, new Set()));
78
+ }
79
+ if ((version === 'v31' || version === 'v32') && isObject(root.webhooks)) {
80
+ Object.values(root.webhooks).forEach((pathItem) => visitPathItem(pathItem, null, new Set()));
81
+ }
82
+ return operations;
83
+ };
84
+
85
+ const definitionsBase = (version) => (version === 'v2' ? ['securityDefinitions'] : ['components', 'securitySchemes']);
86
+
87
+ const lookup = (root, segments) => segments.reduce((value, segment) => (isObject(value) ? value[segment] : undefined), root);
88
+
89
+ /**
90
+ * @param {object} root - The resolved document (given: "$")
91
+ * @param {object} options
92
+ * @param {string|string[]} options['allowed-flows'] - OAuth2 flows allowed in oauth2 security schemes, array or comma-separated,
93
+ * case-insensitive; the Swagger 2.0 names are equivalent to the OpenAPI 3 ones (accessCode = authorizationCode,
94
+ * application = clientCredentials); blank or unusable falls back to "authorizationCode,clientCredentials"
95
+ * @param {string|string[]} options.paths - comma-separated path patterns matched segment by segment; "*" matches one segment
96
+ * and "**" zero or more (default "/me,/health,/ping,/status")
97
+ * @param {string} options.pathValidationStrategy - "/exclude" (default) skips the security schemes applied only to matching
98
+ * paths, "/include" only evaluates the security schemes applied to them; webhooks have no path, so they are only evaluated
99
+ * with "/exclude", and callbacks follow the path of their operation
100
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
101
+ * @returns {Array<{message: string, path: Array<string>}>} one result per disallowed flow, on the scheme definition
102
+ */
103
+ module.exports = (root, options, context) => {
104
+ if (!isObject(root)) return [];
105
+ const version = documentVersion(root);
106
+ if (version === null) return [];
107
+
108
+ const opts = options || {};
109
+ const allowed = allowedFlows(opts['allowed-flows']);
110
+ const allowedText = [...allowed.values()].join(', ');
111
+ const { include, isEvaluatedPath } = pathsIncludeExclude(opts, DEFAULT_PATHS);
112
+
113
+ const base = definitionsBase(version);
114
+ const definitions = lookup(root, base);
115
+ const rawDefinitions = lookup(context.document && context.document.data, base);
116
+ const schemes = new Map();
117
+ if (isObject(definitions)) {
118
+ Object.keys(definitions).forEach((name) => {
119
+ const scheme = definitions[name];
120
+ if (!isObject(scheme)) return;
121
+ const rawEntry = isObject(rawDefinitions) ? rawDefinitions[name] : undefined;
122
+ const direct = isObject(rawEntry) && !has(rawEntry, '$ref');
123
+ const known = schemes.get(scheme);
124
+ if (!known || (!known.direct && direct)) schemes.set(scheme, { name, path: [...base, name], direct });
125
+ });
126
+ }
127
+
128
+ const schemeFor = (key) => {
129
+ if (isObject(definitions) && has(definitions, key)) return isObject(definitions[key]) ? definitions[key] : undefined;
130
+ if (version !== 'v32') return undefined;
131
+ const self = typeof root.$self === 'string' ? root.$self.trim() : '';
132
+ const fragment = self !== '' && key.startsWith(`${self}#`) ? key.slice(self.length) : key;
133
+ if (!fragment.startsWith('#')) return undefined;
134
+ const target = pointerTarget(root, fragment);
135
+ if (!target) return undefined;
136
+ if (!schemes.has(target.value)) {
137
+ schemes.set(target.value, { name: target.segments[target.segments.length - 1], path: target.segments, direct: true });
138
+ }
139
+ return target.value;
140
+ };
141
+
142
+ const usage = new Map();
143
+ const rootSecurity = Array.isArray(root.security) ? root.security : [];
144
+ collectOperations(root, version).forEach(({ op, pathKey }) => {
145
+ const security = Array.isArray(op.security) ? op.security : rootSecurity;
146
+ const inScope = pathKey === null ? !include : isEvaluatedPath(pathKey);
147
+ security.filter(isObject).forEach((requirement) => {
148
+ Object.keys(requirement).forEach((key) => {
149
+ const scheme = schemeFor(key);
150
+ if (scheme) usage.set(scheme, usage.get(scheme) === true || inScope);
151
+ });
152
+ });
153
+ });
154
+
155
+ const inventory = context.documentInventory;
156
+ const isLocatable = (path) => {
157
+ const item = inventory.findAssociatedItemForPath(path, true);
158
+ if (!item || !item.document) return true;
159
+ return item.document.getRangeForJsonPath(item.path, false) !== undefined;
160
+ };
161
+ const locatable = (path) => {
162
+ if (!inventory || typeof inventory.findAssociatedItemForPath !== 'function') return path;
163
+ for (let length = path.length; length > 0; length -= 1) {
164
+ if (isLocatable(path.slice(0, length))) return path.slice(0, length);
165
+ }
166
+ return path;
167
+ };
168
+
169
+ const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'OAR118';
170
+ const results = [];
171
+ const report = (flow, name, path) => {
172
+ results.push({
173
+ message: `${ruleCode}: Flow '${flow}' of security scheme '${name}' is not allowed. Allowed flows: ${allowedText}.`,
174
+ path: locatable(path),
175
+ });
176
+ };
177
+
178
+ schemes.forEach((entry, scheme) => {
179
+ if (usage.has(scheme) ? !usage.get(scheme) : include) return;
180
+ if (typeof scheme.type !== 'string' || scheme.type.trim().toLowerCase() !== 'oauth2') return;
181
+ if (version === 'v2') {
182
+ if (typeof scheme.flow === 'string' && scheme.flow.trim() !== '' && !allowed.has(flowKey(scheme.flow))) {
183
+ report(scheme.flow, entry.name, [...entry.path, 'flow']);
184
+ }
185
+ return;
186
+ }
187
+ if (!isObject(scheme.flows)) return;
188
+ Object.keys(scheme.flows)
189
+ .filter((flow) => !flow.startsWith('x-') && flow.trim() !== '' && !allowed.has(flowKey(flow)))
190
+ .forEach((flow) => report(flow, entry.name, [...entry.path, 'flows', flow]));
191
+ });
192
+
193
+ return results;
194
+ };
@@ -0,0 +1,187 @@
1
+ const { pathsIncludeExclude } = require('./paths-include-exclude.js');
2
+
3
+ const DEFAULT_ALLOWED_TYPES = 'oauth2,bearer';
4
+ const DEFAULT_PATHS = '/me,/health,/ping,/status';
5
+
6
+ const VERBS_V2 = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch'];
7
+ const VERBS_V3 = VERBS_V2.concat(['trace']);
8
+ const VERBS_V32 = VERBS_V3.concat(['query']);
9
+
10
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
12
+ const typeKey = (name) => name.trim().toLowerCase();
13
+
14
+ const documentVersion = (root) => {
15
+ if (has(root, 'swagger')) return 'v2';
16
+ if (!has(root, 'openapi')) return null;
17
+ const openapi = String(root.openapi).trim();
18
+ if (openapi.startsWith('3.2')) return 'v32';
19
+ if (openapi.startsWith('3.1')) return 'v31';
20
+ return 'v3';
21
+ };
22
+
23
+ const allowedTypes = (raw) => {
24
+ let items = [];
25
+ if (typeof raw === 'string') items = raw.split(',');
26
+ else if (Array.isArray(raw)) items = raw.filter((item) => typeof item === 'string').flatMap((item) => item.split(','));
27
+ const names = new Map();
28
+ items.map((item) => item.trim()).filter(Boolean).forEach((item) => {
29
+ if (!names.has(typeKey(item))) names.set(typeKey(item), item);
30
+ });
31
+ return names.size > 0 ? names : allowedTypes(DEFAULT_ALLOWED_TYPES);
32
+ };
33
+
34
+ const pointerTarget = (root, ref) => {
35
+ let fragment;
36
+ try {
37
+ fragment = decodeURIComponent(ref.slice(1));
38
+ } catch (err) {
39
+ return undefined;
40
+ }
41
+ if (!fragment.startsWith('/')) return undefined;
42
+ const segments = fragment.slice(1).split('/').map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));
43
+ let value = root;
44
+ for (const segment of segments) {
45
+ if (!isObject(value) || !has(value, segment)) return undefined;
46
+ value = value[segment];
47
+ }
48
+ return isObject(value) ? { value, segments } : undefined;
49
+ };
50
+
51
+ const collectOperations = (root, version) => {
52
+ const verbs = version === 'v32' ? VERBS_V32 : (version === 'v2' ? VERBS_V2 : VERBS_V3);
53
+ const operations = [];
54
+ const visitPathItem = (pathItem, pathKey, ancestors) => {
55
+ if (!isObject(pathItem) || ancestors.has(pathItem)) return;
56
+ const chain = new Set(ancestors).add(pathItem);
57
+ const ops = verbs.filter((verb) => has(pathItem, verb)).map((verb) => pathItem[verb]);
58
+ if (version === 'v32' && isObject(pathItem.additionalOperations)) ops.push(...Object.values(pathItem.additionalOperations));
59
+ ops.filter(isObject).forEach((op) => {
60
+ operations.push({ op, pathKey });
61
+ if (version === 'v2' || !isObject(op.callbacks)) return;
62
+ Object.values(op.callbacks).filter(isObject).forEach((callback) => {
63
+ Object.keys(callback)
64
+ .filter((expression) => !expression.startsWith('x-'))
65
+ .forEach((expression) => visitPathItem(callback[expression], pathKey, chain));
66
+ });
67
+ });
68
+ };
69
+ if (isObject(root.paths)) {
70
+ Object.keys(root.paths)
71
+ .filter((pathKey) => pathKey.startsWith('/'))
72
+ .forEach((pathKey) => visitPathItem(root.paths[pathKey], pathKey, new Set()));
73
+ }
74
+ if ((version === 'v31' || version === 'v32') && isObject(root.webhooks)) {
75
+ Object.values(root.webhooks).forEach((pathItem) => visitPathItem(pathItem, null, new Set()));
76
+ }
77
+ return operations;
78
+ };
79
+
80
+ const definitionsBase = (version) => (version === 'v2' ? ['securityDefinitions'] : ['components', 'securitySchemes']);
81
+
82
+ const lookup = (root, segments) => segments.reduce((value, segment) => (isObject(value) ? value[segment] : undefined), root);
83
+
84
+ /**
85
+ * @param {object} root - The resolved document (given: "$")
86
+ * @param {object} options
87
+ * @param {string|string[]} options['allowed-security-types'] - security scheme types allowed, array or comma-separated,
88
+ * case-insensitive: apiKey, http, oauth2, openIdConnect, mutualTLS, or an HTTP authentication scheme (basic, bearer,
89
+ * digest...) that allows only that scheme of the http type; "http" allows every HTTP scheme and Swagger 2.0 "basic" is
90
+ * the HTTP basic scheme; blank or unusable falls back to "oauth2,bearer"
91
+ * @param {string|string[]} options.paths - comma-separated path patterns matched segment by segment; "*" matches one segment
92
+ * and "**" zero or more (default "/me,/health,/ping,/status")
93
+ * @param {string} options.pathValidationStrategy - "/exclude" (default) skips the security schemes applied only to matching
94
+ * paths, "/include" only evaluates the security schemes applied to them; webhooks have no path, so they are only evaluated
95
+ * with "/exclude", and callbacks follow the path of their operation
96
+ * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
97
+ * @returns {Array<{message: string, path: Array<string>}>} one result per scheme whose type is not allowed
98
+ */
99
+ module.exports = (root, options, context) => {
100
+ if (!isObject(root)) return [];
101
+ const version = documentVersion(root);
102
+ if (version === null) return [];
103
+
104
+ const opts = options || {};
105
+ const allowed = allowedTypes(opts['allowed-security-types']);
106
+ const allowedText = [...allowed.values()].join(', ');
107
+ const { include, isEvaluatedPath } = pathsIncludeExclude(opts, DEFAULT_PATHS);
108
+
109
+ const base = definitionsBase(version);
110
+ const definitions = lookup(root, base);
111
+ const rawDefinitions = lookup(context.document && context.document.data, base);
112
+ const schemes = new Map();
113
+ if (isObject(definitions)) {
114
+ Object.keys(definitions).forEach((name) => {
115
+ const scheme = definitions[name];
116
+ if (!isObject(scheme)) return;
117
+ const rawEntry = isObject(rawDefinitions) ? rawDefinitions[name] : undefined;
118
+ const direct = isObject(rawEntry) && !has(rawEntry, '$ref');
119
+ const known = schemes.get(scheme);
120
+ if (!known || (!known.direct && direct)) schemes.set(scheme, { name, path: [...base, name], direct });
121
+ });
122
+ }
123
+
124
+ const schemeFor = (key) => {
125
+ if (isObject(definitions) && has(definitions, key)) return isObject(definitions[key]) ? definitions[key] : undefined;
126
+ if (version !== 'v32') return undefined;
127
+ const self = typeof root.$self === 'string' ? root.$self.trim() : '';
128
+ const fragment = self !== '' && key.startsWith(`${self}#`) ? key.slice(self.length) : key;
129
+ if (!fragment.startsWith('#')) return undefined;
130
+ const target = pointerTarget(root, fragment);
131
+ if (!target) return undefined;
132
+ if (!schemes.has(target.value)) {
133
+ schemes.set(target.value, { name: target.segments[target.segments.length - 1], path: target.segments, direct: true });
134
+ }
135
+ return target.value;
136
+ };
137
+
138
+ const usage = new Map();
139
+ const rootSecurity = Array.isArray(root.security) ? root.security : [];
140
+ collectOperations(root, version).forEach(({ op, pathKey }) => {
141
+ const security = Array.isArray(op.security) ? op.security : rootSecurity;
142
+ const inScope = pathKey === null ? !include : isEvaluatedPath(pathKey);
143
+ security.filter(isObject).forEach((requirement) => {
144
+ Object.keys(requirement).forEach((key) => {
145
+ const scheme = schemeFor(key);
146
+ if (scheme) usage.set(scheme, usage.get(scheme) === true || inScope);
147
+ });
148
+ });
149
+ });
150
+
151
+ const inventory = context.documentInventory;
152
+ const isLocatable = (path) => {
153
+ const item = inventory.findAssociatedItemForPath(path, true);
154
+ if (!item || !item.document) return true;
155
+ return item.document.getRangeForJsonPath(item.path, false) !== undefined;
156
+ };
157
+ const locatable = (path) => {
158
+ if (!inventory || typeof inventory.findAssociatedItemForPath !== 'function') return path;
159
+ for (let length = path.length; length > 0; length -= 1) {
160
+ if (isLocatable(path.slice(0, length))) return path.slice(0, length);
161
+ }
162
+ return path;
163
+ };
164
+
165
+ const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'OAR121';
166
+ const results = [];
167
+
168
+ schemes.forEach((entry, scheme) => {
169
+ if (usage.has(scheme) ? !usage.get(scheme) : include) return;
170
+ if (typeof scheme.type !== 'string' || scheme.type.trim() === '') return;
171
+ const type = scheme.type.trim();
172
+ const httpScheme = version !== 'v2' && typeKey(type) === 'http' && typeof scheme.scheme === 'string' && scheme.scheme.trim() !== ''
173
+ ? scheme.scheme.trim()
174
+ : null;
175
+ const labels = [typeKey(type)];
176
+ if (httpScheme !== null) labels.push(typeKey(httpScheme));
177
+ if (version === 'v2' && typeKey(type) === 'basic') labels.push('http');
178
+ if (labels.some((label) => allowed.has(label))) return;
179
+ const described = httpScheme === null ? `type '${type}'` : `type '${type}' with scheme '${httpScheme}'`;
180
+ results.push({
181
+ message: `${ruleCode}: Security scheme '${entry.name}' ${described} is not allowed. Allowed types: ${allowedText}.`,
182
+ path: locatable([...entry.path, httpScheme === null ? 'type' : 'scheme']),
183
+ });
184
+ });
185
+
186
+ return results;
187
+ };
@@ -0,0 +1,42 @@
1
+ const pathPatterns = (raw, defaultPaths) => {
2
+ let items = [];
3
+ if (raw === undefined) items = defaultPaths.split(',');
4
+ else if (typeof raw === 'string') items = raw.split(',');
5
+ else if (Array.isArray(raw)) items = raw;
6
+ return items
7
+ .filter((item) => typeof item === 'string')
8
+ .map((item) => item.trim())
9
+ .filter(Boolean)
10
+ .map((pattern) => pattern.split('/'));
11
+ };
12
+
13
+ const isIncludeStrategy = (value) => typeof value === 'string' && value.trim().replace(/^\//, '').toLowerCase() === 'include';
14
+
15
+ const matchesPattern = (pattern, segments) => {
16
+ const match = (patternIndex, segmentIndex) => {
17
+ if (patternIndex === pattern.length) return segmentIndex === segments.length;
18
+ if (pattern[patternIndex] === '**') {
19
+ for (let next = segmentIndex; next <= segments.length; next += 1) {
20
+ if (match(patternIndex + 1, next)) return true;
21
+ }
22
+ return false;
23
+ }
24
+ if (segmentIndex === segments.length) return false;
25
+ return (pattern[patternIndex] === '*' || pattern[patternIndex] === segments[segmentIndex]) && match(patternIndex + 1, segmentIndex + 1);
26
+ };
27
+ return match(0, 0);
28
+ };
29
+
30
+ const pathsIncludeExclude = (options, defaultPaths) => {
31
+ const opts = options || {};
32
+ const include = isIncludeStrategy(opts.pathValidationStrategy);
33
+ const patterns = pathPatterns(opts.paths, defaultPaths);
34
+ const isEvaluatedPath = (pathKey) => {
35
+ const segments = pathKey.split('/');
36
+ const isListed = patterns.some((pattern) => matchesPattern(pattern, segments));
37
+ return include ? isListed : !isListed;
38
+ };
39
+ return { include, isEvaluatedPath };
40
+ };
41
+
42
+ module.exports = { pathsIncludeExclude };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "api-quality-spectral-ruleset",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Spectral ruleset by API Quality",
5
5
  "main": "apq-spectral.yaml",
6
6
  "files": [