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 +57 -4
- package/functions/apq-oauth-url-pattern.js +205 -0
- package/functions/apq-paged-response-check.js +3 -32
- package/functions/apq-security-flows-allowed.js +194 -0
- package/functions/apq-security-types-allowed.js +187 -0
- package/functions/paths-include-exclude.js +42 -0
- package/package.json +1 -1
- /package/functions/{apq-string-parameter-integrity.js → apq-string-parameter-schema-integrity.js} +0 -0
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
|
|
853
|
-
message: "OAR075: String
|
|
855
|
+
description: "String parameter schemas should have minLength, maxLength, pattern (regular expression), or enum restriction."
|
|
856
|
+
message: "OAR075: String parameter schemas should have minLength, maxLength, pattern, or enum restriction."
|
|
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
|
-
|
|
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
/package/functions/{apq-string-parameter-integrity.js → apq-string-parameter-schema-integrity.js}
RENAMED
|
File without changes
|