@redocly/cli 2.37.0 → 2.39.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/THIRD_PARTY_NOTICES +1 -1
- package/lib/chunks/{IVW4MILQ.js → 2LEVMD2A.js} +3 -3
- package/lib/chunks/{YCTAWG2U.js → 4FB5ZIPP.js} +1 -1
- package/lib/chunks/BA5AZJUM.js +681 -0
- package/lib/chunks/BNHQNMUG.js +525 -0
- package/lib/chunks/{LJUXYC4S.js → C3PXBQU5.js} +1 -1
- package/lib/chunks/C5HID4LT.js +571 -0
- package/lib/chunks/{CPNIO4J3.js → KYZEVOA2.js} +1679 -1400
- package/lib/chunks/{FRVYIHIR.js → OVY6O6WL.js} +5 -3
- package/lib/chunks/{4UQ4YHWW.js → OYZMAAZV.js} +5 -10
- package/lib/chunks/U4V3W7MN.js +17 -0
- package/lib/chunks/UUU33DK3.js +125 -0
- package/lib/chunks/{M7TYKZDD.js → V6CVT3XE.js} +6 -4
- package/lib/chunks/{QFYLVVG2.js → V74PIVJZ.js} +8 -5
- package/lib/chunks/{ZHITMP73.js → W5R2GYDZ.js} +3 -2
- package/lib/chunks/WST4KAJO.js +2788 -0
- package/lib/chunks/{U3LS66ZD.js → XB6C62FW.js} +100 -771
- package/lib/index.js +157 -14
- package/package.json +1 -1
|
@@ -0,0 +1,2788 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from 'node:module';
|
|
2
|
+
const require = __createRequire(import.meta.url);
|
|
3
|
+
import {
|
|
4
|
+
BaseResolver,
|
|
5
|
+
blue,
|
|
6
|
+
bold,
|
|
7
|
+
bundle,
|
|
8
|
+
cyan,
|
|
9
|
+
detectSpec,
|
|
10
|
+
dim,
|
|
11
|
+
getMajorSpecVersion,
|
|
12
|
+
getTypes,
|
|
13
|
+
gray,
|
|
14
|
+
green,
|
|
15
|
+
isPlainObject,
|
|
16
|
+
logger,
|
|
17
|
+
normalizeTypes,
|
|
18
|
+
normalizeVisitors,
|
|
19
|
+
red,
|
|
20
|
+
require__,
|
|
21
|
+
require_dist,
|
|
22
|
+
walkDocument,
|
|
23
|
+
yellow
|
|
24
|
+
} from "./KYZEVOA2.js";
|
|
25
|
+
import {
|
|
26
|
+
__toESM
|
|
27
|
+
} from "./5ILQMFXK.js";
|
|
28
|
+
|
|
29
|
+
// src/commands/drift/engine/reporter.ts
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
|
|
32
|
+
// src/commands/drift/utils/finding-groups.ts
|
|
33
|
+
var KEY_SEPARATOR = "";
|
|
34
|
+
function normalizePart(value) {
|
|
35
|
+
if (!value) {
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
return value.trim();
|
|
39
|
+
}
|
|
40
|
+
function createProblemKey(finding) {
|
|
41
|
+
const operationOrPath = normalizePart(finding.operationId) || normalizePart(finding.path);
|
|
42
|
+
return [
|
|
43
|
+
normalizePart(finding.ruleId),
|
|
44
|
+
normalizePart(finding.severity),
|
|
45
|
+
normalizePart(finding.message),
|
|
46
|
+
operationOrPath,
|
|
47
|
+
normalizePart(finding.target),
|
|
48
|
+
normalizePart(finding.schemaPath)
|
|
49
|
+
].join(KEY_SEPARATOR);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/commands/drift/engine/reporter.ts
|
|
53
|
+
var cyanBold = (text) => bold(cyan(text));
|
|
54
|
+
function mapSeverityToSarifLevel(severity) {
|
|
55
|
+
if (severity === "error") return "error";
|
|
56
|
+
if (severity === "warning") return "warning";
|
|
57
|
+
return "note";
|
|
58
|
+
}
|
|
59
|
+
function escapeCsvCell(value) {
|
|
60
|
+
if (value === void 0 || value === null) {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
const stringValue = String(value);
|
|
64
|
+
if (!stringValue.includes(",") && !stringValue.includes('"') && !stringValue.includes("\n")) {
|
|
65
|
+
return stringValue;
|
|
66
|
+
}
|
|
67
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
68
|
+
}
|
|
69
|
+
function toRelativeSpecPath(specSource) {
|
|
70
|
+
if (!path.isAbsolute(specSource)) {
|
|
71
|
+
return specSource;
|
|
72
|
+
}
|
|
73
|
+
const relativePath = path.relative(process.cwd(), specSource);
|
|
74
|
+
return relativePath || ".";
|
|
75
|
+
}
|
|
76
|
+
function getOperationTemplatePath(details) {
|
|
77
|
+
if (!details) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const maybePath = details.operationPathTemplate;
|
|
81
|
+
return typeof maybePath === "string" && maybePath.length > 0 ? maybePath : null;
|
|
82
|
+
}
|
|
83
|
+
function sortRuleCounts(findingsByRule) {
|
|
84
|
+
return Object.entries(findingsByRule).sort((a, b) => {
|
|
85
|
+
const countDiff = b[1] - a[1];
|
|
86
|
+
if (countDiff !== 0) {
|
|
87
|
+
return countDiff;
|
|
88
|
+
}
|
|
89
|
+
return a[0].localeCompare(b[0]);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function groupFindingsIntoProblems(findings) {
|
|
93
|
+
const problems = [];
|
|
94
|
+
const keyToIndex = /* @__PURE__ */ new Map();
|
|
95
|
+
for (const finding of findings) {
|
|
96
|
+
const key = createProblemKey({
|
|
97
|
+
ruleId: finding.ruleId,
|
|
98
|
+
severity: finding.severity,
|
|
99
|
+
message: finding.message,
|
|
100
|
+
operationId: finding.operationId,
|
|
101
|
+
path: finding.path,
|
|
102
|
+
target: finding.target,
|
|
103
|
+
schemaPath: finding.schemaPath
|
|
104
|
+
});
|
|
105
|
+
const existingIndex = keyToIndex.get(key);
|
|
106
|
+
if (existingIndex !== void 0) {
|
|
107
|
+
problems[existingIndex].occurrences += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
keyToIndex.set(key, problems.length);
|
|
111
|
+
problems.push({ finding, occurrences: 1 });
|
|
112
|
+
}
|
|
113
|
+
return problems;
|
|
114
|
+
}
|
|
115
|
+
function formatJson(result) {
|
|
116
|
+
const problems = groupFindingsIntoProblems(result.findings).map((problem) => ({
|
|
117
|
+
occurrences: problem.occurrences,
|
|
118
|
+
exchangeIndex: problem.finding.exchangeIndex,
|
|
119
|
+
ruleId: problem.finding.ruleId,
|
|
120
|
+
severity: problem.finding.severity,
|
|
121
|
+
category: problem.finding.category,
|
|
122
|
+
message: problem.finding.message,
|
|
123
|
+
operationId: problem.finding.operationId,
|
|
124
|
+
specSource: problem.finding.specSource,
|
|
125
|
+
target: problem.finding.target,
|
|
126
|
+
schemaPath: problem.finding.schemaPath,
|
|
127
|
+
dataPath: problem.finding.dataPath,
|
|
128
|
+
details: problem.finding.details,
|
|
129
|
+
method: problem.finding.method,
|
|
130
|
+
url: problem.finding.url,
|
|
131
|
+
path: problem.finding.path,
|
|
132
|
+
status: problem.finding.status
|
|
133
|
+
}));
|
|
134
|
+
const payload = {
|
|
135
|
+
run: {
|
|
136
|
+
id: result.runId,
|
|
137
|
+
specSource: result.meta.specSource,
|
|
138
|
+
trafficPath: result.meta.trafficPath,
|
|
139
|
+
format: result.meta.format,
|
|
140
|
+
matchMode: result.meta.matchMode,
|
|
141
|
+
server: result.meta.server,
|
|
142
|
+
totalExchanges: result.summary.totalExchanges,
|
|
143
|
+
documentedExchanges: result.summary.documentedExchanges,
|
|
144
|
+
undocumentedExchanges: result.summary.undocumentedExchanges,
|
|
145
|
+
skippedExchanges: result.summary.skippedExchanges,
|
|
146
|
+
findingsBySeverity: result.summary.findingsBySeverity,
|
|
147
|
+
findingsByRule: result.summary.findingsByRule,
|
|
148
|
+
totalProblems: result.summary.totalProblemGroups,
|
|
149
|
+
durationMs: result.summary.durationMs
|
|
150
|
+
},
|
|
151
|
+
problems
|
|
152
|
+
};
|
|
153
|
+
return `${JSON.stringify(payload, null, 2)}
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
function formatCsv(result) {
|
|
157
|
+
const header = [
|
|
158
|
+
"run_id",
|
|
159
|
+
"finding_id",
|
|
160
|
+
"exchange_index",
|
|
161
|
+
"severity",
|
|
162
|
+
"category",
|
|
163
|
+
"rule_id",
|
|
164
|
+
"message",
|
|
165
|
+
"method",
|
|
166
|
+
"path",
|
|
167
|
+
"url",
|
|
168
|
+
"status",
|
|
169
|
+
"operation_id",
|
|
170
|
+
"spec_source",
|
|
171
|
+
"target",
|
|
172
|
+
"schema_path",
|
|
173
|
+
"data_path"
|
|
174
|
+
];
|
|
175
|
+
const rows = result.findings.map((finding) => [
|
|
176
|
+
result.runId,
|
|
177
|
+
finding.id,
|
|
178
|
+
finding.exchangeIndex,
|
|
179
|
+
finding.severity,
|
|
180
|
+
finding.category,
|
|
181
|
+
finding.ruleId,
|
|
182
|
+
finding.message,
|
|
183
|
+
finding.method,
|
|
184
|
+
finding.path,
|
|
185
|
+
finding.url,
|
|
186
|
+
finding.status,
|
|
187
|
+
finding.operationId,
|
|
188
|
+
finding.specSource,
|
|
189
|
+
finding.target,
|
|
190
|
+
finding.schemaPath,
|
|
191
|
+
finding.dataPath
|
|
192
|
+
]);
|
|
193
|
+
const csvRows = [header, ...rows].map((row) => row.map(escapeCsvCell).join(","));
|
|
194
|
+
return `${csvRows.join("\n")}
|
|
195
|
+
`;
|
|
196
|
+
}
|
|
197
|
+
function formatSarif(result) {
|
|
198
|
+
const ruleSet = /* @__PURE__ */ new Map();
|
|
199
|
+
for (const finding of result.findings) {
|
|
200
|
+
if (!ruleSet.has(finding.ruleId)) {
|
|
201
|
+
ruleSet.set(finding.ruleId, {
|
|
202
|
+
id: finding.ruleId,
|
|
203
|
+
name: finding.ruleId,
|
|
204
|
+
shortDescription: { text: `${finding.category} detection` }
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const results = result.findings.map((finding) => {
|
|
209
|
+
const locationUri = finding.specSource ?? finding.url ?? void 0;
|
|
210
|
+
return {
|
|
211
|
+
ruleId: finding.ruleId,
|
|
212
|
+
level: mapSeverityToSarifLevel(finding.severity),
|
|
213
|
+
message: { text: finding.message },
|
|
214
|
+
locations: locationUri ? [{ physicalLocation: { artifactLocation: { uri: locationUri } } }] : void 0,
|
|
215
|
+
properties: {
|
|
216
|
+
runId: result.runId,
|
|
217
|
+
exchangeIndex: finding.exchangeIndex,
|
|
218
|
+
method: finding.method,
|
|
219
|
+
path: finding.path,
|
|
220
|
+
status: finding.status,
|
|
221
|
+
operationId: finding.operationId,
|
|
222
|
+
target: finding.target,
|
|
223
|
+
schemaPath: finding.schemaPath,
|
|
224
|
+
dataPath: finding.dataPath
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const sarifPayload = {
|
|
229
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
230
|
+
version: "2.1.0",
|
|
231
|
+
runs: [{ tool: { driver: { name: "drift", rules: Array.from(ruleSet.values()) } }, results }]
|
|
232
|
+
};
|
|
233
|
+
return `${JSON.stringify(sarifPayload, null, 2)}
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
function totalFindings(summary) {
|
|
237
|
+
return summary.findingsBySeverity.error + summary.findingsBySeverity.warning + summary.findingsBySeverity.info;
|
|
238
|
+
}
|
|
239
|
+
function statusColor(status) {
|
|
240
|
+
if (status >= 500) return red;
|
|
241
|
+
if (status >= 400) return yellow;
|
|
242
|
+
if (status >= 300) return blue;
|
|
243
|
+
if (status >= 200) return green;
|
|
244
|
+
return gray;
|
|
245
|
+
}
|
|
246
|
+
function formatPretty(result, color, maxFindings) {
|
|
247
|
+
const colorize = (text, paint) => color ? paint(text) : text;
|
|
248
|
+
const severityIcon = (severity) => severity === "error" ? colorize("\u2716", red) : severity === "warning" ? colorize("\u25B2", yellow) : colorize("\u25CF", blue);
|
|
249
|
+
const severityLabel = (severity) => severity === "error" ? colorize("ERROR", red) : severity === "warning" ? colorize("WARN", yellow) : colorize("INFO", blue);
|
|
250
|
+
const { summary, meta } = result;
|
|
251
|
+
const findingsCount = totalFindings(summary);
|
|
252
|
+
const findingsToRender = summary.previewFindings.slice(0, maxFindings);
|
|
253
|
+
const lines = [];
|
|
254
|
+
lines.push(colorize("\u250F\u2501 Drift Report", cyanBold));
|
|
255
|
+
lines.push(`\u2503 Run: ${result.runId}`);
|
|
256
|
+
lines.push(`\u2503 Spec: ${meta.specSource}`);
|
|
257
|
+
lines.push(`\u2503 Traffic: ${meta.trafficPath}`);
|
|
258
|
+
lines.push(
|
|
259
|
+
meta.server ? `\u2503 Server: ${meta.server} (overrides description servers)` : `\u2503 Match mode: ${meta.matchMode}`
|
|
260
|
+
);
|
|
261
|
+
lines.push(`\u2503 Traffic format: ${meta.format}${meta.format === "auto" ? " (auto-detect)" : ""}`);
|
|
262
|
+
lines.push(
|
|
263
|
+
`\u2503 Exchanges: total=${summary.totalExchanges} documented=${summary.documentedExchanges} undocumented=${summary.undocumentedExchanges}${summary.skippedExchanges > 0 ? ` skipped=${summary.skippedExchanges}` : ""}`
|
|
264
|
+
);
|
|
265
|
+
lines.push(
|
|
266
|
+
`\u2503 Findings: total=${findingsCount} ${colorize(`error=${summary.findingsBySeverity.error}`, red)} ${colorize(
|
|
267
|
+
`warning=${summary.findingsBySeverity.warning}`,
|
|
268
|
+
yellow
|
|
269
|
+
)} ${colorize(`info=${summary.findingsBySeverity.info}`, blue)}`
|
|
270
|
+
);
|
|
271
|
+
lines.push(`\u2503 Problems: total=${summary.totalProblemGroups}`);
|
|
272
|
+
lines.push(`\u2517 Duration: ${summary.durationMs}ms`);
|
|
273
|
+
if (findingsCount === 0) {
|
|
274
|
+
lines.push("");
|
|
275
|
+
lines.push(colorize("\u2714 No findings.", cyan));
|
|
276
|
+
return `${lines.join("\n")}
|
|
277
|
+
`;
|
|
278
|
+
}
|
|
279
|
+
lines.push("");
|
|
280
|
+
lines.push(colorize(`Types (${Object.keys(summary.problemGroupsByRule).length})`, cyanBold));
|
|
281
|
+
for (const [ruleId, problemsCount] of sortRuleCounts(summary.problemGroupsByRule)) {
|
|
282
|
+
const findingsForRule = summary.findingsByRule[ruleId] ?? problemsCount;
|
|
283
|
+
lines.push(
|
|
284
|
+
` ${colorize("\u2022", gray)} ${ruleId}: ${problemsCount} problems / ${findingsForRule} findings`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
const groupedFindings = /* @__PURE__ */ new Map();
|
|
288
|
+
for (const finding of findingsToRender) {
|
|
289
|
+
if (!groupedFindings.has(finding.ruleId)) {
|
|
290
|
+
groupedFindings.set(finding.ruleId, []);
|
|
291
|
+
}
|
|
292
|
+
groupedFindings.get(finding.ruleId).push(finding);
|
|
293
|
+
}
|
|
294
|
+
const orderedRuleIds = Array.from(groupedFindings.keys()).sort((a, b) => {
|
|
295
|
+
const countDiff = (summary.problemGroupsByRule[b] ?? 0) - (summary.problemGroupsByRule[a] ?? 0);
|
|
296
|
+
if (countDiff !== 0) {
|
|
297
|
+
return countDiff;
|
|
298
|
+
}
|
|
299
|
+
return a.localeCompare(b);
|
|
300
|
+
});
|
|
301
|
+
lines.push("");
|
|
302
|
+
lines.push(
|
|
303
|
+
colorize(
|
|
304
|
+
`Problems by type (showing first ${findingsToRender.length} of ${summary.totalProblemGroups})`,
|
|
305
|
+
cyanBold
|
|
306
|
+
)
|
|
307
|
+
);
|
|
308
|
+
let renderedIndex = 0;
|
|
309
|
+
for (const ruleId of orderedRuleIds) {
|
|
310
|
+
const group = groupedFindings.get(ruleId);
|
|
311
|
+
if (!group || group.length === 0) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const ruleProblemTotal = summary.problemGroupsByRule[ruleId] ?? group.length;
|
|
315
|
+
const ruleFindingTotal = summary.findingsByRule[ruleId] ?? ruleProblemTotal;
|
|
316
|
+
lines.push("");
|
|
317
|
+
lines.push(
|
|
318
|
+
`${colorize("\u25C9", cyan)} ${colorize(ruleId, cyanBold)} ${colorize(
|
|
319
|
+
`(${group.length} problems shown / ${ruleProblemTotal} total, ${ruleFindingTotal} findings)`,
|
|
320
|
+
gray
|
|
321
|
+
)}`
|
|
322
|
+
);
|
|
323
|
+
for (const finding of group) {
|
|
324
|
+
renderedIndex += 1;
|
|
325
|
+
const occurrences = finding.occurrences;
|
|
326
|
+
const statusText = finding.status !== void 0 ? colorize(String(finding.status), statusColor(finding.status)) : colorize("-", gray);
|
|
327
|
+
const displayPath = getOperationTemplatePath(finding.details) ?? finding.path;
|
|
328
|
+
const operationLabel = finding.operationId ? ` ${colorize(finding.operationId, dim)}` : "";
|
|
329
|
+
lines.push(
|
|
330
|
+
`${severityIcon(finding.severity)} ${severityLabel(finding.severity)} #${renderedIndex}${occurrences > 1 ? ` ${colorize(`\xD7${occurrences}`, gray)}` : ""} ${colorize("\u2192", gray)} ${finding.message}`
|
|
331
|
+
);
|
|
332
|
+
lines.push(
|
|
333
|
+
` \u21B3 sample exchange=${finding.exchangeIndex} ${colorize(finding.method, cyan)} ${displayPath} (${statusText})${operationLabel}`
|
|
334
|
+
);
|
|
335
|
+
lines.push("");
|
|
336
|
+
lines.push(` ${colorize(finding.url, gray)}`);
|
|
337
|
+
if (finding.specSource || finding.schemaPath || finding.dataPath) {
|
|
338
|
+
lines.push("");
|
|
339
|
+
if (finding.specSource) {
|
|
340
|
+
lines.push(
|
|
341
|
+
` ${colorize("spec:", dim)} ${colorize(toRelativeSpecPath(finding.specSource), cyan)}`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
if (finding.schemaPath) {
|
|
345
|
+
lines.push(` ${colorize("schemaPath=", dim)} ${colorize(finding.schemaPath, dim)}`);
|
|
346
|
+
}
|
|
347
|
+
if (finding.dataPath) {
|
|
348
|
+
lines.push(` ${colorize("dataPath=", dim)} ${colorize(finding.dataPath, cyan)}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (finding.details) {
|
|
352
|
+
lines.push("");
|
|
353
|
+
if (finding.ruleId === "security-baseline" && typeof finding.details.summary === "string") {
|
|
354
|
+
lines.push(` ${colorize("security:", dim)} ${finding.details.summary}`);
|
|
355
|
+
} else if (finding.ruleId === "owasp-api-top10" && typeof finding.details.summary === "string") {
|
|
356
|
+
const issueId = typeof finding.details.issueId === "string" ? finding.details.issueId : null;
|
|
357
|
+
const issueTitle = typeof finding.details.issueTitle === "string" ? finding.details.issueTitle : null;
|
|
358
|
+
lines.push(` ${colorize("owasp:", dim)} ${finding.details.summary}`);
|
|
359
|
+
if (issueId || issueTitle) {
|
|
360
|
+
lines.push(
|
|
361
|
+
` ${colorize("issue:", dim)} ${[issueId, issueTitle].filter(Boolean).join(" - ")}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
} else if (finding.ruleId === "schema-consistency" && typeof finding.details.summary === "string") {
|
|
365
|
+
const detailPath = typeof finding.details.path === "string" ? finding.details.path : null;
|
|
366
|
+
const expected = typeof finding.details.expected === "string" ? finding.details.expected : null;
|
|
367
|
+
const actual = typeof finding.details.actual === "string" ? finding.details.actual : null;
|
|
368
|
+
const suggestion = typeof finding.details.suggestion === "string" ? finding.details.suggestion : null;
|
|
369
|
+
lines.push(` ${colorize("schema:", dim)} ${finding.details.summary}`);
|
|
370
|
+
if (detailPath) {
|
|
371
|
+
lines.push(` ${colorize("path:", dim)} ${colorize(detailPath, cyan)}`);
|
|
372
|
+
}
|
|
373
|
+
if (expected) {
|
|
374
|
+
lines.push(` ${colorize("expected:", dim)} ${expected}`);
|
|
375
|
+
}
|
|
376
|
+
if (actual) {
|
|
377
|
+
lines.push(` ${colorize("actual:", dim)} ${actual}`);
|
|
378
|
+
}
|
|
379
|
+
if (suggestion) {
|
|
380
|
+
lines.push(` ${colorize("hint:", dim)}`);
|
|
381
|
+
for (const suggestionLine of suggestion.split("\n")) {
|
|
382
|
+
lines.push(` ${suggestionLine}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
} else {
|
|
386
|
+
lines.push(` ${colorize("details:", dim)} ${JSON.stringify(finding.details)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
lines.push("");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const omittedProblemsCount = summary.totalProblemGroups - findingsToRender.length;
|
|
393
|
+
if (omittedProblemsCount > 0) {
|
|
394
|
+
if (lines[lines.length - 1] !== "") {
|
|
395
|
+
lines.push("");
|
|
396
|
+
}
|
|
397
|
+
lines.push(
|
|
398
|
+
colorize(
|
|
399
|
+
`\u2026 ${omittedProblemsCount} problems omitted from terminal output. Use --format json/csv/sarif for the complete export.`,
|
|
400
|
+
gray
|
|
401
|
+
)
|
|
402
|
+
);
|
|
403
|
+
} else if (lines[lines.length - 1] === "") {
|
|
404
|
+
lines.pop();
|
|
405
|
+
}
|
|
406
|
+
return `${lines.join("\n")}
|
|
407
|
+
`;
|
|
408
|
+
}
|
|
409
|
+
function renderReport(result, options) {
|
|
410
|
+
const maxFindings = options.maxFindings ?? 10;
|
|
411
|
+
switch (options.format) {
|
|
412
|
+
case "json":
|
|
413
|
+
return formatJson(result);
|
|
414
|
+
case "csv":
|
|
415
|
+
return formatCsv(result);
|
|
416
|
+
case "sarif":
|
|
417
|
+
return formatSarif(result);
|
|
418
|
+
case "pretty":
|
|
419
|
+
return formatPretty(result, options.color ?? false, maxFindings);
|
|
420
|
+
default:
|
|
421
|
+
throw new Error(`Unsupported report format: ${options.format}`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/commands/drift/utils/files.ts
|
|
426
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
427
|
+
import path2 from "node:path";
|
|
428
|
+
var SPEC_FILE_EXTENSIONS = /* @__PURE__ */ new Set([".yaml", ".yml", ".json"]);
|
|
429
|
+
async function listOpenApiFiles(rootDir) {
|
|
430
|
+
const output = [];
|
|
431
|
+
async function walk(currentDir) {
|
|
432
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
433
|
+
for (const entry of entries) {
|
|
434
|
+
if (entry.name.startsWith(".")) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
const absolutePath = path2.join(currentDir, entry.name);
|
|
438
|
+
if (entry.isDirectory()) {
|
|
439
|
+
await walk(absolutePath);
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (entry.isFile() && SPEC_FILE_EXTENSIONS.has(path2.extname(entry.name).toLowerCase())) {
|
|
443
|
+
output.push(absolutePath);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
await walk(rootDir);
|
|
448
|
+
output.sort();
|
|
449
|
+
return output;
|
|
450
|
+
}
|
|
451
|
+
async function readProbe(filePath, maxBytes = 4096) {
|
|
452
|
+
const fileHandle = await open(filePath, "r");
|
|
453
|
+
try {
|
|
454
|
+
const buffer = Buffer.allocUnsafe(maxBytes);
|
|
455
|
+
const { bytesRead } = await fileHandle.read(buffer, 0, maxBytes, 0);
|
|
456
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
457
|
+
} finally {
|
|
458
|
+
await fileHandle.close();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
function normalizeFsPath(value) {
|
|
462
|
+
return path2.resolve(process.cwd(), value);
|
|
463
|
+
}
|
|
464
|
+
async function listFilesRecursively(rootPath) {
|
|
465
|
+
const stats = await stat(rootPath);
|
|
466
|
+
if (stats.isFile()) {
|
|
467
|
+
return [rootPath];
|
|
468
|
+
}
|
|
469
|
+
if (!stats.isDirectory()) {
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
const output = [];
|
|
473
|
+
async function walk(currentDir) {
|
|
474
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
475
|
+
for (const entry of entries) {
|
|
476
|
+
if (entry.name.startsWith(".")) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const absolutePath = path2.join(currentDir, entry.name);
|
|
480
|
+
if (entry.isDirectory()) {
|
|
481
|
+
await walk(absolutePath);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
if (entry.isFile()) {
|
|
485
|
+
output.push(absolutePath);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
await walk(rootPath);
|
|
490
|
+
output.sort();
|
|
491
|
+
return output;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// src/commands/drift/engine/validation-session.ts
|
|
495
|
+
import { randomUUID } from "node:crypto";
|
|
496
|
+
|
|
497
|
+
// src/commands/drift/utils/http.ts
|
|
498
|
+
var DUMMY_HOST = "drift.local";
|
|
499
|
+
var DUMMY_BASE_URL = `http://${DUMMY_HOST}`;
|
|
500
|
+
function isSyntheticHost(host) {
|
|
501
|
+
return host === DUMMY_HOST;
|
|
502
|
+
}
|
|
503
|
+
var IGNORED_UNDOCUMENTED_HEADERS = /* @__PURE__ */ new Set([
|
|
504
|
+
"accept",
|
|
505
|
+
"accept-charset",
|
|
506
|
+
"accept-encoding",
|
|
507
|
+
"accept-language",
|
|
508
|
+
"authorization",
|
|
509
|
+
"baggage",
|
|
510
|
+
"cache-control",
|
|
511
|
+
"cdn-loop",
|
|
512
|
+
"cookie",
|
|
513
|
+
"connection",
|
|
514
|
+
"content-length",
|
|
515
|
+
"content-type",
|
|
516
|
+
"dpr",
|
|
517
|
+
"dnt",
|
|
518
|
+
"downlink",
|
|
519
|
+
"ect",
|
|
520
|
+
"forwarded",
|
|
521
|
+
"host",
|
|
522
|
+
"if-match",
|
|
523
|
+
"if-modified-since",
|
|
524
|
+
"if-none-match",
|
|
525
|
+
"if-range",
|
|
526
|
+
"if-unmodified-since",
|
|
527
|
+
"origin",
|
|
528
|
+
"pragma",
|
|
529
|
+
"priority",
|
|
530
|
+
"range",
|
|
531
|
+
"referer",
|
|
532
|
+
"sec-fetch-dest",
|
|
533
|
+
"sec-fetch-mode",
|
|
534
|
+
"sec-fetch-site",
|
|
535
|
+
"sec-fetch-user",
|
|
536
|
+
"sec-gpc",
|
|
537
|
+
"sentry-trace",
|
|
538
|
+
"te",
|
|
539
|
+
"traceparent",
|
|
540
|
+
"tracestate",
|
|
541
|
+
"upgrade",
|
|
542
|
+
"upgrade-insecure-requests",
|
|
543
|
+
"user-agent",
|
|
544
|
+
"via",
|
|
545
|
+
"x-amzn-trace-id",
|
|
546
|
+
"x-client-trace-id",
|
|
547
|
+
"x-cloud-trace-context",
|
|
548
|
+
"x-correlation-id",
|
|
549
|
+
"x-http-method-override",
|
|
550
|
+
"x-method-override",
|
|
551
|
+
"x-real-ip",
|
|
552
|
+
"x-request-id",
|
|
553
|
+
"x-forwarded-for",
|
|
554
|
+
"x-forwarded-host",
|
|
555
|
+
"x-forwarded-port",
|
|
556
|
+
"x-forwarded-proto"
|
|
557
|
+
]);
|
|
558
|
+
var IGNORED_UNDOCUMENTED_HEADER_PREFIXES = [
|
|
559
|
+
"cf-",
|
|
560
|
+
"sec-ch-",
|
|
561
|
+
"sec-fetch-",
|
|
562
|
+
"x-b3-",
|
|
563
|
+
"x-envoy-",
|
|
564
|
+
"x-forwarded-"
|
|
565
|
+
];
|
|
566
|
+
var SET_COOKIE_SEPARATOR = "\n";
|
|
567
|
+
function splitSetCookieHeader(value) {
|
|
568
|
+
return value.split(SET_COOKIE_SEPARATOR);
|
|
569
|
+
}
|
|
570
|
+
function appendHeaderValue(result, name, value) {
|
|
571
|
+
const key = name.toLowerCase();
|
|
572
|
+
const existing = result[key];
|
|
573
|
+
if (existing === void 0) {
|
|
574
|
+
result[key] = value;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
result[key] = key === "set-cookie" ? `${existing}${SET_COOKIE_SEPARATOR}${value}` : `${existing},${value}`;
|
|
578
|
+
}
|
|
579
|
+
function normalizeHeaders(input) {
|
|
580
|
+
if (!isPlainObject(input) && !Array.isArray(input)) {
|
|
581
|
+
return {};
|
|
582
|
+
}
|
|
583
|
+
const result = {};
|
|
584
|
+
if (Array.isArray(input)) {
|
|
585
|
+
for (const item of input) {
|
|
586
|
+
if (!isPlainObject(item)) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const { name, value } = item;
|
|
590
|
+
if (typeof name === "string" && value !== void 0) {
|
|
591
|
+
appendHeaderValue(result, name, String(value));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return result;
|
|
595
|
+
}
|
|
596
|
+
for (const [key, value] of Object.entries(input)) {
|
|
597
|
+
if (value === void 0 || value === null) {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (Array.isArray(value)) {
|
|
601
|
+
for (const item of value) {
|
|
602
|
+
appendHeaderValue(result, key, String(item));
|
|
603
|
+
}
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
appendHeaderValue(result, key, String(value));
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
function parseUrl(input) {
|
|
611
|
+
try {
|
|
612
|
+
return new URL(input);
|
|
613
|
+
} catch {
|
|
614
|
+
return new URL(input, DUMMY_BASE_URL);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function getPathWithoutTrailingSlash(pathname) {
|
|
618
|
+
if (pathname.length > 1 && pathname.endsWith("/")) {
|
|
619
|
+
return pathname.slice(0, -1);
|
|
620
|
+
}
|
|
621
|
+
return pathname;
|
|
622
|
+
}
|
|
623
|
+
function escapeRegex(value) {
|
|
624
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
625
|
+
}
|
|
626
|
+
function compileOpenApiPath(pathTemplate) {
|
|
627
|
+
const params = [];
|
|
628
|
+
let score = 0;
|
|
629
|
+
const absolutePath = pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`;
|
|
630
|
+
const normalizedPath = getPathWithoutTrailingSlash(absolutePath);
|
|
631
|
+
const regexBody = normalizedPath.split("/").map((segment) => {
|
|
632
|
+
if (!segment) {
|
|
633
|
+
return "";
|
|
634
|
+
}
|
|
635
|
+
const paramMatch = segment.match(/^\{([^}]+)\}$/);
|
|
636
|
+
if (paramMatch) {
|
|
637
|
+
params.push(paramMatch[1]);
|
|
638
|
+
return "([^/]+)";
|
|
639
|
+
}
|
|
640
|
+
score += 2;
|
|
641
|
+
return escapeRegex(segment);
|
|
642
|
+
}).join("/");
|
|
643
|
+
return {
|
|
644
|
+
regex: new RegExp(`^${regexBody || "/"}$`),
|
|
645
|
+
params,
|
|
646
|
+
score
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function parseJsonBodyIfPresent(contentType, bodyText) {
|
|
650
|
+
if (!bodyText) {
|
|
651
|
+
return void 0;
|
|
652
|
+
}
|
|
653
|
+
if (!isJsonMime(contentType)) {
|
|
654
|
+
return void 0;
|
|
655
|
+
}
|
|
656
|
+
try {
|
|
657
|
+
return JSON.parse(bodyText);
|
|
658
|
+
} catch {
|
|
659
|
+
return void 0;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
function normalizeContentType(contentType) {
|
|
663
|
+
if (!contentType) {
|
|
664
|
+
return "";
|
|
665
|
+
}
|
|
666
|
+
return contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
667
|
+
}
|
|
668
|
+
function isJsonMime(contentType) {
|
|
669
|
+
const mime = normalizeContentType(contentType);
|
|
670
|
+
return mime === "application/json" || mime.endsWith("+json");
|
|
671
|
+
}
|
|
672
|
+
function pickSchemaByMime(contentMap, contentType) {
|
|
673
|
+
const requestedMime = normalizeContentType(contentType);
|
|
674
|
+
if (!requestedMime) {
|
|
675
|
+
return contentMap["application/json"] ?? contentMap["*/*"];
|
|
676
|
+
}
|
|
677
|
+
if (contentMap[requestedMime] !== void 0) {
|
|
678
|
+
return contentMap[requestedMime];
|
|
679
|
+
}
|
|
680
|
+
const [type, subtype] = requestedMime.split("/");
|
|
681
|
+
if (type && subtype) {
|
|
682
|
+
const wildcardSubtype = `${type}/*`;
|
|
683
|
+
if (contentMap[wildcardSubtype] !== void 0) {
|
|
684
|
+
return contentMap[wildcardSubtype];
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return contentMap["*/*"];
|
|
688
|
+
}
|
|
689
|
+
function shouldIgnoreHeaderAsUndocumented(headerName) {
|
|
690
|
+
const normalizedHeaderName = headerName.toLowerCase();
|
|
691
|
+
if (normalizedHeaderName.startsWith(":")) {
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
return IGNORED_UNDOCUMENTED_HEADERS.has(normalizedHeaderName) || IGNORED_UNDOCUMENTED_HEADER_PREFIXES.some((prefix) => normalizedHeaderName.startsWith(prefix));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/commands/drift/openapi/matcher.ts
|
|
698
|
+
function toRelativePath(requestPath, server) {
|
|
699
|
+
const normalizedRequestPath = getPathWithoutTrailingSlash(requestPath || "/") || "/";
|
|
700
|
+
const normalizedBasePath = getPathWithoutTrailingSlash(server.basePath || "/") || "/";
|
|
701
|
+
if (normalizedBasePath === "/") {
|
|
702
|
+
return normalizedRequestPath;
|
|
703
|
+
}
|
|
704
|
+
if (normalizedRequestPath === normalizedBasePath) {
|
|
705
|
+
return "/";
|
|
706
|
+
}
|
|
707
|
+
if (!normalizedRequestPath.startsWith(`${normalizedBasePath}/`)) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
return normalizedRequestPath.slice(normalizedBasePath.length) || "/";
|
|
711
|
+
}
|
|
712
|
+
function hostMatches(operationServer, requestHost) {
|
|
713
|
+
if (!operationServer.host || !requestHost) {
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
return operationServer.host === requestHost.toLowerCase();
|
|
717
|
+
}
|
|
718
|
+
function decodePathParam(value) {
|
|
719
|
+
try {
|
|
720
|
+
return decodeURIComponent(value);
|
|
721
|
+
} catch {
|
|
722
|
+
return value;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function scoreCandidate(operation, server, mode) {
|
|
726
|
+
const hostScore = mode === "strict-host" ? server.host ? 20 : 5 : 0;
|
|
727
|
+
return operation.pathScore * 10 + hostScore;
|
|
728
|
+
}
|
|
729
|
+
function extractPathParams(operation, pathMatch) {
|
|
730
|
+
const params = {};
|
|
731
|
+
for (let index = 0; index < operation.pathParams.length; index += 1) {
|
|
732
|
+
const paramName = operation.pathParams[index];
|
|
733
|
+
const paramValue = pathMatch[index + 1];
|
|
734
|
+
if (paramName && paramValue !== void 0) {
|
|
735
|
+
params[paramName] = decodePathParam(paramValue);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return params;
|
|
739
|
+
}
|
|
740
|
+
function matchOperationByRelativePath(operationCandidates, relativePath) {
|
|
741
|
+
const normalizedPath = getPathWithoutTrailingSlash(relativePath) || "/";
|
|
742
|
+
for (const operation of operationCandidates) {
|
|
743
|
+
const pathMatch = operation.pathRegex.exec(normalizedPath);
|
|
744
|
+
if (pathMatch) {
|
|
745
|
+
return { operation, pathParams: extractPathParams(operation, pathMatch) };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
function matchOperation(index, exchange, mode, relativePathOverride) {
|
|
751
|
+
const method = exchange.request.method.toLowerCase();
|
|
752
|
+
const operationCandidates = index.operationsByMethod.get(method);
|
|
753
|
+
if (!operationCandidates || operationCandidates.length === 0) {
|
|
754
|
+
return null;
|
|
755
|
+
}
|
|
756
|
+
if (relativePathOverride !== void 0) {
|
|
757
|
+
return matchOperationByRelativePath(operationCandidates, relativePathOverride);
|
|
758
|
+
}
|
|
759
|
+
let bestCandidate = null;
|
|
760
|
+
for (const operation of operationCandidates) {
|
|
761
|
+
for (const server of operation.servers) {
|
|
762
|
+
if (mode === "strict-host" && !hostMatches(server, exchange.request.host)) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const relativePath = toRelativePath(exchange.request.path, server);
|
|
766
|
+
if (!relativePath) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const pathMatch = operation.pathRegex.exec(relativePath);
|
|
770
|
+
if (!pathMatch) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const candidate = {
|
|
774
|
+
score: scoreCandidate(operation, server, mode),
|
|
775
|
+
matched: {
|
|
776
|
+
operation,
|
|
777
|
+
pathParams: extractPathParams(operation, pathMatch)
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
if (!bestCandidate || candidate.score > bestCandidate.score) {
|
|
781
|
+
bestCandidate = candidate;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return bestCandidate?.matched ?? null;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// src/commands/drift/rules/builtins/owasp.ts
|
|
789
|
+
var MAX_EXPOSED_PATHS = 6;
|
|
790
|
+
var MAX_SCAN_NODES = 2e3;
|
|
791
|
+
var MAX_SCAN_ARRAY_ITEMS = 50;
|
|
792
|
+
var MAX_SENSITIVE_QUERY_PARAMS = 5;
|
|
793
|
+
var LARGE_RESPONSE_THRESHOLD_BYTES = 1e6;
|
|
794
|
+
var SENSITIVE_QUERY_KEY_PATTERN = /(?:token|api[_-]?key|apikey|password|passwd|secret|authorization|jwt)/i;
|
|
795
|
+
var SENSITIVE_RESPONSE_KEY_PATTERN = /(?:password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token|private[_-]?key|ssn|credit[_-]?card|card[_-]?number|cvv)/i;
|
|
796
|
+
var PAGINATION_QUERY_KEYS = /* @__PURE__ */ new Set([
|
|
797
|
+
"page",
|
|
798
|
+
"limit",
|
|
799
|
+
"offset",
|
|
800
|
+
"cursor",
|
|
801
|
+
"per_page",
|
|
802
|
+
"perpage",
|
|
803
|
+
"size",
|
|
804
|
+
"start",
|
|
805
|
+
"after",
|
|
806
|
+
"before"
|
|
807
|
+
]);
|
|
808
|
+
var MASK_REVEALED_PREFIX_LENGTH = 4;
|
|
809
|
+
var MASK_FULL_THRESHOLD = MASK_REVEALED_PREFIX_LENGTH * 2;
|
|
810
|
+
function maskValue(value) {
|
|
811
|
+
if (value.length <= MASK_FULL_THRESHOLD) {
|
|
812
|
+
return "***";
|
|
813
|
+
}
|
|
814
|
+
return `${value.slice(0, MASK_REVEALED_PREFIX_LENGTH)}\u2026`;
|
|
815
|
+
}
|
|
816
|
+
function normalizeJsonPointerSegment(segment) {
|
|
817
|
+
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
818
|
+
}
|
|
819
|
+
function looksLikePrimitive(value) {
|
|
820
|
+
return typeof value === "string" || typeof value === "number";
|
|
821
|
+
}
|
|
822
|
+
function isFlagLikeSensitiveField(key) {
|
|
823
|
+
const lower = key.toLowerCase();
|
|
824
|
+
if (!(lower.includes("secret") || lower.includes("token") || lower.includes("key"))) {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
return lower.startsWith("is") || lower.startsWith("has") || lower.startsWith("can") || lower.startsWith("should") || lower.startsWith("enable") || lower.startsWith("allow");
|
|
828
|
+
}
|
|
829
|
+
function isBooleanLikeValue(value) {
|
|
830
|
+
if (typeof value === "number") {
|
|
831
|
+
return value === 0 || value === 1;
|
|
832
|
+
}
|
|
833
|
+
const normalized = value.trim().toLowerCase();
|
|
834
|
+
return normalized === "true" || normalized === "false" || normalized === "0" || normalized === "1";
|
|
835
|
+
}
|
|
836
|
+
function findSensitiveResponsePaths(payload) {
|
|
837
|
+
if (!isPlainObject(payload) && !Array.isArray(payload)) {
|
|
838
|
+
return [];
|
|
839
|
+
}
|
|
840
|
+
const exposedPaths = [];
|
|
841
|
+
const queue = [{ value: payload, path: "" }];
|
|
842
|
+
let visitedNodes = 0;
|
|
843
|
+
while (queue.length > 0 && visitedNodes < MAX_SCAN_NODES && exposedPaths.length < MAX_EXPOSED_PATHS) {
|
|
844
|
+
const current = queue.pop();
|
|
845
|
+
if (!current) {
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
visitedNodes += 1;
|
|
849
|
+
if (Array.isArray(current.value)) {
|
|
850
|
+
const limit = Math.min(current.value.length, MAX_SCAN_ARRAY_ITEMS);
|
|
851
|
+
for (let index = 0; index < limit; index += 1) {
|
|
852
|
+
const item = current.value[index];
|
|
853
|
+
if (isPlainObject(item) || Array.isArray(item)) {
|
|
854
|
+
queue.push({ value: item, path: `${current.path}/${index}` });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (!isPlainObject(current.value)) {
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
for (const [key, value] of Object.entries(current.value)) {
|
|
863
|
+
const pointerPath = `${current.path}/${normalizeJsonPointerSegment(key)}`;
|
|
864
|
+
if (SENSITIVE_RESPONSE_KEY_PATTERN.test(key) && looksLikePrimitive(value)) {
|
|
865
|
+
const stringified = String(value);
|
|
866
|
+
if (isFlagLikeSensitiveField(key) && isBooleanLikeValue(value)) {
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
if (stringified.length > 0) {
|
|
870
|
+
exposedPaths.push(pointerPath || "/");
|
|
871
|
+
if (exposedPaths.length >= MAX_EXPOSED_PATHS) {
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (isPlainObject(value) || Array.isArray(value)) {
|
|
877
|
+
queue.push({ value, path: pointerPath });
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return exposedPaths;
|
|
882
|
+
}
|
|
883
|
+
function hasPaginationHint(query) {
|
|
884
|
+
for (const key of query.keys()) {
|
|
885
|
+
if (PAGINATION_QUERY_KEYS.has(key.toLowerCase())) {
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
function buildSecurityFinding(context, message, target, severity, details) {
|
|
892
|
+
return {
|
|
893
|
+
ruleId: "owasp-api-top10",
|
|
894
|
+
severity,
|
|
895
|
+
category: "security",
|
|
896
|
+
message,
|
|
897
|
+
exchangeIndex: context.exchange.index,
|
|
898
|
+
operationId: context.matchedOperation?.operation.operationId,
|
|
899
|
+
specSource: context.matchedOperation?.operation.specSource,
|
|
900
|
+
target,
|
|
901
|
+
details
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
function detectSensitiveQueryParams(context) {
|
|
905
|
+
const matches = [];
|
|
906
|
+
for (const [key, value] of context.exchange.request.query.entries()) {
|
|
907
|
+
if (!SENSITIVE_QUERY_KEY_PATTERN.test(key)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
matches.push({
|
|
911
|
+
key,
|
|
912
|
+
valuePreview: maskValue(value)
|
|
913
|
+
});
|
|
914
|
+
if (matches.length >= MAX_SENSITIVE_QUERY_PARAMS) {
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
if (matches.length === 0) {
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
return [
|
|
922
|
+
buildSecurityFinding(
|
|
923
|
+
context,
|
|
924
|
+
"Sensitive credential-like query parameters detected",
|
|
925
|
+
"request",
|
|
926
|
+
"warning",
|
|
927
|
+
{
|
|
928
|
+
issueId: "API2:2023",
|
|
929
|
+
issueTitle: "Broken Authentication",
|
|
930
|
+
summary: "Authentication data should not be passed via query parameters because it can leak via logs, browser history, and referrers.",
|
|
931
|
+
sensitiveQueryParams: matches
|
|
932
|
+
}
|
|
933
|
+
)
|
|
934
|
+
];
|
|
935
|
+
}
|
|
936
|
+
function detectInsecureCors(context) {
|
|
937
|
+
const response = context.exchange.response;
|
|
938
|
+
if (!response) {
|
|
939
|
+
return [];
|
|
940
|
+
}
|
|
941
|
+
const allowOrigin = response.headers["access-control-allow-origin"]?.trim();
|
|
942
|
+
const allowCredentials = response.headers["access-control-allow-credentials"]?.trim().toLowerCase();
|
|
943
|
+
if (allowOrigin !== "*" || allowCredentials !== "true") {
|
|
944
|
+
return [];
|
|
945
|
+
}
|
|
946
|
+
return [
|
|
947
|
+
buildSecurityFinding(
|
|
948
|
+
context,
|
|
949
|
+
"Insecure CORS policy: wildcard origin with credentials enabled",
|
|
950
|
+
"response",
|
|
951
|
+
"error",
|
|
952
|
+
{
|
|
953
|
+
issueId: "API8:2023",
|
|
954
|
+
issueTitle: "Security Misconfiguration",
|
|
955
|
+
summary: 'Access-Control-Allow-Origin is "*" while Access-Control-Allow-Credentials is true. Browsers may expose authenticated API responses to untrusted origins.',
|
|
956
|
+
headers: {
|
|
957
|
+
accessControlAllowOrigin: allowOrigin,
|
|
958
|
+
accessControlAllowCredentials: allowCredentials
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
)
|
|
962
|
+
];
|
|
963
|
+
}
|
|
964
|
+
function detectWeakCookieAttributes(context) {
|
|
965
|
+
const response = context.exchange.response;
|
|
966
|
+
if (!response) {
|
|
967
|
+
return [];
|
|
968
|
+
}
|
|
969
|
+
const setCookie = response.headers["set-cookie"];
|
|
970
|
+
if (!setCookie) {
|
|
971
|
+
return [];
|
|
972
|
+
}
|
|
973
|
+
const findings = [];
|
|
974
|
+
for (const cookie of splitSetCookieHeader(setCookie)) {
|
|
975
|
+
const attributeNames = new Set(
|
|
976
|
+
cookie.split(";").slice(1).map((attribute) => attribute.split("=")[0].trim().toLowerCase())
|
|
977
|
+
);
|
|
978
|
+
if (context.exchange.request.protocol === "https:" && !attributeNames.has("secure")) {
|
|
979
|
+
findings.push(
|
|
980
|
+
buildSecurityFinding(
|
|
981
|
+
context,
|
|
982
|
+
'Set-Cookie header is missing "Secure" attribute',
|
|
983
|
+
"response",
|
|
984
|
+
"warning",
|
|
985
|
+
{
|
|
986
|
+
issueId: "API2:2023",
|
|
987
|
+
issueTitle: "Broken Authentication",
|
|
988
|
+
summary: 'Session cookies over HTTPS should include "Secure" to prevent transmission over plain HTTP.',
|
|
989
|
+
setCookiePreview: cookie.slice(0, 200)
|
|
990
|
+
}
|
|
991
|
+
)
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
if (!attributeNames.has("httponly")) {
|
|
995
|
+
findings.push(
|
|
996
|
+
buildSecurityFinding(
|
|
997
|
+
context,
|
|
998
|
+
'Set-Cookie header is missing "HttpOnly" attribute',
|
|
999
|
+
"response",
|
|
1000
|
+
"warning",
|
|
1001
|
+
{
|
|
1002
|
+
issueId: "API2:2023",
|
|
1003
|
+
issueTitle: "Broken Authentication",
|
|
1004
|
+
summary: 'Session cookies should include "HttpOnly" to reduce risk of token theft via client-side script access.',
|
|
1005
|
+
setCookiePreview: cookie.slice(0, 200)
|
|
1006
|
+
}
|
|
1007
|
+
)
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
if (!attributeNames.has("samesite")) {
|
|
1011
|
+
findings.push(
|
|
1012
|
+
buildSecurityFinding(
|
|
1013
|
+
context,
|
|
1014
|
+
'Set-Cookie header is missing "SameSite" attribute',
|
|
1015
|
+
"response",
|
|
1016
|
+
"info",
|
|
1017
|
+
{
|
|
1018
|
+
issueId: "API8:2023",
|
|
1019
|
+
issueTitle: "Security Misconfiguration",
|
|
1020
|
+
summary: 'Set "SameSite" on session cookies to reduce CSRF risk for state-changing requests.',
|
|
1021
|
+
setCookiePreview: cookie.slice(0, 200)
|
|
1022
|
+
}
|
|
1023
|
+
)
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return findings;
|
|
1028
|
+
}
|
|
1029
|
+
function detectSensitiveResponseData(context) {
|
|
1030
|
+
const response = context.exchange.response;
|
|
1031
|
+
if (!response || response.bodyJson === void 0) {
|
|
1032
|
+
return [];
|
|
1033
|
+
}
|
|
1034
|
+
const exposedPaths = findSensitiveResponsePaths(response.bodyJson);
|
|
1035
|
+
if (exposedPaths.length === 0) {
|
|
1036
|
+
return [];
|
|
1037
|
+
}
|
|
1038
|
+
return [
|
|
1039
|
+
buildSecurityFinding(
|
|
1040
|
+
context,
|
|
1041
|
+
"Potential sensitive data exposure in response payload",
|
|
1042
|
+
"response",
|
|
1043
|
+
"warning",
|
|
1044
|
+
{
|
|
1045
|
+
issueId: "API3:2019",
|
|
1046
|
+
issueTitle: "Excessive Data Exposure",
|
|
1047
|
+
summary: `Sensitive-looking fields detected in response payload: ${exposedPaths.join(", ")}`,
|
|
1048
|
+
exposedPaths
|
|
1049
|
+
}
|
|
1050
|
+
)
|
|
1051
|
+
];
|
|
1052
|
+
}
|
|
1053
|
+
function detectLargeUnpaginatedResponses(context) {
|
|
1054
|
+
const response = context.exchange.response;
|
|
1055
|
+
if (!response || response.status >= 400 || context.exchange.request.method !== "GET") {
|
|
1056
|
+
return [];
|
|
1057
|
+
}
|
|
1058
|
+
if (!response.bodyText || response.bodyText.length < LARGE_RESPONSE_THRESHOLD_BYTES) {
|
|
1059
|
+
return [];
|
|
1060
|
+
}
|
|
1061
|
+
if (hasPaginationHint(context.exchange.request.query)) {
|
|
1062
|
+
return [];
|
|
1063
|
+
}
|
|
1064
|
+
return [
|
|
1065
|
+
buildSecurityFinding(
|
|
1066
|
+
context,
|
|
1067
|
+
"Large response without pagination parameters (possible resource-consumption risk)",
|
|
1068
|
+
"response",
|
|
1069
|
+
"info",
|
|
1070
|
+
{
|
|
1071
|
+
issueId: "API4:2023",
|
|
1072
|
+
issueTitle: "Unrestricted Resource Consumption",
|
|
1073
|
+
summary: "A large response was returned without common pagination query parameters. Consider explicit page/limit controls for large collections.",
|
|
1074
|
+
responseBytes: response.bodyText.length
|
|
1075
|
+
}
|
|
1076
|
+
)
|
|
1077
|
+
];
|
|
1078
|
+
}
|
|
1079
|
+
var OwaspApiTop10Rule = class {
|
|
1080
|
+
id = "owasp-api-top10";
|
|
1081
|
+
analyze(context) {
|
|
1082
|
+
if (!context.matchedOperation) {
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
return [
|
|
1086
|
+
...detectSensitiveQueryParams(context),
|
|
1087
|
+
...detectInsecureCors(context),
|
|
1088
|
+
...detectWeakCookieAttributes(context),
|
|
1089
|
+
...detectSensitiveResponseData(context),
|
|
1090
|
+
...detectLargeUnpaginatedResponses(context)
|
|
1091
|
+
];
|
|
1092
|
+
}
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
// src/commands/drift/rules/builtins/schema.ts
|
|
1096
|
+
var MAX_ACTUAL_VALUE_LENGTH = 200;
|
|
1097
|
+
function hasBodyContent(bodyText) {
|
|
1098
|
+
return bodyText !== void 0 && bodyText !== "";
|
|
1099
|
+
}
|
|
1100
|
+
function parseCookies(headerValue) {
|
|
1101
|
+
if (!headerValue) {
|
|
1102
|
+
return {};
|
|
1103
|
+
}
|
|
1104
|
+
const cookies = {};
|
|
1105
|
+
for (const pair of headerValue.split(";")) {
|
|
1106
|
+
const [rawName, ...rawValueParts] = pair.trim().split("=");
|
|
1107
|
+
if (!rawName) {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const value = rawValueParts.join("=").trim();
|
|
1111
|
+
cookies[rawName] = value;
|
|
1112
|
+
}
|
|
1113
|
+
return cookies;
|
|
1114
|
+
}
|
|
1115
|
+
function decodeJsonPointerSegment(segment) {
|
|
1116
|
+
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1117
|
+
}
|
|
1118
|
+
function encodeJsonPointerSegment(segment) {
|
|
1119
|
+
return segment.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1120
|
+
}
|
|
1121
|
+
function normalizeJsonPointer(pointer) {
|
|
1122
|
+
if (!pointer || pointer === "#") {
|
|
1123
|
+
return "";
|
|
1124
|
+
}
|
|
1125
|
+
if (pointer.startsWith("#")) {
|
|
1126
|
+
return pointer.slice(1);
|
|
1127
|
+
}
|
|
1128
|
+
return pointer;
|
|
1129
|
+
}
|
|
1130
|
+
function jsonPointerSegments(pointer) {
|
|
1131
|
+
const normalized = normalizeJsonPointer(pointer);
|
|
1132
|
+
if (!normalized || normalized === "/") {
|
|
1133
|
+
return [];
|
|
1134
|
+
}
|
|
1135
|
+
return normalized.split("/").slice(1).map((segment) => decodeJsonPointerSegment(segment));
|
|
1136
|
+
}
|
|
1137
|
+
function joinJsonPointer(basePath, segment) {
|
|
1138
|
+
const baseSegments = jsonPointerSegments(basePath);
|
|
1139
|
+
return `/${[...baseSegments, encodeJsonPointerSegment(segment)].join("/")}`;
|
|
1140
|
+
}
|
|
1141
|
+
function getValueAtJsonPointer(root, pointer) {
|
|
1142
|
+
const segments = jsonPointerSegments(pointer);
|
|
1143
|
+
let current = root;
|
|
1144
|
+
for (const segment of segments) {
|
|
1145
|
+
if (Array.isArray(current)) {
|
|
1146
|
+
const index = Number.parseInt(segment, 10);
|
|
1147
|
+
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
|
|
1148
|
+
return void 0;
|
|
1149
|
+
}
|
|
1150
|
+
current = current[index];
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
if (!isPlainObject(current)) {
|
|
1154
|
+
return void 0;
|
|
1155
|
+
}
|
|
1156
|
+
current = current[segment];
|
|
1157
|
+
}
|
|
1158
|
+
return current;
|
|
1159
|
+
}
|
|
1160
|
+
function normalizeDataPathForError(error) {
|
|
1161
|
+
const basePath = error.instancePath ?? "";
|
|
1162
|
+
const params = error.params ?? {};
|
|
1163
|
+
if (error.keyword === "required" && typeof params.missingProperty === "string") {
|
|
1164
|
+
return joinJsonPointer(basePath, params.missingProperty);
|
|
1165
|
+
}
|
|
1166
|
+
if (error.keyword === "additionalProperties" && typeof params.additionalProperty === "string") {
|
|
1167
|
+
return joinJsonPointer(basePath, params.additionalProperty);
|
|
1168
|
+
}
|
|
1169
|
+
return basePath;
|
|
1170
|
+
}
|
|
1171
|
+
function renderReadablePath(pointer) {
|
|
1172
|
+
const segments = jsonPointerSegments(pointer);
|
|
1173
|
+
if (segments.length === 0) {
|
|
1174
|
+
return "root";
|
|
1175
|
+
}
|
|
1176
|
+
const pathParts = [];
|
|
1177
|
+
for (const segment of segments) {
|
|
1178
|
+
if (/^\d+$/.test(segment)) {
|
|
1179
|
+
pathParts.push(`[${segment}]`);
|
|
1180
|
+
} else if (pathParts.length === 0) {
|
|
1181
|
+
pathParts.push(segment);
|
|
1182
|
+
} else {
|
|
1183
|
+
pathParts.push(`.${segment}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
return pathParts.join("");
|
|
1187
|
+
}
|
|
1188
|
+
function compactActualValue(value) {
|
|
1189
|
+
if (value === void 0) {
|
|
1190
|
+
return "[missing]";
|
|
1191
|
+
}
|
|
1192
|
+
const serialized = JSON.stringify(value);
|
|
1193
|
+
if (!serialized) {
|
|
1194
|
+
return String(value);
|
|
1195
|
+
}
|
|
1196
|
+
return serialized.length > MAX_ACTUAL_VALUE_LENGTH ? `${serialized.slice(0, MAX_ACTUAL_VALUE_LENGTH)}\u2026` : serialized;
|
|
1197
|
+
}
|
|
1198
|
+
function createExpectedHint(error) {
|
|
1199
|
+
const params = error.params ?? {};
|
|
1200
|
+
if (error.keyword === "required" && typeof params.missingProperty === "string") {
|
|
1201
|
+
return `required field "${params.missingProperty}"`;
|
|
1202
|
+
}
|
|
1203
|
+
if (error.keyword === "type" && typeof params.type === "string") {
|
|
1204
|
+
return `type "${params.type}"`;
|
|
1205
|
+
}
|
|
1206
|
+
if (error.keyword === "additionalProperties") {
|
|
1207
|
+
return "no additional undocumented properties";
|
|
1208
|
+
}
|
|
1209
|
+
if (error.keyword === "format" && typeof params.format === "string") {
|
|
1210
|
+
return `format "${params.format}"`;
|
|
1211
|
+
}
|
|
1212
|
+
return void 0;
|
|
1213
|
+
}
|
|
1214
|
+
function createFallbackSchemaErrorSummary(target, error, highlightedDataPath) {
|
|
1215
|
+
const targetLabel = target === "request" ? "Request" : "Response";
|
|
1216
|
+
const params = error.params ?? {};
|
|
1217
|
+
const location = renderReadablePath(highlightedDataPath);
|
|
1218
|
+
if (error.keyword === "required" && typeof params.missingProperty === "string") {
|
|
1219
|
+
return `${targetLabel} is missing required field "${location}".`;
|
|
1220
|
+
}
|
|
1221
|
+
if (error.keyword === "additionalProperties" && typeof params.additionalProperty === "string") {
|
|
1222
|
+
return `${targetLabel} has undocumented field "${location}".`;
|
|
1223
|
+
}
|
|
1224
|
+
if (error.keyword === "type" && typeof params.type === "string") {
|
|
1225
|
+
return `${targetLabel} field "${location}" must be ${params.type}.`;
|
|
1226
|
+
}
|
|
1227
|
+
if (error.keyword === "enum") {
|
|
1228
|
+
return `${targetLabel} field "${location}" has a value outside allowed enum values.`;
|
|
1229
|
+
}
|
|
1230
|
+
if (error.keyword === "format" && typeof params.format === "string") {
|
|
1231
|
+
return `${targetLabel} field "${location}" does not match format "${params.format}".`;
|
|
1232
|
+
}
|
|
1233
|
+
return `${targetLabel} schema mismatch at "${location}": ${error.message ?? "validation error"}`;
|
|
1234
|
+
}
|
|
1235
|
+
function createSchemaErrorDetails(value, error, target) {
|
|
1236
|
+
const highlightedDataPath = normalizeDataPathForError(error);
|
|
1237
|
+
const actualValue = getValueAtJsonPointer(value, highlightedDataPath);
|
|
1238
|
+
const summary = createFallbackSchemaErrorSummary(target, error, highlightedDataPath);
|
|
1239
|
+
return {
|
|
1240
|
+
summary,
|
|
1241
|
+
keyword: error.keyword ?? null,
|
|
1242
|
+
path: highlightedDataPath || "/",
|
|
1243
|
+
expected: createExpectedHint(error) ?? null,
|
|
1244
|
+
actual: compactActualValue(actualValue),
|
|
1245
|
+
suggestion: null,
|
|
1246
|
+
params: error.params ?? {},
|
|
1247
|
+
ajvMessage: error.message ?? "validation error"
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
function validateParameter(parameter, actualValue, context, findings) {
|
|
1251
|
+
if (actualValue === void 0 || actualValue === null || !parameter.schema) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const result = context.validateSchema(parameter.schema, actualValue, { coerce: true });
|
|
1255
|
+
if (result.valid) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
for (const error of result.errors) {
|
|
1259
|
+
const details = createSchemaErrorDetails(actualValue, error, "request");
|
|
1260
|
+
findings.push({
|
|
1261
|
+
ruleId: "schema-consistency",
|
|
1262
|
+
severity: "error",
|
|
1263
|
+
category: "schema",
|
|
1264
|
+
message: `Invalid ${parameter.in} parameter "${parameter.name}": ${typeof details.summary === "string" ? details.summary : error.message ?? "schema mismatch"}`,
|
|
1265
|
+
exchangeIndex: context.exchange.index,
|
|
1266
|
+
operationId: context.matchedOperation?.operation.operationId,
|
|
1267
|
+
specSource: context.matchedOperation?.operation.specSource,
|
|
1268
|
+
target: "request",
|
|
1269
|
+
schemaPath: error.schemaPath,
|
|
1270
|
+
dataPath: normalizeDataPathForError(error),
|
|
1271
|
+
details: {
|
|
1272
|
+
...details,
|
|
1273
|
+
parameter: {
|
|
1274
|
+
name: parameter.name,
|
|
1275
|
+
in: parameter.in
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
function getActualParameterValue(parameter, context, cookies) {
|
|
1282
|
+
switch (parameter.in) {
|
|
1283
|
+
case "path":
|
|
1284
|
+
return context.matchedOperation?.pathParams[parameter.name];
|
|
1285
|
+
case "query": {
|
|
1286
|
+
const values = context.exchange.request.query.getAll(parameter.name);
|
|
1287
|
+
if (values.length === 0) {
|
|
1288
|
+
return void 0;
|
|
1289
|
+
}
|
|
1290
|
+
const schemaType = isPlainObject(parameter.schema) ? parameter.schema.type : void 0;
|
|
1291
|
+
if (schemaType === "array" || values.length > 1) {
|
|
1292
|
+
return values;
|
|
1293
|
+
}
|
|
1294
|
+
return values[0];
|
|
1295
|
+
}
|
|
1296
|
+
case "header":
|
|
1297
|
+
return context.exchange.request.headers[parameter.name.toLowerCase()];
|
|
1298
|
+
case "cookie":
|
|
1299
|
+
return cookies[parameter.name];
|
|
1300
|
+
default:
|
|
1301
|
+
return void 0;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
function createUndocumentedParameterFindings(context, matchedOperation) {
|
|
1305
|
+
const findings = [];
|
|
1306
|
+
const paramsByLocation = {
|
|
1307
|
+
query: /* @__PURE__ */ new Set(),
|
|
1308
|
+
header: /* @__PURE__ */ new Set(),
|
|
1309
|
+
cookie: /* @__PURE__ */ new Set()
|
|
1310
|
+
};
|
|
1311
|
+
for (const parameter of matchedOperation.operation.requestParameters) {
|
|
1312
|
+
if (parameter.in === "header") {
|
|
1313
|
+
paramsByLocation.header.add(parameter.name.toLowerCase());
|
|
1314
|
+
} else if (parameter.in === "query" || parameter.in === "cookie") {
|
|
1315
|
+
paramsByLocation[parameter.in].add(parameter.name);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
for (const name of new Set(context.exchange.request.query.keys())) {
|
|
1319
|
+
if (!paramsByLocation.query.has(name)) {
|
|
1320
|
+
findings.push({
|
|
1321
|
+
ruleId: "schema-consistency",
|
|
1322
|
+
severity: "warning",
|
|
1323
|
+
category: "documentation",
|
|
1324
|
+
message: `Undocumented query parameter in traffic: "${name}"`,
|
|
1325
|
+
exchangeIndex: context.exchange.index,
|
|
1326
|
+
operationId: matchedOperation.operation.operationId,
|
|
1327
|
+
specSource: matchedOperation.operation.specSource,
|
|
1328
|
+
target: "request"
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
for (const headerName of Object.keys(context.exchange.request.headers)) {
|
|
1333
|
+
if (shouldIgnoreHeaderAsUndocumented(headerName)) {
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
if (!paramsByLocation.header.has(headerName.toLowerCase())) {
|
|
1337
|
+
findings.push({
|
|
1338
|
+
ruleId: "schema-consistency",
|
|
1339
|
+
severity: "info",
|
|
1340
|
+
category: "documentation",
|
|
1341
|
+
message: `Undocumented header in traffic: "${headerName}"`,
|
|
1342
|
+
exchangeIndex: context.exchange.index,
|
|
1343
|
+
operationId: matchedOperation.operation.operationId,
|
|
1344
|
+
specSource: matchedOperation.operation.specSource,
|
|
1345
|
+
target: "request"
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
return findings;
|
|
1350
|
+
}
|
|
1351
|
+
function pickResponseSchema(context, matchedOperation) {
|
|
1352
|
+
const response = context.exchange.response;
|
|
1353
|
+
if (!response) {
|
|
1354
|
+
return void 0;
|
|
1355
|
+
}
|
|
1356
|
+
const statusCode = String(response.status);
|
|
1357
|
+
const statusClass = `${Math.floor(response.status / 100)}XX`;
|
|
1358
|
+
const responseContentMap = matchedOperation.operation.responseBodyContent[statusCode] ?? matchedOperation.operation.responseBodyContent[statusClass] ?? matchedOperation.operation.responseBodyContent[statusClass.toLowerCase()] ?? matchedOperation.operation.responseBodyContent.default;
|
|
1359
|
+
if (!responseContentMap) {
|
|
1360
|
+
return void 0;
|
|
1361
|
+
}
|
|
1362
|
+
return pickSchemaByMime(responseContentMap, response.contentType);
|
|
1363
|
+
}
|
|
1364
|
+
function validateSchemaResult(schema, value, context, target) {
|
|
1365
|
+
const result = context.validateSchema(schema, value, { target });
|
|
1366
|
+
if (result.valid) {
|
|
1367
|
+
return [];
|
|
1368
|
+
}
|
|
1369
|
+
return result.errors.map((error) => {
|
|
1370
|
+
const details = createSchemaErrorDetails(value, error, target);
|
|
1371
|
+
return {
|
|
1372
|
+
ruleId: "schema-consistency",
|
|
1373
|
+
severity: "error",
|
|
1374
|
+
category: "schema",
|
|
1375
|
+
message: typeof details.summary === "string" ? details.summary : `Schema mismatch (${target}): ${error.message ?? "validation error"}`,
|
|
1376
|
+
exchangeIndex: context.exchange.index,
|
|
1377
|
+
operationId: context.matchedOperation?.operation.operationId,
|
|
1378
|
+
specSource: context.matchedOperation?.operation.specSource,
|
|
1379
|
+
target,
|
|
1380
|
+
schemaPath: error.schemaPath,
|
|
1381
|
+
dataPath: normalizeDataPathForError(error),
|
|
1382
|
+
details
|
|
1383
|
+
};
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
var SchemaConsistencyRule = class {
|
|
1387
|
+
id = "schema-consistency";
|
|
1388
|
+
analyze(context) {
|
|
1389
|
+
const matchedOperation = context.matchedOperation;
|
|
1390
|
+
if (!matchedOperation) {
|
|
1391
|
+
return [];
|
|
1392
|
+
}
|
|
1393
|
+
const findings = [];
|
|
1394
|
+
const cookies = parseCookies(context.exchange.request.headers.cookie);
|
|
1395
|
+
findings.push(...createUndocumentedParameterFindings(context, matchedOperation));
|
|
1396
|
+
for (const parameter of matchedOperation.operation.requestParameters) {
|
|
1397
|
+
if (context.ignoreCookies && parameter.in === "cookie") {
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
const actualValue = getActualParameterValue(parameter, context, cookies);
|
|
1401
|
+
if (parameter.required && (actualValue === void 0 || actualValue === null)) {
|
|
1402
|
+
findings.push({
|
|
1403
|
+
ruleId: this.id,
|
|
1404
|
+
severity: "error",
|
|
1405
|
+
category: "documentation",
|
|
1406
|
+
message: `Missing required ${parameter.in} parameter: "${parameter.name}"`,
|
|
1407
|
+
exchangeIndex: context.exchange.index,
|
|
1408
|
+
operationId: matchedOperation.operation.operationId,
|
|
1409
|
+
specSource: matchedOperation.operation.specSource,
|
|
1410
|
+
target: "request"
|
|
1411
|
+
});
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
validateParameter(parameter, actualValue, context, findings);
|
|
1415
|
+
}
|
|
1416
|
+
const requestContentType = context.exchange.request.contentType;
|
|
1417
|
+
const requestSchema = pickSchemaByMime(
|
|
1418
|
+
matchedOperation.operation.requestBodyContent,
|
|
1419
|
+
requestContentType
|
|
1420
|
+
);
|
|
1421
|
+
const hasRequestBody = hasBodyContent(context.exchange.request.bodyText);
|
|
1422
|
+
if (matchedOperation.operation.requestBodyRequired && !hasRequestBody) {
|
|
1423
|
+
findings.push({
|
|
1424
|
+
ruleId: this.id,
|
|
1425
|
+
severity: "error",
|
|
1426
|
+
category: "documentation",
|
|
1427
|
+
message: "Missing required request body",
|
|
1428
|
+
exchangeIndex: context.exchange.index,
|
|
1429
|
+
operationId: matchedOperation.operation.operationId,
|
|
1430
|
+
specSource: matchedOperation.operation.specSource,
|
|
1431
|
+
target: "request"
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
if (requestSchema && hasRequestBody && isJsonMime(requestContentType)) {
|
|
1435
|
+
if (context.exchange.request.bodyJson === void 0) {
|
|
1436
|
+
findings.push({
|
|
1437
|
+
ruleId: this.id,
|
|
1438
|
+
severity: "error",
|
|
1439
|
+
category: "schema",
|
|
1440
|
+
message: "Request body is not valid JSON for JSON content-type",
|
|
1441
|
+
exchangeIndex: context.exchange.index,
|
|
1442
|
+
operationId: matchedOperation.operation.operationId,
|
|
1443
|
+
specSource: matchedOperation.operation.specSource,
|
|
1444
|
+
target: "request"
|
|
1445
|
+
});
|
|
1446
|
+
} else {
|
|
1447
|
+
findings.push(
|
|
1448
|
+
...validateSchemaResult(
|
|
1449
|
+
requestSchema,
|
|
1450
|
+
context.exchange.request.bodyJson,
|
|
1451
|
+
context,
|
|
1452
|
+
"request"
|
|
1453
|
+
)
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
const responseSchema = pickResponseSchema(context, matchedOperation);
|
|
1458
|
+
if (responseSchema && context.exchange.response && hasBodyContent(context.exchange.response.bodyText)) {
|
|
1459
|
+
const responseContentType = context.exchange.response.contentType;
|
|
1460
|
+
if (isJsonMime(responseContentType)) {
|
|
1461
|
+
if (context.exchange.response.bodyJson === void 0) {
|
|
1462
|
+
findings.push({
|
|
1463
|
+
ruleId: this.id,
|
|
1464
|
+
severity: "error",
|
|
1465
|
+
category: "schema",
|
|
1466
|
+
message: "Response body is not valid JSON for JSON content-type",
|
|
1467
|
+
exchangeIndex: context.exchange.index,
|
|
1468
|
+
operationId: matchedOperation.operation.operationId,
|
|
1469
|
+
specSource: matchedOperation.operation.specSource,
|
|
1470
|
+
target: "response"
|
|
1471
|
+
});
|
|
1472
|
+
} else {
|
|
1473
|
+
findings.push(
|
|
1474
|
+
...validateSchemaResult(
|
|
1475
|
+
responseSchema,
|
|
1476
|
+
context.exchange.response.bodyJson,
|
|
1477
|
+
context,
|
|
1478
|
+
"response"
|
|
1479
|
+
)
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return findings;
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
// src/commands/drift/rules/builtins/security.ts
|
|
1489
|
+
function extractAuthorizationScheme(authorizationHeader) {
|
|
1490
|
+
if (!authorizationHeader) {
|
|
1491
|
+
return null;
|
|
1492
|
+
}
|
|
1493
|
+
const [scheme] = authorizationHeader.trim().split(/\s+/, 1);
|
|
1494
|
+
return scheme ? scheme.toLowerCase() : null;
|
|
1495
|
+
}
|
|
1496
|
+
function hasAuthorizationHeader(context) {
|
|
1497
|
+
return Boolean(context.exchange.request.headers.authorization);
|
|
1498
|
+
}
|
|
1499
|
+
function hasCookieValue(cookieHeader, cookieName) {
|
|
1500
|
+
if (!cookieHeader) {
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
return cookieHeader.split(";").map((part) => part.trim()).some((part) => part.startsWith(`${cookieName}=`));
|
|
1504
|
+
}
|
|
1505
|
+
function evaluateScheme(context, schemeName) {
|
|
1506
|
+
const operation = context.matchedOperation?.operation;
|
|
1507
|
+
if (!operation) {
|
|
1508
|
+
return {
|
|
1509
|
+
schemeName,
|
|
1510
|
+
schemeType: "unknown",
|
|
1511
|
+
satisfied: true,
|
|
1512
|
+
reason: "No matched OpenAPI operation."
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
const scheme = operation.securitySchemes[schemeName];
|
|
1516
|
+
if (!scheme) {
|
|
1517
|
+
return {
|
|
1518
|
+
schemeName,
|
|
1519
|
+
schemeType: "missing",
|
|
1520
|
+
satisfied: false,
|
|
1521
|
+
reason: "Security scheme is referenced but not defined in components.securitySchemes."
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
const schemeType = String(scheme.type ?? "unknown");
|
|
1525
|
+
if (scheme.type === "apiKey") {
|
|
1526
|
+
const parameterName = String(scheme.name ?? "");
|
|
1527
|
+
const parameterLocation = String(scheme.in ?? "header");
|
|
1528
|
+
if (!parameterName) {
|
|
1529
|
+
return {
|
|
1530
|
+
schemeName,
|
|
1531
|
+
schemeType,
|
|
1532
|
+
satisfied: false,
|
|
1533
|
+
reason: 'apiKey scheme is missing the "name" field in the OpenAPI spec.'
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
if (parameterLocation === "header") {
|
|
1537
|
+
const present = Boolean(context.exchange.request.headers[parameterName.toLowerCase()]);
|
|
1538
|
+
return {
|
|
1539
|
+
schemeName,
|
|
1540
|
+
schemeType,
|
|
1541
|
+
satisfied: present,
|
|
1542
|
+
reason: present ? `Found required apiKey header "${parameterName}".` : `Missing required apiKey header "${parameterName}".`
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
if (parameterLocation === "query") {
|
|
1546
|
+
const present = context.exchange.request.query.has(parameterName);
|
|
1547
|
+
return {
|
|
1548
|
+
schemeName,
|
|
1549
|
+
schemeType,
|
|
1550
|
+
satisfied: present,
|
|
1551
|
+
reason: present ? `Found required apiKey query parameter "${parameterName}".` : `Missing required apiKey query parameter "${parameterName}".`
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
if (parameterLocation === "cookie") {
|
|
1555
|
+
if (context.ignoreCookies) {
|
|
1556
|
+
return {
|
|
1557
|
+
schemeName,
|
|
1558
|
+
schemeType,
|
|
1559
|
+
satisfied: true,
|
|
1560
|
+
skipped: true,
|
|
1561
|
+
reason: `Skipped apiKey cookie "${parameterName}" validation because --ignore-cookies is enabled.`
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
const present = hasCookieValue(context.exchange.request.headers.cookie, parameterName);
|
|
1565
|
+
return {
|
|
1566
|
+
schemeName,
|
|
1567
|
+
schemeType,
|
|
1568
|
+
satisfied: present,
|
|
1569
|
+
reason: present ? `Found required apiKey cookie "${parameterName}".` : `Missing required apiKey cookie "${parameterName}".`
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
return {
|
|
1573
|
+
schemeName,
|
|
1574
|
+
schemeType,
|
|
1575
|
+
satisfied: false,
|
|
1576
|
+
reason: `Unsupported apiKey location "${parameterLocation}" in OpenAPI security scheme.`
|
|
1577
|
+
};
|
|
1578
|
+
}
|
|
1579
|
+
if (scheme.type === "http") {
|
|
1580
|
+
const auth = context.exchange.request.headers.authorization;
|
|
1581
|
+
if (!auth) {
|
|
1582
|
+
return {
|
|
1583
|
+
schemeName,
|
|
1584
|
+
schemeType,
|
|
1585
|
+
satisfied: false,
|
|
1586
|
+
reason: "Missing Authorization header."
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
const httpScheme = String(scheme.scheme ?? "").toLowerCase();
|
|
1590
|
+
const actualAuthScheme = extractAuthorizationScheme(auth);
|
|
1591
|
+
if (!httpScheme) {
|
|
1592
|
+
return {
|
|
1593
|
+
schemeName,
|
|
1594
|
+
schemeType,
|
|
1595
|
+
satisfied: true,
|
|
1596
|
+
reason: "Authorization header is present and no specific HTTP auth scheme is documented."
|
|
1597
|
+
};
|
|
1598
|
+
}
|
|
1599
|
+
if (!actualAuthScheme) {
|
|
1600
|
+
return {
|
|
1601
|
+
schemeName,
|
|
1602
|
+
schemeType,
|
|
1603
|
+
satisfied: false,
|
|
1604
|
+
reason: "Authorization header is malformed and does not include an auth scheme."
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
const satisfied = actualAuthScheme === httpScheme;
|
|
1608
|
+
return {
|
|
1609
|
+
schemeName,
|
|
1610
|
+
schemeType,
|
|
1611
|
+
satisfied,
|
|
1612
|
+
reason: satisfied ? `Authorization uses expected "${httpScheme}" scheme.` : `Authorization uses "${actualAuthScheme}" but "${httpScheme}" is required.`
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
if (scheme.type === "oauth2" || scheme.type === "openIdConnect") {
|
|
1616
|
+
const auth = context.exchange.request.headers.authorization;
|
|
1617
|
+
if (!auth) {
|
|
1618
|
+
return {
|
|
1619
|
+
schemeName,
|
|
1620
|
+
schemeType,
|
|
1621
|
+
satisfied: false,
|
|
1622
|
+
reason: "Missing Authorization header for OAuth2/OpenID Connect scheme."
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
const actualAuthScheme = extractAuthorizationScheme(auth);
|
|
1626
|
+
const satisfied = actualAuthScheme === "bearer";
|
|
1627
|
+
return {
|
|
1628
|
+
schemeName,
|
|
1629
|
+
schemeType,
|
|
1630
|
+
satisfied,
|
|
1631
|
+
reason: satisfied ? "Authorization uses bearer token as expected for OAuth2/OpenID Connect." : `Authorization scheme "${actualAuthScheme ?? "unknown"}" does not satisfy OAuth2/OpenID Connect (expected bearer).`
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
if (scheme.type === "mutualTLS") {
|
|
1635
|
+
return {
|
|
1636
|
+
schemeName,
|
|
1637
|
+
schemeType,
|
|
1638
|
+
satisfied: true,
|
|
1639
|
+
reason: "mutualTLS cannot be validated from HTTP traffic logs alone."
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
return {
|
|
1643
|
+
schemeName,
|
|
1644
|
+
schemeType,
|
|
1645
|
+
satisfied: false,
|
|
1646
|
+
reason: `Unsupported security scheme type "${schemeType}".`
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
function evaluateSecurityRequirements(context) {
|
|
1650
|
+
const security = context.matchedOperation?.operation.security;
|
|
1651
|
+
if (!security) {
|
|
1652
|
+
return {
|
|
1653
|
+
satisfied: true,
|
|
1654
|
+
requirements: []
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
if (security.length === 0) {
|
|
1658
|
+
return {
|
|
1659
|
+
satisfied: true,
|
|
1660
|
+
requirements: []
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
const requirements = security.map((requirement, requirementIndex) => {
|
|
1664
|
+
const schemeNames = Object.keys(requirement);
|
|
1665
|
+
if (schemeNames.length === 0) {
|
|
1666
|
+
return {
|
|
1667
|
+
requirementIndex,
|
|
1668
|
+
satisfied: true,
|
|
1669
|
+
schemeResults: []
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
const schemeResults = schemeNames.map((schemeName) => evaluateScheme(context, schemeName));
|
|
1673
|
+
return {
|
|
1674
|
+
requirementIndex,
|
|
1675
|
+
satisfied: schemeResults.every((result) => result.satisfied),
|
|
1676
|
+
schemeResults
|
|
1677
|
+
};
|
|
1678
|
+
});
|
|
1679
|
+
return {
|
|
1680
|
+
satisfied: requirements.some((requirement) => requirement.satisfied),
|
|
1681
|
+
requirements
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function collectSecurityIssues(evaluation) {
|
|
1685
|
+
const issues = [];
|
|
1686
|
+
for (const requirement of evaluation.requirements) {
|
|
1687
|
+
for (const schemeResult of requirement.schemeResults) {
|
|
1688
|
+
if (!schemeResult.satisfied) {
|
|
1689
|
+
issues.push({
|
|
1690
|
+
requirement: requirement.requirementIndex + 1,
|
|
1691
|
+
schemeName: schemeResult.schemeName,
|
|
1692
|
+
reason: schemeResult.reason
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
return issues;
|
|
1698
|
+
}
|
|
1699
|
+
function collectSkippedSecurityChecks(evaluation) {
|
|
1700
|
+
const skippedChecks = [];
|
|
1701
|
+
for (const requirement of evaluation.requirements) {
|
|
1702
|
+
for (const schemeResult of requirement.schemeResults) {
|
|
1703
|
+
if (schemeResult.skipped) {
|
|
1704
|
+
skippedChecks.push({
|
|
1705
|
+
requirement: requirement.requirementIndex + 1,
|
|
1706
|
+
schemeName: schemeResult.schemeName,
|
|
1707
|
+
reason: schemeResult.reason
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
return skippedChecks;
|
|
1713
|
+
}
|
|
1714
|
+
function createSecuritySummary(issues) {
|
|
1715
|
+
if (issues.length === 0) {
|
|
1716
|
+
return "All documented security requirements are satisfied.";
|
|
1717
|
+
}
|
|
1718
|
+
const groupedByRequirement = /* @__PURE__ */ new Map();
|
|
1719
|
+
for (const issue of issues) {
|
|
1720
|
+
const grouped = groupedByRequirement.get(issue.requirement);
|
|
1721
|
+
if (grouped) {
|
|
1722
|
+
grouped.push(issue);
|
|
1723
|
+
} else {
|
|
1724
|
+
groupedByRequirement.set(issue.requirement, [issue]);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
const optionSummaries = Array.from(groupedByRequirement.entries()).sort((a, b) => a[0] - b[0]).map(([requirement, optionIssues]) => {
|
|
1728
|
+
const schemeNames = optionIssues.map((issue) => issue.schemeName).join(" + ");
|
|
1729
|
+
const reasons = optionIssues.map((issue) => issue.reason).join(" ");
|
|
1730
|
+
return `Option ${requirement} (${schemeNames}): ${reasons}`;
|
|
1731
|
+
});
|
|
1732
|
+
if (optionSummaries.length === 1) {
|
|
1733
|
+
return `Authentication check failed. ${optionSummaries[0]}`;
|
|
1734
|
+
}
|
|
1735
|
+
return `None of the documented authentication options matched. Any one of these options would satisfy the OpenAPI security requirements: ${optionSummaries.join(" | ")}`;
|
|
1736
|
+
}
|
|
1737
|
+
function getSensitiveQueryKeys(context) {
|
|
1738
|
+
return Array.from(context.exchange.request.query.keys()).filter((key) => {
|
|
1739
|
+
const normalizedKey = key.toLowerCase();
|
|
1740
|
+
return normalizedKey.includes("token") || normalizedKey.includes("apikey") || normalizedKey.includes("api_key") || normalizedKey.includes("access_key");
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
function shouldFlagInsecureTransport(context) {
|
|
1744
|
+
if (context.exchange.request.protocol !== "http:" || !context.exchange.request.protocolKnown) {
|
|
1745
|
+
return {
|
|
1746
|
+
flag: false,
|
|
1747
|
+
hasAuthHeader: false,
|
|
1748
|
+
sensitiveQueryKeys: []
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1751
|
+
const hasAuthHeader = hasAuthorizationHeader(context);
|
|
1752
|
+
const sensitiveQueryKeys = getSensitiveQueryKeys(context);
|
|
1753
|
+
return {
|
|
1754
|
+
flag: hasAuthHeader || sensitiveQueryKeys.length > 0,
|
|
1755
|
+
hasAuthHeader,
|
|
1756
|
+
sensitiveQueryKeys
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
var SecurityRule = class {
|
|
1760
|
+
id = "security-baseline";
|
|
1761
|
+
analyze(context) {
|
|
1762
|
+
if (!context.matchedOperation) {
|
|
1763
|
+
return [];
|
|
1764
|
+
}
|
|
1765
|
+
const findings = [];
|
|
1766
|
+
const securityEvaluation = evaluateSecurityRequirements(context);
|
|
1767
|
+
if (!securityEvaluation.satisfied) {
|
|
1768
|
+
const issues = collectSecurityIssues(securityEvaluation);
|
|
1769
|
+
findings.push({
|
|
1770
|
+
ruleId: this.id,
|
|
1771
|
+
severity: "error",
|
|
1772
|
+
category: "security",
|
|
1773
|
+
message: "Request does not satisfy documented OpenAPI security requirements",
|
|
1774
|
+
exchangeIndex: context.exchange.index,
|
|
1775
|
+
operationId: context.matchedOperation.operation.operationId,
|
|
1776
|
+
specSource: context.matchedOperation.operation.specSource,
|
|
1777
|
+
target: "request",
|
|
1778
|
+
details: {
|
|
1779
|
+
summary: createSecuritySummary(issues),
|
|
1780
|
+
failedChecks: issues,
|
|
1781
|
+
skippedChecks: collectSkippedSecurityChecks(securityEvaluation),
|
|
1782
|
+
authorizationScheme: extractAuthorizationScheme(
|
|
1783
|
+
context.exchange.request.headers.authorization
|
|
1784
|
+
),
|
|
1785
|
+
hasAuthorizationHeader: hasAuthorizationHeader(context)
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1788
|
+
} else {
|
|
1789
|
+
const skippedChecks = collectSkippedSecurityChecks(securityEvaluation);
|
|
1790
|
+
if (skippedChecks.length > 0) {
|
|
1791
|
+
findings.push({
|
|
1792
|
+
ruleId: this.id,
|
|
1793
|
+
severity: "info",
|
|
1794
|
+
category: "security",
|
|
1795
|
+
message: "Some documented cookie-based security checks were skipped",
|
|
1796
|
+
exchangeIndex: context.exchange.index,
|
|
1797
|
+
operationId: context.matchedOperation.operation.operationId,
|
|
1798
|
+
specSource: context.matchedOperation.operation.specSource,
|
|
1799
|
+
target: "request",
|
|
1800
|
+
details: {
|
|
1801
|
+
summary: skippedChecks.map(
|
|
1802
|
+
(check) => `Skipped option ${check.requirement} (${check.schemeName}): ${check.reason}`
|
|
1803
|
+
).join(" "),
|
|
1804
|
+
skippedChecks
|
|
1805
|
+
}
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
const insecureTransport = shouldFlagInsecureTransport(context);
|
|
1810
|
+
if (insecureTransport.flag) {
|
|
1811
|
+
findings.push({
|
|
1812
|
+
ruleId: this.id,
|
|
1813
|
+
severity: "warning",
|
|
1814
|
+
category: "security",
|
|
1815
|
+
message: "Potential credential exposure over insecure HTTP transport",
|
|
1816
|
+
exchangeIndex: context.exchange.index,
|
|
1817
|
+
operationId: context.matchedOperation.operation.operationId,
|
|
1818
|
+
specSource: context.matchedOperation.operation.specSource,
|
|
1819
|
+
target: "request",
|
|
1820
|
+
details: {
|
|
1821
|
+
protocol: context.exchange.request.protocol,
|
|
1822
|
+
hasAuthorizationHeader: insecureTransport.hasAuthHeader,
|
|
1823
|
+
sensitiveQueryKeys: insecureTransport.sensitiveQueryKeys
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
return findings;
|
|
1828
|
+
}
|
|
1829
|
+
};
|
|
1830
|
+
|
|
1831
|
+
// src/commands/drift/rules/builtins/undocumented-endpoint.ts
|
|
1832
|
+
var UndocumentedEndpointRule = class {
|
|
1833
|
+
id = "undocumented-endpoint";
|
|
1834
|
+
analyze(context) {
|
|
1835
|
+
if (context.matchedOperation) {
|
|
1836
|
+
return [];
|
|
1837
|
+
}
|
|
1838
|
+
const { method, path: path3, host } = context.exchange.request;
|
|
1839
|
+
const isHostMismatch = context.matchMode === "strict-host" && !context.hostCompatibleWithSpecServers;
|
|
1840
|
+
const message = isHostMismatch ? `Undocumented server: ${method} ${path3} was sent to "${host}", which does not match any server in the description` : `Undocumented endpoint: ${method} ${path3}`;
|
|
1841
|
+
return [
|
|
1842
|
+
{
|
|
1843
|
+
ruleId: this.id,
|
|
1844
|
+
severity: "error",
|
|
1845
|
+
category: "documentation",
|
|
1846
|
+
message,
|
|
1847
|
+
exchangeIndex: context.exchange.index,
|
|
1848
|
+
target: "request"
|
|
1849
|
+
}
|
|
1850
|
+
];
|
|
1851
|
+
}
|
|
1852
|
+
};
|
|
1853
|
+
|
|
1854
|
+
// src/commands/drift/rules/registry.ts
|
|
1855
|
+
var BUILTIN_RULE_FACTORIES = {
|
|
1856
|
+
"undocumented-endpoint": () => new UndocumentedEndpointRule(),
|
|
1857
|
+
"schema-consistency": () => new SchemaConsistencyRule(),
|
|
1858
|
+
"security-baseline": () => new SecurityRule(),
|
|
1859
|
+
"owasp-api-top10": () => new OwaspApiTop10Rule()
|
|
1860
|
+
};
|
|
1861
|
+
var BUILTIN_RULE_IDS = Object.keys(BUILTIN_RULE_FACTORIES);
|
|
1862
|
+
var DEFAULT_BUILTIN_RULE_IDS = [
|
|
1863
|
+
"undocumented-endpoint",
|
|
1864
|
+
"schema-consistency",
|
|
1865
|
+
"security-baseline"
|
|
1866
|
+
];
|
|
1867
|
+
function loadRules(activeRuleIds) {
|
|
1868
|
+
if (!activeRuleIds || activeRuleIds.length === 0) {
|
|
1869
|
+
return DEFAULT_BUILTIN_RULE_IDS.map((ruleId) => BUILTIN_RULE_FACTORIES[ruleId]());
|
|
1870
|
+
}
|
|
1871
|
+
return [...new Set(activeRuleIds)].map((ruleId) => {
|
|
1872
|
+
const factory = BUILTIN_RULE_FACTORIES[ruleId];
|
|
1873
|
+
if (!factory) {
|
|
1874
|
+
throw new Error(
|
|
1875
|
+
`Unknown rule id: "${ruleId}". Available rules: ${BUILTIN_RULE_IDS.join(", ")}`
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
return factory();
|
|
1879
|
+
});
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
// src/commands/drift/utils/server.ts
|
|
1883
|
+
function normalizeServerPrefix(server) {
|
|
1884
|
+
const trimmed = server?.replace(/\/+$/, "");
|
|
1885
|
+
return trimmed || void 0;
|
|
1886
|
+
}
|
|
1887
|
+
function stripPrefixFromPath(pathname, prefixPath) {
|
|
1888
|
+
if (!prefixPath || prefixPath === "/") {
|
|
1889
|
+
return pathname || "/";
|
|
1890
|
+
}
|
|
1891
|
+
if (pathname === prefixPath) {
|
|
1892
|
+
return "/";
|
|
1893
|
+
}
|
|
1894
|
+
if (!pathname.startsWith(`${prefixPath}/`)) {
|
|
1895
|
+
return void 0;
|
|
1896
|
+
}
|
|
1897
|
+
return pathname.slice(prefixPath.length) || "/";
|
|
1898
|
+
}
|
|
1899
|
+
function resolvePathForServer(request, server) {
|
|
1900
|
+
if (server.startsWith("/")) {
|
|
1901
|
+
return stripPrefixFromPath(request.path, server);
|
|
1902
|
+
}
|
|
1903
|
+
const serverUrl = parseUrl(server.includes("://") ? server : `http://${server}`);
|
|
1904
|
+
if (isSyntheticHost(serverUrl.host) || request.host !== void 0 && request.host.toLowerCase() !== serverUrl.host) {
|
|
1905
|
+
return void 0;
|
|
1906
|
+
}
|
|
1907
|
+
return stripPrefixFromPath(request.path, getPathWithoutTrailingSlash(serverUrl.pathname));
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// src/commands/drift/engine/schema-validator.ts
|
|
1911
|
+
var import__ = __toESM(require__(), 1);
|
|
1912
|
+
var import_ajv_formats = __toESM(require_dist(), 1);
|
|
1913
|
+
var AjvConstructor = import__.default;
|
|
1914
|
+
var applyFormats = import_ajv_formats.default;
|
|
1915
|
+
function isPropertyExcludedFromTarget(propertySchema, target) {
|
|
1916
|
+
if (!isPlainObject(propertySchema)) {
|
|
1917
|
+
return false;
|
|
1918
|
+
}
|
|
1919
|
+
return target === "request" ? propertySchema.readOnly === true : propertySchema.writeOnly === true;
|
|
1920
|
+
}
|
|
1921
|
+
function isRequiredNameExcludedFromTarget(schema, name, target, seen = /* @__PURE__ */ new WeakSet()) {
|
|
1922
|
+
if (!isPlainObject(schema) || seen.has(schema)) {
|
|
1923
|
+
return false;
|
|
1924
|
+
}
|
|
1925
|
+
seen.add(schema);
|
|
1926
|
+
const properties = schema.properties;
|
|
1927
|
+
if (isPlainObject(properties) && isPropertyExcludedFromTarget(properties[name], target)) {
|
|
1928
|
+
return true;
|
|
1929
|
+
}
|
|
1930
|
+
return [schema.allOf, schema.oneOf, schema.anyOf].some(
|
|
1931
|
+
(branches) => Array.isArray(branches) && branches.some((branch) => isRequiredNameExcludedFromTarget(branch, name, target, seen))
|
|
1932
|
+
);
|
|
1933
|
+
}
|
|
1934
|
+
var SINGLE_SCHEMA_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1935
|
+
"items",
|
|
1936
|
+
"additionalItems",
|
|
1937
|
+
"additionalProperties",
|
|
1938
|
+
"unevaluatedItems",
|
|
1939
|
+
"unevaluatedProperties",
|
|
1940
|
+
"contains",
|
|
1941
|
+
"propertyNames",
|
|
1942
|
+
"not",
|
|
1943
|
+
"if",
|
|
1944
|
+
"then",
|
|
1945
|
+
"else"
|
|
1946
|
+
]);
|
|
1947
|
+
var SCHEMA_LIST_KEYWORDS = /* @__PURE__ */ new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
|
|
1948
|
+
var SCHEMA_MAP_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1949
|
+
"properties",
|
|
1950
|
+
"patternProperties",
|
|
1951
|
+
"dependentSchemas",
|
|
1952
|
+
"$defs",
|
|
1953
|
+
"definitions"
|
|
1954
|
+
]);
|
|
1955
|
+
function relaxRequiredForTarget(schema, target, ancestors = /* @__PURE__ */ new WeakSet()) {
|
|
1956
|
+
if (!isPlainObject(schema)) {
|
|
1957
|
+
return schema;
|
|
1958
|
+
}
|
|
1959
|
+
if (ancestors.has(schema)) {
|
|
1960
|
+
return true;
|
|
1961
|
+
}
|
|
1962
|
+
ancestors.add(schema);
|
|
1963
|
+
const relaxValue = (value) => relaxRequiredForTarget(value, target, ancestors);
|
|
1964
|
+
const output = {};
|
|
1965
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
1966
|
+
if (SINGLE_SCHEMA_KEYWORDS.has(key)) {
|
|
1967
|
+
output[key] = Array.isArray(value) ? value.map(relaxValue) : relaxValue(value);
|
|
1968
|
+
} else if (SCHEMA_LIST_KEYWORDS.has(key) && Array.isArray(value)) {
|
|
1969
|
+
output[key] = value.map(relaxValue);
|
|
1970
|
+
} else if (SCHEMA_MAP_KEYWORDS.has(key) && isPlainObject(value)) {
|
|
1971
|
+
output[key] = Object.fromEntries(
|
|
1972
|
+
Object.entries(value).map(([mapKey, mapValue]) => [mapKey, relaxValue(mapValue)])
|
|
1973
|
+
);
|
|
1974
|
+
} else {
|
|
1975
|
+
output[key] = value;
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
if (Array.isArray(schema.required)) {
|
|
1979
|
+
output.required = schema.required.filter(
|
|
1980
|
+
(name) => typeof name !== "string" || !isRequiredNameExcludedFromTarget(schema, name, target)
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
ancestors.delete(schema);
|
|
1984
|
+
return output;
|
|
1985
|
+
}
|
|
1986
|
+
var SchemaValidator = class {
|
|
1987
|
+
ajv;
|
|
1988
|
+
objectSchemaCaches = {
|
|
1989
|
+
none: /* @__PURE__ */ new WeakMap(),
|
|
1990
|
+
request: /* @__PURE__ */ new WeakMap(),
|
|
1991
|
+
response: /* @__PURE__ */ new WeakMap()
|
|
1992
|
+
};
|
|
1993
|
+
scalarSchemaCache = /* @__PURE__ */ new Map();
|
|
1994
|
+
constructor(options) {
|
|
1995
|
+
this.ajv = new AjvConstructor({
|
|
1996
|
+
strict: false,
|
|
1997
|
+
allErrors: true,
|
|
1998
|
+
allowUnionTypes: true,
|
|
1999
|
+
coerceTypes: options?.coerceTypes ? "array" : false,
|
|
2000
|
+
validateFormats: true,
|
|
2001
|
+
verbose: true,
|
|
2002
|
+
formats: {
|
|
2003
|
+
// Some specs misuse `format: "enum"` instead of the `enum` keyword.
|
|
2004
|
+
// Treat it as a no-op format to avoid noisy unknown-format warnings.
|
|
2005
|
+
enum: true
|
|
2006
|
+
}
|
|
2007
|
+
});
|
|
2008
|
+
applyFormats(this.ajv);
|
|
2009
|
+
}
|
|
2010
|
+
validate(schema, value, target) {
|
|
2011
|
+
if (schema === void 0) {
|
|
2012
|
+
return { valid: true, errors: [] };
|
|
2013
|
+
}
|
|
2014
|
+
let validate;
|
|
2015
|
+
try {
|
|
2016
|
+
validate = this.getOrCompileValidator(schema, target);
|
|
2017
|
+
} catch (error) {
|
|
2018
|
+
return {
|
|
2019
|
+
valid: false,
|
|
2020
|
+
errors: [
|
|
2021
|
+
{
|
|
2022
|
+
message: `Schema compilation failed: ${error.message}`
|
|
2023
|
+
}
|
|
2024
|
+
]
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
2027
|
+
const valid = Boolean(validate(value));
|
|
2028
|
+
const errors = validate.errors ?? [];
|
|
2029
|
+
return { valid, errors };
|
|
2030
|
+
}
|
|
2031
|
+
getOrCompileValidator(schema, target) {
|
|
2032
|
+
if (isPlainObject(schema)) {
|
|
2033
|
+
const cache = this.objectSchemaCaches[target ?? "none"];
|
|
2034
|
+
const cached2 = cache.get(schema);
|
|
2035
|
+
if (cached2) {
|
|
2036
|
+
return cached2;
|
|
2037
|
+
}
|
|
2038
|
+
const effectiveSchema = target ? relaxRequiredForTarget(schema, target) : schema;
|
|
2039
|
+
const compiled2 = this.ajv.compile(effectiveSchema);
|
|
2040
|
+
cache.set(schema, compiled2);
|
|
2041
|
+
return compiled2;
|
|
2042
|
+
}
|
|
2043
|
+
const cacheKey = `${target ?? "none"}:${JSON.stringify(schema)}`;
|
|
2044
|
+
const cached = this.scalarSchemaCache.get(cacheKey);
|
|
2045
|
+
if (cached) {
|
|
2046
|
+
return cached;
|
|
2047
|
+
}
|
|
2048
|
+
const compiled = this.ajv.compile(schema);
|
|
2049
|
+
this.scalarSchemaCache.set(cacheKey, compiled);
|
|
2050
|
+
return compiled;
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
|
|
2054
|
+
// src/commands/drift/engine/validation-session.ts
|
|
2055
|
+
var DEFAULT_FINDINGS_PREVIEW_LIMIT = 10;
|
|
2056
|
+
var SEVERITY_RANK = {
|
|
2057
|
+
info: 0,
|
|
2058
|
+
warning: 1,
|
|
2059
|
+
error: 2
|
|
2060
|
+
};
|
|
2061
|
+
function createInitialCounters() {
|
|
2062
|
+
return {
|
|
2063
|
+
totalExchanges: 0,
|
|
2064
|
+
documentedExchanges: 0,
|
|
2065
|
+
undocumentedExchanges: 0,
|
|
2066
|
+
skippedExchanges: 0,
|
|
2067
|
+
hostCompatibleExchanges: 0,
|
|
2068
|
+
findingsBySeverity: {
|
|
2069
|
+
info: 0,
|
|
2070
|
+
warning: 0,
|
|
2071
|
+
error: 0
|
|
2072
|
+
},
|
|
2073
|
+
findingsByRule: {}
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
function accumulateFinding(counters, finding) {
|
|
2077
|
+
counters.findingsBySeverity[finding.severity] += 1;
|
|
2078
|
+
counters.findingsByRule[finding.ruleId] = (counters.findingsByRule[finding.ruleId] ?? 0) + 1;
|
|
2079
|
+
}
|
|
2080
|
+
function toFindingRecord(finding, exchange) {
|
|
2081
|
+
return {
|
|
2082
|
+
...finding,
|
|
2083
|
+
id: randomUUID(),
|
|
2084
|
+
method: exchange.request.method,
|
|
2085
|
+
url: exchange.request.url,
|
|
2086
|
+
path: exchange.request.path,
|
|
2087
|
+
status: exchange.response?.status
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
function mapFindingToPreview(finding, exchange) {
|
|
2091
|
+
return {
|
|
2092
|
+
exchangeIndex: finding.exchangeIndex,
|
|
2093
|
+
ruleId: finding.ruleId,
|
|
2094
|
+
severity: finding.severity,
|
|
2095
|
+
category: finding.category,
|
|
2096
|
+
message: finding.message,
|
|
2097
|
+
occurrences: 1,
|
|
2098
|
+
operationId: finding.operationId,
|
|
2099
|
+
specSource: finding.specSource,
|
|
2100
|
+
target: finding.target,
|
|
2101
|
+
schemaPath: finding.schemaPath,
|
|
2102
|
+
dataPath: finding.dataPath,
|
|
2103
|
+
details: finding.details,
|
|
2104
|
+
method: exchange.request.method,
|
|
2105
|
+
url: exchange.request.url,
|
|
2106
|
+
path: exchange.request.path,
|
|
2107
|
+
status: exchange.response?.status
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
function ensureOperationContextInFinding(finding, matchedOperation) {
|
|
2111
|
+
if (!matchedOperation) {
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
if (!finding.operationId) {
|
|
2115
|
+
finding.operationId = matchedOperation.operation.operationId;
|
|
2116
|
+
}
|
|
2117
|
+
if (!finding.specSource) {
|
|
2118
|
+
finding.specSource = matchedOperation.operation.specSource;
|
|
2119
|
+
}
|
|
2120
|
+
const existingDetails = finding.details ?? {};
|
|
2121
|
+
if (!isPlainObject(existingDetails)) {
|
|
2122
|
+
finding.details = {
|
|
2123
|
+
operationPathTemplate: matchedOperation.operation.pathTemplate
|
|
2124
|
+
};
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
if (typeof existingDetails.operationPathTemplate !== "string") {
|
|
2128
|
+
existingDetails.operationPathTemplate = matchedOperation.operation.pathTemplate;
|
|
2129
|
+
}
|
|
2130
|
+
finding.details = existingDetails;
|
|
2131
|
+
}
|
|
2132
|
+
function collectSpecServerHosts(openApiIndex) {
|
|
2133
|
+
const specServerHosts = /* @__PURE__ */ new Set();
|
|
2134
|
+
let hasHostlessSpecServer = false;
|
|
2135
|
+
for (const operations of openApiIndex.operationsByMethod.values()) {
|
|
2136
|
+
for (const operation of operations) {
|
|
2137
|
+
for (const server of operation.servers) {
|
|
2138
|
+
if (server.host) {
|
|
2139
|
+
specServerHosts.add(server.host);
|
|
2140
|
+
} else {
|
|
2141
|
+
hasHostlessSpecServer = true;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
return { specServerHosts, hasHostlessSpecServer };
|
|
2147
|
+
}
|
|
2148
|
+
async function executeRules(rules, context) {
|
|
2149
|
+
const findings = [];
|
|
2150
|
+
for (const rule of rules) {
|
|
2151
|
+
try {
|
|
2152
|
+
const ruleFindings = await rule.analyze(context);
|
|
2153
|
+
if (ruleFindings.length > 0) {
|
|
2154
|
+
findings.push(...ruleFindings);
|
|
2155
|
+
}
|
|
2156
|
+
} catch (error) {
|
|
2157
|
+
logger.warn(`Rule "${rule.id}" failed to execute: ${error.message}
|
|
2158
|
+
`);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
return findings;
|
|
2162
|
+
}
|
|
2163
|
+
var ValidationSession = class _ValidationSession {
|
|
2164
|
+
startedAt = Date.now();
|
|
2165
|
+
runId = randomUUID();
|
|
2166
|
+
previewLimit;
|
|
2167
|
+
rules;
|
|
2168
|
+
schemaValidator = new SchemaValidator();
|
|
2169
|
+
coercingSchemaValidator = new SchemaValidator({ coerceTypes: true });
|
|
2170
|
+
options;
|
|
2171
|
+
counters = createInitialCounters();
|
|
2172
|
+
findings = [];
|
|
2173
|
+
previewFindings = [];
|
|
2174
|
+
problemGroupsByRule = {};
|
|
2175
|
+
problemKeyStats = /* @__PURE__ */ new Map();
|
|
2176
|
+
totalProblemGroups = 0;
|
|
2177
|
+
server;
|
|
2178
|
+
minSeverityRank;
|
|
2179
|
+
specServerHosts;
|
|
2180
|
+
hasHostlessSpecServer;
|
|
2181
|
+
constructor(options, rules) {
|
|
2182
|
+
this.options = options;
|
|
2183
|
+
this.rules = rules;
|
|
2184
|
+
this.server = normalizeServerPrefix(options.server);
|
|
2185
|
+
const { specServerHosts, hasHostlessSpecServer } = collectSpecServerHosts(options.openApiIndex);
|
|
2186
|
+
this.specServerHosts = specServerHosts;
|
|
2187
|
+
this.hasHostlessSpecServer = hasHostlessSpecServer;
|
|
2188
|
+
this.minSeverityRank = SEVERITY_RANK[options.minSeverity ?? "info"];
|
|
2189
|
+
this.previewLimit = options.previewFindingsLimit && options.previewFindingsLimit > 0 ? options.previewFindingsLimit : DEFAULT_FINDINGS_PREVIEW_LIMIT;
|
|
2190
|
+
}
|
|
2191
|
+
static create(options) {
|
|
2192
|
+
if (options.openApiIndex.loadedOperations === 0) {
|
|
2193
|
+
throw new Error("No OpenAPI operations available to validate traffic against.");
|
|
2194
|
+
}
|
|
2195
|
+
return new _ValidationSession(options, loadRules(options.activeRules));
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Validate a single exchange and return the finding records produced for it,
|
|
2199
|
+
* so callers (e.g. the proxy) can surface findings live as they arrive.
|
|
2200
|
+
*/
|
|
2201
|
+
async process(exchange) {
|
|
2202
|
+
this.counters.totalExchanges += 1;
|
|
2203
|
+
let relativePathOverride;
|
|
2204
|
+
if (this.server !== void 0) {
|
|
2205
|
+
relativePathOverride = resolvePathForServer(exchange.request, this.server);
|
|
2206
|
+
if (relativePathOverride === void 0) {
|
|
2207
|
+
this.counters.skippedExchanges += 1;
|
|
2208
|
+
return [];
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const hostCompatible = this.isHostCompatible(exchange.request.host);
|
|
2212
|
+
if (hostCompatible) {
|
|
2213
|
+
this.counters.hostCompatibleExchanges += 1;
|
|
2214
|
+
}
|
|
2215
|
+
const matchedOperation = matchOperation(
|
|
2216
|
+
this.options.openApiIndex,
|
|
2217
|
+
exchange,
|
|
2218
|
+
this.options.matchMode,
|
|
2219
|
+
relativePathOverride
|
|
2220
|
+
);
|
|
2221
|
+
if (matchedOperation) {
|
|
2222
|
+
this.counters.documentedExchanges += 1;
|
|
2223
|
+
} else {
|
|
2224
|
+
this.counters.undocumentedExchanges += 1;
|
|
2225
|
+
}
|
|
2226
|
+
const exchangeFindings = await executeRules(this.rules, {
|
|
2227
|
+
exchange,
|
|
2228
|
+
matchedOperation,
|
|
2229
|
+
matchMode: this.options.matchMode,
|
|
2230
|
+
hostCompatibleWithSpecServers: relativePathOverride !== void 0 || hostCompatible,
|
|
2231
|
+
ignoreCookies: this.options.ignoreCookies ?? false,
|
|
2232
|
+
validateSchema: (schema, value, options) => options?.coerce ? this.coercingSchemaValidator.validate(schema, value, options?.target) : this.schemaValidator.validate(schema, value, options?.target)
|
|
2233
|
+
});
|
|
2234
|
+
const records = [];
|
|
2235
|
+
for (const finding of exchangeFindings) {
|
|
2236
|
+
if (SEVERITY_RANK[finding.severity] < this.minSeverityRank) {
|
|
2237
|
+
continue;
|
|
2238
|
+
}
|
|
2239
|
+
ensureOperationContextInFinding(finding, matchedOperation);
|
|
2240
|
+
const record = toFindingRecord(finding, exchange);
|
|
2241
|
+
this.findings.push(record);
|
|
2242
|
+
records.push(record);
|
|
2243
|
+
accumulateFinding(this.counters, finding);
|
|
2244
|
+
const problemKey = createProblemKey({
|
|
2245
|
+
ruleId: finding.ruleId,
|
|
2246
|
+
severity: finding.severity,
|
|
2247
|
+
message: finding.message,
|
|
2248
|
+
operationId: finding.operationId,
|
|
2249
|
+
path: exchange.request.path,
|
|
2250
|
+
target: finding.target,
|
|
2251
|
+
schemaPath: finding.schemaPath
|
|
2252
|
+
});
|
|
2253
|
+
const existingProblem = this.problemKeyStats.get(problemKey);
|
|
2254
|
+
if (existingProblem) {
|
|
2255
|
+
existingProblem.occurrences += 1;
|
|
2256
|
+
if (existingProblem.previewIndex !== null) {
|
|
2257
|
+
this.previewFindings[existingProblem.previewIndex].occurrences = existingProblem.occurrences;
|
|
2258
|
+
}
|
|
2259
|
+
continue;
|
|
2260
|
+
}
|
|
2261
|
+
this.totalProblemGroups += 1;
|
|
2262
|
+
this.problemGroupsByRule[finding.ruleId] = (this.problemGroupsByRule[finding.ruleId] ?? 0) + 1;
|
|
2263
|
+
if (this.previewFindings.length < this.previewLimit) {
|
|
2264
|
+
const previewIndex = this.previewFindings.length;
|
|
2265
|
+
this.previewFindings.push(mapFindingToPreview(finding, exchange));
|
|
2266
|
+
this.problemKeyStats.set(problemKey, { occurrences: 1, previewIndex });
|
|
2267
|
+
continue;
|
|
2268
|
+
}
|
|
2269
|
+
this.problemKeyStats.set(problemKey, { occurrences: 1, previewIndex: null });
|
|
2270
|
+
}
|
|
2271
|
+
return records;
|
|
2272
|
+
}
|
|
2273
|
+
isHostCompatible(requestHost) {
|
|
2274
|
+
return !requestHost || this.hasHostlessSpecServer || this.specServerHosts.has(requestHost.toLowerCase());
|
|
2275
|
+
}
|
|
2276
|
+
finalize() {
|
|
2277
|
+
const summary = {
|
|
2278
|
+
runId: this.runId,
|
|
2279
|
+
totalExchanges: this.counters.totalExchanges,
|
|
2280
|
+
documentedExchanges: this.counters.documentedExchanges,
|
|
2281
|
+
undocumentedExchanges: this.counters.undocumentedExchanges,
|
|
2282
|
+
skippedExchanges: this.counters.skippedExchanges,
|
|
2283
|
+
hostCompatibleExchanges: this.counters.hostCompatibleExchanges,
|
|
2284
|
+
findingsBySeverity: this.counters.findingsBySeverity,
|
|
2285
|
+
findingsByRule: this.counters.findingsByRule,
|
|
2286
|
+
problemGroupsByRule: this.problemGroupsByRule,
|
|
2287
|
+
totalProblemGroups: this.totalProblemGroups,
|
|
2288
|
+
durationMs: Date.now() - this.startedAt,
|
|
2289
|
+
previewFindings: this.previewFindings,
|
|
2290
|
+
previewLimit: this.previewLimit,
|
|
2291
|
+
previewTruncated: this.totalProblemGroups > this.previewFindings.length
|
|
2292
|
+
};
|
|
2293
|
+
return { runId: this.runId, summary, findings: this.findings };
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
// src/commands/drift/openapi/loader.ts
|
|
2298
|
+
import { stat as stat2 } from "node:fs/promises";
|
|
2299
|
+
|
|
2300
|
+
// src/commands/drift/utils/openapi.ts
|
|
2301
|
+
function resolveServerUrl(rawUrl, variables) {
|
|
2302
|
+
const resolvedUrl = rawUrl.replace(/\{([^}]+)\}/g, (_, variableName) => {
|
|
2303
|
+
const variable = variables?.[variableName];
|
|
2304
|
+
if (variable?.default !== void 0) {
|
|
2305
|
+
return String(variable.default);
|
|
2306
|
+
}
|
|
2307
|
+
if (Array.isArray(variable?.enum) && variable.enum.length > 0) {
|
|
2308
|
+
return String(variable.enum[0]);
|
|
2309
|
+
}
|
|
2310
|
+
return "";
|
|
2311
|
+
});
|
|
2312
|
+
const parsedUrl = parseUrl(resolvedUrl);
|
|
2313
|
+
const hasExplicitHost = Boolean(parsedUrl.host && !isSyntheticHost(parsedUrl.host));
|
|
2314
|
+
return {
|
|
2315
|
+
rawUrl,
|
|
2316
|
+
protocol: hasExplicitHost ? parsedUrl.protocol : void 0,
|
|
2317
|
+
host: hasExplicitHost ? parsedUrl.host.toLowerCase() : void 0,
|
|
2318
|
+
basePath: getPathWithoutTrailingSlash(parsedUrl.pathname || "/")
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
function ensureLeadingSlash(value) {
|
|
2322
|
+
if (!value) {
|
|
2323
|
+
return "/";
|
|
2324
|
+
}
|
|
2325
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// src/commands/drift/openapi/loader.ts
|
|
2329
|
+
var HTTP_METHODS = ["get", "put", "post", "delete", "patch", "head", "options", "trace"];
|
|
2330
|
+
var PARAMETER_LOCATIONS = /* @__PURE__ */ new Set(["query", "header", "path", "cookie"]);
|
|
2331
|
+
function isHttpMethod(value) {
|
|
2332
|
+
return HTTP_METHODS.includes(value);
|
|
2333
|
+
}
|
|
2334
|
+
function isParameterLocation(value) {
|
|
2335
|
+
return PARAMETER_LOCATIONS.has(value);
|
|
2336
|
+
}
|
|
2337
|
+
function normalizeParameters(parameters) {
|
|
2338
|
+
if (!Array.isArray(parameters)) {
|
|
2339
|
+
return [];
|
|
2340
|
+
}
|
|
2341
|
+
const normalized = [];
|
|
2342
|
+
for (const entry of parameters) {
|
|
2343
|
+
if (!isPlainObject(entry)) {
|
|
2344
|
+
continue;
|
|
2345
|
+
}
|
|
2346
|
+
const location = entry.in;
|
|
2347
|
+
if (typeof location !== "string" || !isParameterLocation(location)) {
|
|
2348
|
+
continue;
|
|
2349
|
+
}
|
|
2350
|
+
normalized.push({
|
|
2351
|
+
name: String(entry.name ?? ""),
|
|
2352
|
+
in: location,
|
|
2353
|
+
required: Boolean(entry.required) || location === "path",
|
|
2354
|
+
schema: entry.schema
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
return normalized;
|
|
2358
|
+
}
|
|
2359
|
+
function parameterKey(parameter) {
|
|
2360
|
+
const caseInsensitive = parameter.in === "header";
|
|
2361
|
+
return `${parameter.in}:${caseInsensitive ? parameter.name.toLowerCase() : parameter.name}`;
|
|
2362
|
+
}
|
|
2363
|
+
function mergeParameters(baseParameters, operationParameters) {
|
|
2364
|
+
const map = /* @__PURE__ */ new Map();
|
|
2365
|
+
for (const parameter of baseParameters) {
|
|
2366
|
+
map.set(parameterKey(parameter), parameter);
|
|
2367
|
+
}
|
|
2368
|
+
for (const parameter of operationParameters) {
|
|
2369
|
+
map.set(parameterKey(parameter), parameter);
|
|
2370
|
+
}
|
|
2371
|
+
return Array.from(map.values());
|
|
2372
|
+
}
|
|
2373
|
+
function extractMediaSchemas(content) {
|
|
2374
|
+
const output = {};
|
|
2375
|
+
if (!isPlainObject(content)) {
|
|
2376
|
+
return output;
|
|
2377
|
+
}
|
|
2378
|
+
for (const [mime, mediaTypeObject] of Object.entries(content)) {
|
|
2379
|
+
if (isPlainObject(mediaTypeObject) && "schema" in mediaTypeObject) {
|
|
2380
|
+
output[mime.toLowerCase()] = mediaTypeObject.schema;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return output;
|
|
2384
|
+
}
|
|
2385
|
+
function extractRequestBodyContent(requestBody) {
|
|
2386
|
+
if (!isPlainObject(requestBody)) {
|
|
2387
|
+
return {};
|
|
2388
|
+
}
|
|
2389
|
+
return extractMediaSchemas(requestBody.content);
|
|
2390
|
+
}
|
|
2391
|
+
function extractResponseBodyContent(responses) {
|
|
2392
|
+
if (!isPlainObject(responses)) {
|
|
2393
|
+
return {};
|
|
2394
|
+
}
|
|
2395
|
+
const output = {};
|
|
2396
|
+
for (const [statusCode, responseObject] of Object.entries(responses)) {
|
|
2397
|
+
if (!isPlainObject(responseObject) || !("content" in responseObject)) {
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
output[statusCode] = extractMediaSchemas(responseObject.content);
|
|
2401
|
+
}
|
|
2402
|
+
return output;
|
|
2403
|
+
}
|
|
2404
|
+
function resolveOperationServers(operationServers, pathServers, rootServers) {
|
|
2405
|
+
const sourceServers = operationServers ?? pathServers ?? rootServers;
|
|
2406
|
+
if (!Array.isArray(sourceServers) || sourceServers.length === 0) {
|
|
2407
|
+
return [resolveServerUrl("/")];
|
|
2408
|
+
}
|
|
2409
|
+
return sourceServers.map((server) => {
|
|
2410
|
+
if (!isPlainObject(server)) {
|
|
2411
|
+
return resolveServerUrl("/");
|
|
2412
|
+
}
|
|
2413
|
+
const url = typeof server.url === "string" ? server.url : "/";
|
|
2414
|
+
const variables = isPlainObject(server.variables) ? server.variables : void 0;
|
|
2415
|
+
return resolveServerUrl(url, variables);
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
function toOperationId(method, pathTemplate, declaredOperationId) {
|
|
2419
|
+
if (declaredOperationId) {
|
|
2420
|
+
return declaredOperationId;
|
|
2421
|
+
}
|
|
2422
|
+
return `${method.toUpperCase()} ${pathTemplate}`;
|
|
2423
|
+
}
|
|
2424
|
+
function detectOpenApi3Spec(document) {
|
|
2425
|
+
try {
|
|
2426
|
+
const specVersion = detectSpec(document.parsed);
|
|
2427
|
+
return getMajorSpecVersion(specVersion) === "oas3" ? specVersion : null;
|
|
2428
|
+
} catch {
|
|
2429
|
+
return null;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
function normalizeSecurity(value) {
|
|
2433
|
+
return Array.isArray(value) ? value : void 0;
|
|
2434
|
+
}
|
|
2435
|
+
function createIndexVisitor(specSource, operationsByMethod) {
|
|
2436
|
+
let rootServers;
|
|
2437
|
+
let rootSecurity;
|
|
2438
|
+
let securitySchemes = {};
|
|
2439
|
+
let currentPathTemplate = "/";
|
|
2440
|
+
let currentPathParameters = [];
|
|
2441
|
+
let currentPathServers;
|
|
2442
|
+
return {
|
|
2443
|
+
Root: {
|
|
2444
|
+
enter(root) {
|
|
2445
|
+
rootServers = root.servers;
|
|
2446
|
+
rootSecurity = root.security;
|
|
2447
|
+
const componentsSecuritySchemes = isPlainObject(root.components) ? root.components.securitySchemes : void 0;
|
|
2448
|
+
securitySchemes = isPlainObject(componentsSecuritySchemes) ? componentsSecuritySchemes : {};
|
|
2449
|
+
}
|
|
2450
|
+
},
|
|
2451
|
+
Paths: {
|
|
2452
|
+
PathItem: {
|
|
2453
|
+
enter(pathItem, ctx) {
|
|
2454
|
+
currentPathTemplate = ensureLeadingSlash(String(ctx.key));
|
|
2455
|
+
currentPathParameters = normalizeParameters(pathItem.parameters);
|
|
2456
|
+
currentPathServers = pathItem.servers;
|
|
2457
|
+
},
|
|
2458
|
+
Operation: {
|
|
2459
|
+
enter(operation, ctx) {
|
|
2460
|
+
const method = String(ctx.key);
|
|
2461
|
+
if (!isHttpMethod(method)) {
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const mergedParameters = mergeParameters(
|
|
2465
|
+
currentPathParameters,
|
|
2466
|
+
normalizeParameters(operation.parameters)
|
|
2467
|
+
);
|
|
2468
|
+
const compiledPath = compileOpenApiPath(currentPathTemplate);
|
|
2469
|
+
const servers = resolveOperationServers(
|
|
2470
|
+
operation.servers,
|
|
2471
|
+
currentPathServers,
|
|
2472
|
+
rootServers
|
|
2473
|
+
);
|
|
2474
|
+
const requestBody = operation.requestBody;
|
|
2475
|
+
const item = {
|
|
2476
|
+
operationId: toOperationId(
|
|
2477
|
+
method,
|
|
2478
|
+
currentPathTemplate,
|
|
2479
|
+
typeof operation.operationId === "string" ? operation.operationId : void 0
|
|
2480
|
+
),
|
|
2481
|
+
method,
|
|
2482
|
+
pathTemplate: currentPathTemplate,
|
|
2483
|
+
pathRegex: compiledPath.regex,
|
|
2484
|
+
pathParams: compiledPath.params,
|
|
2485
|
+
pathScore: compiledPath.score,
|
|
2486
|
+
servers,
|
|
2487
|
+
requestParameters: mergedParameters,
|
|
2488
|
+
requestBodyContent: extractRequestBodyContent(requestBody),
|
|
2489
|
+
requestBodyRequired: isPlainObject(requestBody) && Boolean(requestBody.required),
|
|
2490
|
+
responseBodyContent: extractResponseBodyContent(operation.responses),
|
|
2491
|
+
security: normalizeSecurity(operation.security) ?? normalizeSecurity(rootSecurity),
|
|
2492
|
+
securitySchemes,
|
|
2493
|
+
specSource
|
|
2494
|
+
};
|
|
2495
|
+
const methodOperations = operationsByMethod.get(method) ?? [];
|
|
2496
|
+
methodOperations.push(item);
|
|
2497
|
+
operationsByMethod.set(method, methodOperations);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
function indexDocument(document, specVersion, config, specSource, operationsByMethod) {
|
|
2505
|
+
const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config);
|
|
2506
|
+
const resolvedRefMap = /* @__PURE__ */ new Map();
|
|
2507
|
+
const ctx = { problems: [], specVersion, config, visitorsData: {} };
|
|
2508
|
+
const normalizedVisitors = normalizeVisitors(
|
|
2509
|
+
[
|
|
2510
|
+
{
|
|
2511
|
+
severity: "warn",
|
|
2512
|
+
ruleId: "drift-index",
|
|
2513
|
+
visitor: createIndexVisitor(specSource, operationsByMethod)
|
|
2514
|
+
}
|
|
2515
|
+
],
|
|
2516
|
+
types
|
|
2517
|
+
);
|
|
2518
|
+
walkDocument({
|
|
2519
|
+
document,
|
|
2520
|
+
rootType: types.Root,
|
|
2521
|
+
normalizedVisitors,
|
|
2522
|
+
resolvedRefMap,
|
|
2523
|
+
ctx
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
function serverIdentity(server) {
|
|
2527
|
+
return `${server.host ?? ""}|${server.basePath}`;
|
|
2528
|
+
}
|
|
2529
|
+
function warnAboutCollidingOperations(operationsByMethod) {
|
|
2530
|
+
for (const operations of operationsByMethod.values()) {
|
|
2531
|
+
const byPathPattern = /* @__PURE__ */ new Map();
|
|
2532
|
+
for (const operation of operations) {
|
|
2533
|
+
const group = byPathPattern.get(operation.pathRegex.source) ?? [];
|
|
2534
|
+
group.push(operation);
|
|
2535
|
+
byPathPattern.set(operation.pathRegex.source, group);
|
|
2536
|
+
}
|
|
2537
|
+
for (const group of byPathPattern.values()) {
|
|
2538
|
+
const sourcesByServer = /* @__PURE__ */ new Map();
|
|
2539
|
+
for (const operation of group) {
|
|
2540
|
+
for (const server of operation.servers) {
|
|
2541
|
+
const key = serverIdentity(server);
|
|
2542
|
+
const sources = sourcesByServer.get(key) ?? /* @__PURE__ */ new Set();
|
|
2543
|
+
sources.add(operation.specSource);
|
|
2544
|
+
sourcesByServer.set(key, sources);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
for (const sources of sourcesByServer.values()) {
|
|
2548
|
+
if (sources.size > 1) {
|
|
2549
|
+
const [first] = group;
|
|
2550
|
+
logger.warn(
|
|
2551
|
+
`"${first.method.toUpperCase()} ${first.pathTemplate}" is documented for the same server in multiple descriptions (${Array.from(
|
|
2552
|
+
sources
|
|
2553
|
+
).join(", ")}). Matching traffic is validated against only one of them.
|
|
2554
|
+
`
|
|
2555
|
+
);
|
|
2556
|
+
break;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
function finalizeIndex(operationsByMethod, loadedSpecs) {
|
|
2563
|
+
for (const operations of operationsByMethod.values()) {
|
|
2564
|
+
operations.sort((left, right) => right.pathScore - left.pathScore);
|
|
2565
|
+
}
|
|
2566
|
+
const loadedOperations = Array.from(operationsByMethod.values()).reduce(
|
|
2567
|
+
(acc, current) => acc + current.length,
|
|
2568
|
+
0
|
|
2569
|
+
);
|
|
2570
|
+
return { operationsByMethod, loadedSpecs, loadedOperations };
|
|
2571
|
+
}
|
|
2572
|
+
async function resolveSpecFiles(specPath) {
|
|
2573
|
+
const stats = await stat2(specPath);
|
|
2574
|
+
if (stats.isDirectory()) {
|
|
2575
|
+
return { specFiles: await listOpenApiFiles(specPath), fromDirectory: true };
|
|
2576
|
+
}
|
|
2577
|
+
return { specFiles: [specPath], fromDirectory: false };
|
|
2578
|
+
}
|
|
2579
|
+
async function loadOpenApiIndex(specPath, config) {
|
|
2580
|
+
const { specFiles, fromDirectory } = await resolveSpecFiles(specPath);
|
|
2581
|
+
const operationsByMethod = /* @__PURE__ */ new Map();
|
|
2582
|
+
const externalRefResolver = new BaseResolver(config.resolve);
|
|
2583
|
+
let loadedSpecs = 0;
|
|
2584
|
+
for (const specFile of specFiles) {
|
|
2585
|
+
const resolvedDocument = await externalRefResolver.resolveDocument(null, specFile, true);
|
|
2586
|
+
if (resolvedDocument instanceof Error) {
|
|
2587
|
+
logger.warn(`Failed to load OpenAPI description ${specFile}: ${resolvedDocument.message}
|
|
2588
|
+
`);
|
|
2589
|
+
continue;
|
|
2590
|
+
}
|
|
2591
|
+
const specVersion = detectOpenApi3Spec(resolvedDocument);
|
|
2592
|
+
if (!specVersion) {
|
|
2593
|
+
if (!fromDirectory) {
|
|
2594
|
+
logger.warn(`Skipping ${specFile}: not an OpenAPI 3.x description.
|
|
2595
|
+
`);
|
|
2596
|
+
}
|
|
2597
|
+
continue;
|
|
2598
|
+
}
|
|
2599
|
+
let document;
|
|
2600
|
+
try {
|
|
2601
|
+
const { bundle: bundled } = await bundle({
|
|
2602
|
+
config,
|
|
2603
|
+
doc: resolvedDocument,
|
|
2604
|
+
externalRefResolver,
|
|
2605
|
+
dereference: true
|
|
2606
|
+
});
|
|
2607
|
+
document = bundled;
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
logger.warn(
|
|
2610
|
+
`Failed to bundle OpenAPI description ${specFile}: ${error.message}
|
|
2611
|
+
`
|
|
2612
|
+
);
|
|
2613
|
+
continue;
|
|
2614
|
+
}
|
|
2615
|
+
loadedSpecs += 1;
|
|
2616
|
+
indexDocument(document, specVersion, config, specFile, operationsByMethod);
|
|
2617
|
+
}
|
|
2618
|
+
if (loadedSpecs > 1) {
|
|
2619
|
+
warnAboutCollidingOperations(operationsByMethod);
|
|
2620
|
+
}
|
|
2621
|
+
return finalizeIndex(operationsByMethod, loadedSpecs);
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// src/commands/drift/utils/args.ts
|
|
2625
|
+
function parseCsv(input) {
|
|
2626
|
+
return input.split(",").map((value) => value.trim()).filter(Boolean);
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// src/commands/drift/log-formats/helpers.ts
|
|
2630
|
+
import { createReadStream } from "node:fs";
|
|
2631
|
+
import { readFile } from "node:fs/promises";
|
|
2632
|
+
import { createInterface } from "node:readline";
|
|
2633
|
+
function coerceString(value) {
|
|
2634
|
+
if (value === void 0 || value === null) {
|
|
2635
|
+
return void 0;
|
|
2636
|
+
}
|
|
2637
|
+
if (typeof value === "string") {
|
|
2638
|
+
return value;
|
|
2639
|
+
}
|
|
2640
|
+
if (Buffer.isBuffer(value)) {
|
|
2641
|
+
return value.toString("utf8");
|
|
2642
|
+
}
|
|
2643
|
+
if (isPlainObject(value) || Array.isArray(value)) {
|
|
2644
|
+
try {
|
|
2645
|
+
return JSON.stringify(value);
|
|
2646
|
+
} catch {
|
|
2647
|
+
return String(value);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
return String(value);
|
|
2651
|
+
}
|
|
2652
|
+
function coerceNumber(value) {
|
|
2653
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
2654
|
+
return value;
|
|
2655
|
+
}
|
|
2656
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
2657
|
+
const parsed = Number(value);
|
|
2658
|
+
if (Number.isFinite(parsed)) {
|
|
2659
|
+
return parsed;
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
return void 0;
|
|
2663
|
+
}
|
|
2664
|
+
function decodeBody(value, encoding) {
|
|
2665
|
+
if (value === void 0 || value === null) {
|
|
2666
|
+
return void 0;
|
|
2667
|
+
}
|
|
2668
|
+
if (encoding === "base64" && typeof value === "string") {
|
|
2669
|
+
try {
|
|
2670
|
+
return Buffer.from(value, "base64").toString("utf8");
|
|
2671
|
+
} catch {
|
|
2672
|
+
return value;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
return coerceString(value);
|
|
2676
|
+
}
|
|
2677
|
+
function createNormalizedExchange(seed, index, source) {
|
|
2678
|
+
const method = seed.method?.toUpperCase();
|
|
2679
|
+
const url = seed.url;
|
|
2680
|
+
if (!method || !url) {
|
|
2681
|
+
return null;
|
|
2682
|
+
}
|
|
2683
|
+
const requestHeaders = normalizeHeaders(seed.requestHeaders);
|
|
2684
|
+
let parsedUrl = parseUrl(url);
|
|
2685
|
+
if (isSyntheticHost(parsedUrl.host) && requestHeaders.host) {
|
|
2686
|
+
try {
|
|
2687
|
+
parsedUrl = new URL(
|
|
2688
|
+
`${parsedUrl.protocol}//${requestHeaders.host}${parsedUrl.pathname}${parsedUrl.search}`
|
|
2689
|
+
);
|
|
2690
|
+
} catch {
|
|
2691
|
+
parsedUrl = parseUrl(url);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
const requestContentType = seed.requestContentType ?? requestHeaders["content-type"];
|
|
2695
|
+
const requestBodyText = decodeBody(seed.requestBody);
|
|
2696
|
+
const request = {
|
|
2697
|
+
method,
|
|
2698
|
+
url: parsedUrl.toString(),
|
|
2699
|
+
path: parsedUrl.pathname,
|
|
2700
|
+
query: parsedUrl.searchParams,
|
|
2701
|
+
protocol: parsedUrl.protocol,
|
|
2702
|
+
protocolKnown: seed.schemeKnown ?? /^https?:\/\//i.test(url),
|
|
2703
|
+
host: isSyntheticHost(parsedUrl.host) ? void 0 : parsedUrl.host || void 0,
|
|
2704
|
+
headers: requestHeaders,
|
|
2705
|
+
contentType: requestContentType,
|
|
2706
|
+
bodyText: requestBodyText,
|
|
2707
|
+
bodyJson: parseJsonBodyIfPresent(requestContentType, requestBodyText)
|
|
2708
|
+
};
|
|
2709
|
+
let response;
|
|
2710
|
+
const responseStatus = seed.responseStatus;
|
|
2711
|
+
if (responseStatus !== void 0) {
|
|
2712
|
+
const responseHeaders = normalizeHeaders(seed.responseHeaders);
|
|
2713
|
+
const responseContentType = seed.responseContentType ?? responseHeaders["content-type"];
|
|
2714
|
+
const responseBodyText = decodeBody(seed.responseBody);
|
|
2715
|
+
response = {
|
|
2716
|
+
status: responseStatus,
|
|
2717
|
+
statusText: seed.responseStatusText,
|
|
2718
|
+
headers: responseHeaders,
|
|
2719
|
+
contentType: responseContentType,
|
|
2720
|
+
bodyText: responseBodyText,
|
|
2721
|
+
bodyJson: parseJsonBodyIfPresent(responseContentType, responseBodyText)
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
return {
|
|
2725
|
+
index,
|
|
2726
|
+
source,
|
|
2727
|
+
startedAt: seed.startedAt,
|
|
2728
|
+
request,
|
|
2729
|
+
response,
|
|
2730
|
+
raw: seed.raw
|
|
2731
|
+
};
|
|
2732
|
+
}
|
|
2733
|
+
async function* streamNdjsonObjects(filePath) {
|
|
2734
|
+
const readStream = createReadStream(filePath, { encoding: "utf8" });
|
|
2735
|
+
const reader = createInterface({ input: readStream, crlfDelay: Infinity });
|
|
2736
|
+
for await (const line of reader) {
|
|
2737
|
+
const trimmed = line.trim();
|
|
2738
|
+
if (!trimmed) {
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
try {
|
|
2742
|
+
const parsed = JSON.parse(trimmed);
|
|
2743
|
+
if (isPlainObject(parsed)) {
|
|
2744
|
+
yield parsed;
|
|
2745
|
+
}
|
|
2746
|
+
} catch {
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
async function* iterateJsonArray(filePath, arrayPath) {
|
|
2751
|
+
const content = await readFile(filePath, "utf8");
|
|
2752
|
+
let value = JSON.parse(content);
|
|
2753
|
+
if (arrayPath) {
|
|
2754
|
+
for (const key of arrayPath.split(".")) {
|
|
2755
|
+
value = isPlainObject(value) ? value[key] : void 0;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
if (Array.isArray(value)) {
|
|
2759
|
+
for (const item of value) {
|
|
2760
|
+
if (isPlainObject(item)) {
|
|
2761
|
+
yield item;
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
function pickHeaderContentType(headers) {
|
|
2767
|
+
const normalized = normalizeHeaders(headers);
|
|
2768
|
+
return normalized["content-type"];
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
export {
|
|
2772
|
+
renderReport,
|
|
2773
|
+
readProbe,
|
|
2774
|
+
normalizeFsPath,
|
|
2775
|
+
listFilesRecursively,
|
|
2776
|
+
normalizeContentType,
|
|
2777
|
+
isJsonMime,
|
|
2778
|
+
coerceString,
|
|
2779
|
+
coerceNumber,
|
|
2780
|
+
decodeBody,
|
|
2781
|
+
createNormalizedExchange,
|
|
2782
|
+
streamNdjsonObjects,
|
|
2783
|
+
iterateJsonArray,
|
|
2784
|
+
pickHeaderContentType,
|
|
2785
|
+
ValidationSession,
|
|
2786
|
+
loadOpenApiIndex,
|
|
2787
|
+
parseCsv
|
|
2788
|
+
};
|