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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
.
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
248
|
+
if (isDetailPath(pathKey)) return [];
|
|
249
|
+
if (!pathsIncludeExclude(opts, DEFAULT_PATHS).isEvaluatedPath(pathKey)) return [];
|
|
216
250
|
|
|
217
|
-
const pagingSchema = parsePagingSchema(
|
|
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
|
|
223
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
32
|
-
const
|
|
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
|
|
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
|
|
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 (!
|
|
101
|
+
if (!allowedNames.includes(propName)) {
|
|
60
102
|
errors.push({
|
|
61
|
-
message: `OAR038: Response property must be named
|
|
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) {
|