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.
@@ -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: {
@@ -22,12 +24,11 @@ const DEFAULT_PAGING_SCHEMA = {
22
24
  };
23
25
 
24
26
  const DEFAULT_PAGING_PROPERTY = 'paging';
25
-
26
- const COLLECTION_PROPERTY_NAMES = ['data', 'items', 'results', 'content', 'elements', 'records', 'entries', 'values', 'list', 'rows', 'payload'];
27
+ const DEFAULT_PATHS = '/status,/health,/ping,**/me';
27
28
 
28
29
  const SUCCESS_CODE_REGEX = /^2(\d\d|XX)$/i;
29
30
  const NO_CONTENT_CODE = '204';
30
- const DETAIL_PATH_REGEX = /\/\{[^}]+\}$/;
31
+ const MEDIA_TYPE_TOKEN = /^[a-z0-9!#$&^_.+-]+$/;
31
32
 
32
33
  const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
33
34
 
@@ -143,15 +144,6 @@ const isArrayView = (view) => view.types.has('array');
143
144
 
144
145
  const typePath = (view) => (isObject(view.rootSchema) && view.rootSchema.type !== undefined ? [...view.path, 'type'] : view.path);
145
146
 
146
- const findCollectionProperty = (view) => {
147
- for (const [name, property] of view.properties) {
148
- if (COLLECTION_PROPERTY_NAMES.includes(name.toLowerCase()) && isArrayView(buildView(property.schema, property.path))) {
149
- return name;
150
- }
151
- }
152
- return null;
153
- };
154
-
155
147
  const validateAgainstTemplate = (template, view, label, report) => {
156
148
  if (!isObject(template)) return;
157
149
  const type = expectedType(template);
@@ -189,56 +181,99 @@ const validateAgainstTemplate = (template, view, label, report) => {
189
181
  });
190
182
  };
191
183
 
192
- const responseSchemas = (response, responsePath, isOas2) => {
184
+ const isJsonMediaType = (mediaType) => {
185
+ const essence = String(mediaType).split(';')[0].trim().toLowerCase();
186
+ const parts = essence.split('/');
187
+ if (parts.length !== 2 || parts[0] === '*') return true;
188
+ const [type, subtype] = parts;
189
+ if (!MEDIA_TYPE_TOKEN.test(type) || (subtype !== '*' && !MEDIA_TYPE_TOKEN.test(subtype))) return true;
190
+ if (type === 'application' && (subtype === 'json' || subtype === '*')) return true;
191
+ return subtype.endsWith('+json');
192
+ };
193
+
194
+ const isDetailPath = (pathKey) => pathKey.slice(pathKey.lastIndexOf('/') + 1).startsWith('{');
195
+
196
+ const isUnresolvedRef = (value) => isObject(value) && typeof value.$ref === 'string';
197
+
198
+ const producedMediaTypes = (document, pathKey) => {
199
+ const pathItem = isObject(document) && isObject(document.paths) ? document.paths[pathKey] : null;
200
+ const operation = isObject(pathItem) ? pathItem.get : null;
201
+ const listed = (value) => (Array.isArray(value) && value.length > 0 ? value : null);
202
+ return listed(isObject(operation) ? operation.produces : null) || listed(isObject(document) ? document.produces : null);
203
+ };
204
+
205
+ const evaluatedSchemas = (response, responsePath, document, pathKey, isOas2) => {
206
+ const schemas = [];
207
+ const missing = [];
193
208
  if (isOas2) {
194
- return isObject(response.schema) ? [{ schema: response.schema, path: [...responsePath, 'schema'] }] : [];
209
+ const produces = producedMediaTypes(document, pathKey);
210
+ if (produces && !produces.some(isJsonMediaType)) return { schemas, missing };
211
+ if (isUnresolvedRef(response.schema)) return { schemas, missing };
212
+ if (isObject(response.schema)) schemas.push({ schema: response.schema, path: [...responsePath, 'schema'] });
213
+ else missing.push(responsePath);
214
+ return { schemas, missing };
215
+ }
216
+ if (!isObject(response.content) || Object.keys(response.content).length === 0) {
217
+ missing.push(isObject(response.content) ? [...responsePath, 'content'] : responsePath);
218
+ return { schemas, missing };
195
219
  }
196
- if (!isObject(response.content)) return [];
197
- return Object.entries(response.content)
198
- .filter(([, mediaType]) => isObject(mediaType) && isObject(mediaType.schema))
199
- .map(([mediaTypeName, mediaType]) => ({ schema: mediaType.schema, path: [...responsePath, 'content', mediaTypeName, 'schema'] }));
220
+ Object.entries(response.content)
221
+ .filter(([mediaTypeName]) => isJsonMediaType(mediaTypeName))
222
+ .forEach(([mediaTypeName, mediaType]) => {
223
+ const mediaTypePath = [...responsePath, 'content', mediaTypeName];
224
+ if (isUnresolvedRef(mediaType) || (isObject(mediaType) && isUnresolvedRef(mediaType.schema))) return;
225
+ if (isObject(mediaType) && isObject(mediaType.schema)) schemas.push({ schema: mediaType.schema, path: [...mediaTypePath, 'schema'] });
226
+ else missing.push(mediaTypePath);
227
+ });
228
+ return { schemas, missing };
200
229
  };
201
230
 
202
231
  /**
203
232
  * @param {object} response - a resolved response of a GET operation
204
- * @param {object} options - { 'paging-schema': JSON string or object }
233
+ * @param {object} options
234
+ * @param {string|object} options['paging-schema'] - JSON string or object with the paging structure and its pagingPropertyName (default "paging")
235
+ * @param {string|string[]} options.paths - comma-separated path patterns matched segment by segment; "*" matches one segment and "**" zero or more (default "/status,/health,/ping,**\/me")
236
+ * @param {string} options.pathValidationStrategy - "/exclude" (default) skips the matching paths, "/include" only evaluates them
205
237
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
206
238
  */
207
239
  module.exports = (response, options = {}, context) => {
208
- if (!isObject(response)) return [];
240
+ if (!isObject(response) || isUnresolvedRef(response)) return [];
241
+ const opts = options || {};
209
242
 
210
243
  const responsePath = context.path || [];
211
244
  const statusCode = String(responsePath[responsePath.length - 1]);
212
245
  if (!SUCCESS_CODE_REGEX.test(statusCode) || statusCode === NO_CONTENT_CODE) return [];
213
246
 
214
247
  const pathKey = typeof responsePath[1] === 'string' ? responsePath[1] : '';
215
- const isDetailPath = DETAIL_PATH_REGEX.test(pathKey);
248
+ if (isDetailPath(pathKey)) return [];
249
+ if (!pathsIncludeExclude(opts, DEFAULT_PATHS).isEvaluatedPath(pathKey)) return [];
216
250
 
217
- const pagingSchema = parsePagingSchema((options || {})['paging-schema']);
251
+ const pagingSchema = parsePagingSchema(opts['paging-schema']);
218
252
  const pagingPropertyName = typeof pagingSchema.pagingPropertyName === 'string' && pagingSchema.pagingPropertyName.trim() !== ''
219
253
  ? pagingSchema.pagingPropertyName.trim()
220
254
  : DEFAULT_PAGING_PROPERTY;
221
255
 
222
- const document = context.document && context.document.data;
223
- const isOas2 = isObject(document) && typeof document.swagger === 'string';
256
+ const rawDocument = context.document && context.document.data;
257
+ const resolvedDocument = context.documentInventory && context.documentInventory.resolved;
258
+ const document = isObject(resolvedDocument) ? resolvedDocument : rawDocument;
259
+ const isOas2 = isObject(rawDocument) && typeof rawDocument.swagger === 'string';
224
260
  const ruleCode = context.rule && context.rule.name ? context.rule.name.split(':').pop() : 'OAR034';
225
261
 
226
262
  const results = [];
227
263
  const report = (message, path) => results.push({ message: `${ruleCode}: ${message}`, path });
228
264
 
229
- responseSchemas(response, responsePath, isOas2).forEach(({ schema, path }) => {
265
+ const { schemas, missing } = evaluatedSchemas(response, responsePath, document, pathKey, isOas2);
266
+ missing.forEach((path) => report(`Collection response must define a schema with a '${pagingPropertyName}' property`, path));
267
+
268
+ schemas.forEach(({ schema, path }) => {
230
269
  const view = buildView(schema, path);
231
270
  const paging = view.properties.get(pagingPropertyName);
232
271
 
233
272
  if (!paging) {
234
- if (isDetailPath) return;
235
273
  if (isArrayView(view)) {
236
274
  report(`Collection response must be an object with a '${pagingPropertyName}' property, not a top-level array`, view.path);
237
- return;
238
- }
239
- const collectionProperty = findCollectionProperty(view);
240
- if (collectionProperty) {
241
- report(`Response with collection property '${collectionProperty}' must include '${pagingPropertyName}' property for pagination`, view.path);
275
+ } else {
276
+ report(`Collection response must include '${pagingPropertyName}' property for pagination`, view.path);
242
277
  }
243
278
  return;
244
279
  }
@@ -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
+ };
@@ -18,9 +18,40 @@ function mergedProperties(schema, visited) {
18
18
  return properties;
19
19
  }
20
20
 
21
+ function parseList(value) {
22
+ let items = [];
23
+ if (Array.isArray(value)) {
24
+ items = value;
25
+ } else if (typeof value === 'string') {
26
+ items = value.split(',');
27
+ }
28
+ const names = items
29
+ .filter((item) => typeof item === 'string' || typeof item === 'number')
30
+ .map((item) => String(item).trim())
31
+ .filter(Boolean);
32
+ return [...new Set(names)];
33
+ }
34
+
35
+ function formatNames(names) {
36
+ const quoted = names.map((name) => `'${name}'`);
37
+ if (quoted.length <= 1) {
38
+ return quoted.join('');
39
+ }
40
+ return `${quoted.slice(0, -1).join(', ')} or ${quoted[quoted.length - 1]}`;
41
+ }
42
+
43
+ function isIncludeStrategy(value) {
44
+ return typeof value === 'string' && value.trim().replace(/^\//, '').toLowerCase() === 'include';
45
+ }
46
+
21
47
  /**
22
48
  * @param {object} given - the 201 response object of a POST operation
23
49
  * @param {object} options
50
+ * @param {string} options['data-property'] - name accepted next to 'error' when allowed-properties is not set (default "data")
51
+ * @param {string|string[]} options['allowed-properties'] - top-level property names accepted in the schema, array or comma-separated; blank falls back to [data-property, 'error']
52
+ * @param {string|string[]} options['required-properties'] - top-level property names that must be present, array or comma-separated
53
+ * @param {string|string[]} options.paths - paths to include/exclude (exact match), array or comma-separated
54
+ * @param {string} options.pathValidationStrategy - "/include" or "/exclude" (default "/exclude")
24
55
  * @param {import('@stoplight/spectral-core').RulesetFunctionContext} context
25
56
  */
26
57
  module.exports = (given, options, context) => {
@@ -28,17 +59,28 @@ module.exports = (given, options, context) => {
28
59
  return [];
29
60
  }
30
61
 
31
- const dataProperty = (options && options['data-property']) || 'data';
32
- const VALID_NAMES = [dataProperty, 'error'];
62
+ const opts = options || {};
63
+ const basePath = context.path || [];
64
+
65
+ const paths = parseList(opts.paths);
66
+ const isListed = paths.includes(basePath[1]);
67
+ if (isIncludeStrategy(opts.pathValidationStrategy) ? !isListed : isListed) {
68
+ return [];
69
+ }
70
+
71
+ const dataProperty = opts['data-property'] || 'data';
72
+ const configuredNames = parseList(opts['allowed-properties']);
73
+ const allowedNames = configuredNames.length > 0 ? configuredNames : [...new Set([dataProperty, 'error'])];
74
+ const requiredNames = parseList(opts['required-properties']);
75
+ const allowedText = formatNames(allowedNames);
33
76
  const doc = context.document?.data ?? {};
34
77
  const isOAP2 = typeof doc.swagger === 'string';
35
- const basePath = context.path || [];
36
78
  const errors = [];
37
79
 
38
80
  const checkSchema = (schema, schemaPath) => {
39
81
  if (!schema || typeof schema !== 'object') {
40
82
  errors.push({
41
- message: `OAR038: 201 response must define a schema with a '${dataProperty}' or 'error' property.`,
83
+ message: `OAR038: 201 response must define a schema with a ${allowedText} property.`,
42
84
  path: schemaPath,
43
85
  });
44
86
  return;
@@ -49,16 +91,16 @@ module.exports = (given, options, context) => {
49
91
 
50
92
  if (propNames.length === 0) {
51
93
  errors.push({
52
- message: `OAR038: Response schema must have properties named '${dataProperty}' or 'error'.`,
94
+ message: `OAR038: Response schema must have properties named ${allowedText}.`,
53
95
  path: schemaPath,
54
96
  });
55
97
  return;
56
98
  }
57
99
 
58
100
  for (const propName of propNames) {
59
- if (!VALID_NAMES.includes(propName)) {
101
+ if (!allowedNames.includes(propName)) {
60
102
  errors.push({
61
- message: `OAR038: Response property must be named '${dataProperty}' or 'error'. Got '${propName}'.`,
103
+ message: `OAR038: Response property must be named ${allowedText}. Got '${propName}'.`,
62
104
  path: [...schemaPath, 'properties', propName],
63
105
  });
64
106
  continue;
@@ -71,6 +113,15 @@ module.exports = (given, options, context) => {
71
113
  });
72
114
  }
73
115
  }
116
+
117
+ for (const requiredName of requiredNames) {
118
+ if (!Object.prototype.hasOwnProperty.call(properties, requiredName)) {
119
+ errors.push({
120
+ message: `OAR038: Response schema must define the required property '${requiredName}'.`,
121
+ path: schemaPath,
122
+ });
123
+ }
124
+ }
74
125
  };
75
126
 
76
127
  if (isOAP2) {