@safets-org/cli 1.0.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/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/analyze.d.ts +3 -0
- package/dist/analyze.js +330 -0
- package/dist/detectors/index.d.ts +12 -0
- package/dist/detectors/index.js +785 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +141 -0
- package/dist/reporters/baseline.d.ts +8 -0
- package/dist/reporters/baseline.js +53 -0
- package/dist/reporters/index.d.ts +5 -0
- package/dist/reporters/index.js +208 -0
- package/dist/reporters/json.d.ts +71 -0
- package/dist/reporters/json.js +170 -0
- package/dist/utils/ast.d.ts +11 -0
- package/dist/utils/ast.js +66 -0
- package/dist/utils/colors.d.ts +8 -0
- package/dist/utils/colors.js +8 -0
- package/dist/utils/files.d.ts +9 -0
- package/dist/utils/files.js +123 -0
- package/dist/utils/types.d.ts +45 -0
- package/dist/utils/types.js +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,785 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { getChainRoot, hasNonNullAssertion, isInsideTryCatch, isNullable, isOptionalAccess, isSubChainDuplicate, pos, } from "../utils/ast.js";
|
|
3
|
+
export function detectFallbackPatterns(sf) {
|
|
4
|
+
const results = [];
|
|
5
|
+
function visit(node) {
|
|
6
|
+
if (ts.isCallExpression(node) &&
|
|
7
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
8
|
+
node.expression.name.getText() === "parse" &&
|
|
9
|
+
node.expression.expression.getText() === "JSON" &&
|
|
10
|
+
!isInsideTryCatch(node)) {
|
|
11
|
+
const { line, col } = pos(sf, node);
|
|
12
|
+
results.push({
|
|
13
|
+
file: sf.fileName,
|
|
14
|
+
line,
|
|
15
|
+
col,
|
|
16
|
+
expr: node.getText(),
|
|
17
|
+
rootExpr: "JSON.parse",
|
|
18
|
+
type: "unknown",
|
|
19
|
+
pattern: "Unprotected JSON.parse",
|
|
20
|
+
confidence: "HIGH",
|
|
21
|
+
fallback: true,
|
|
22
|
+
crashPath: [
|
|
23
|
+
"JSON.parse(input) - throws SyntaxError if input is malformed",
|
|
24
|
+
"Unhandled exception -> process crash",
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (ts.isNonNullExpression(node) &&
|
|
29
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
30
|
+
ts.isPropertyAccessExpression(node.expression.expression) &&
|
|
31
|
+
node.expression.expression.expression.getText() === "process" &&
|
|
32
|
+
node.expression.expression.name.getText() === "env") {
|
|
33
|
+
const envVar = node.expression.name.getText();
|
|
34
|
+
const { line, col } = pos(sf, node);
|
|
35
|
+
results.push({
|
|
36
|
+
file: sf.fileName,
|
|
37
|
+
line,
|
|
38
|
+
col,
|
|
39
|
+
expr: node.getText(),
|
|
40
|
+
rootExpr: `process.env.${envVar}`,
|
|
41
|
+
type: "string | undefined",
|
|
42
|
+
pattern: "Unsafe process.env access",
|
|
43
|
+
confidence: "HIGH",
|
|
44
|
+
fallback: true,
|
|
45
|
+
crashPath: [
|
|
46
|
+
`process.env.${envVar}! - non-null assertion used`,
|
|
47
|
+
`If ${envVar} is not set, crash is silently bypassed by compiler`,
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
ts.forEachChild(node, visit);
|
|
52
|
+
}
|
|
53
|
+
visit(sf);
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
export function detectUnsafePropertyAccess(sf, checker) {
|
|
57
|
+
const results = [];
|
|
58
|
+
function visit(node) {
|
|
59
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
60
|
+
if (isOptionalAccess(node) || hasNonNullAssertion(node) || isSubChainDuplicate(node, checker)) {
|
|
61
|
+
ts.forEachChild(node, visit);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const objectType = checker.getTypeAtLocation(node.expression);
|
|
66
|
+
if (isNullable(objectType)) {
|
|
67
|
+
const { line, col } = pos(sf, node);
|
|
68
|
+
const prop = node.name.getText();
|
|
69
|
+
const objectExpr = node.expression.getText();
|
|
70
|
+
results.push({
|
|
71
|
+
file: sf.fileName,
|
|
72
|
+
line,
|
|
73
|
+
col,
|
|
74
|
+
expr: node.getText(),
|
|
75
|
+
rootExpr: getChainRoot(node.expression).getText(),
|
|
76
|
+
type: checker.typeToString(objectType),
|
|
77
|
+
pattern: "Unsafe property access",
|
|
78
|
+
confidence: "HIGH",
|
|
79
|
+
crashPath: [
|
|
80
|
+
`${objectExpr} -> ${checker.typeToString(objectType)}`,
|
|
81
|
+
`${objectExpr} may be undefined at runtime`,
|
|
82
|
+
`${objectExpr}.${prop} -> Cannot read properties of undefined (reading '${prop}')`,
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Ignore nodes where type resolution fails.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(node, visit);
|
|
92
|
+
}
|
|
93
|
+
visit(sf);
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
export function detectUnsafeDestructuring(sf, checker) {
|
|
97
|
+
const results = [];
|
|
98
|
+
function visit(node) {
|
|
99
|
+
if (ts.isVariableDeclaration(node) &&
|
|
100
|
+
node.initializer &&
|
|
101
|
+
ts.isObjectBindingPattern(node.name)) {
|
|
102
|
+
try {
|
|
103
|
+
const initialType = checker.getTypeAtLocation(node.initializer);
|
|
104
|
+
if (isNullable(initialType)) {
|
|
105
|
+
const { line, col } = pos(sf, node);
|
|
106
|
+
const initializerText = node.initializer.getText();
|
|
107
|
+
results.push({
|
|
108
|
+
file: sf.fileName,
|
|
109
|
+
line,
|
|
110
|
+
col,
|
|
111
|
+
expr: `const ${node.name.getText()} = ${initializerText}`,
|
|
112
|
+
rootExpr: initializerText,
|
|
113
|
+
type: checker.typeToString(initialType),
|
|
114
|
+
pattern: "Unsafe destructuring",
|
|
115
|
+
confidence: "HIGH",
|
|
116
|
+
crashPath: [
|
|
117
|
+
`${initializerText} -> ${checker.typeToString(initialType)}`,
|
|
118
|
+
"Cannot destructure property of undefined",
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Ignore nodes where type resolution fails.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
ts.forEachChild(node, visit);
|
|
128
|
+
}
|
|
129
|
+
visit(sf);
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
export function detectUnsafeArrayAccess(sf, checker) {
|
|
133
|
+
const results = [];
|
|
134
|
+
function visit(node) {
|
|
135
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
136
|
+
ts.isElementAccessExpression(node.expression) &&
|
|
137
|
+
node.questionDotToken === undefined) {
|
|
138
|
+
try {
|
|
139
|
+
const elementType = checker.getTypeAtLocation(node.expression);
|
|
140
|
+
if (isNullable(elementType)) {
|
|
141
|
+
const { line, col } = pos(sf, node);
|
|
142
|
+
const arrayExpr = node.expression.getText();
|
|
143
|
+
const prop = node.name.getText();
|
|
144
|
+
results.push({
|
|
145
|
+
file: sf.fileName,
|
|
146
|
+
line,
|
|
147
|
+
col,
|
|
148
|
+
expr: node.getText(),
|
|
149
|
+
rootExpr: arrayExpr,
|
|
150
|
+
type: checker.typeToString(elementType),
|
|
151
|
+
pattern: "Unsafe array index access",
|
|
152
|
+
confidence: "HIGH",
|
|
153
|
+
crashPath: [
|
|
154
|
+
`${arrayExpr} -> ${checker.typeToString(elementType)} (may be out of bounds)`,
|
|
155
|
+
`${arrayExpr}.${prop} -> Cannot read properties of undefined (reading '${prop}')`,
|
|
156
|
+
],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// Ignore nodes where type resolution fails.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
ts.forEachChild(node, visit);
|
|
165
|
+
}
|
|
166
|
+
visit(sf);
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
export function detectUnsafeJsonParse(sf) {
|
|
170
|
+
const results = [];
|
|
171
|
+
function visit(node) {
|
|
172
|
+
if (ts.isCallExpression(node) &&
|
|
173
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
174
|
+
node.expression.name.getText() === "parse" &&
|
|
175
|
+
node.expression.expression.getText() === "JSON" &&
|
|
176
|
+
!isInsideTryCatch(node)) {
|
|
177
|
+
const { line, col } = pos(sf, node);
|
|
178
|
+
results.push({
|
|
179
|
+
file: sf.fileName,
|
|
180
|
+
line,
|
|
181
|
+
col,
|
|
182
|
+
expr: node.getText(),
|
|
183
|
+
rootExpr: "JSON.parse",
|
|
184
|
+
type: "unknown",
|
|
185
|
+
pattern: "Unprotected JSON.parse",
|
|
186
|
+
confidence: "HIGH",
|
|
187
|
+
crashPath: [
|
|
188
|
+
"JSON.parse(input) - throws SyntaxError if input is malformed",
|
|
189
|
+
"Unhandled exception -> process crash",
|
|
190
|
+
],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
ts.forEachChild(node, visit);
|
|
194
|
+
}
|
|
195
|
+
visit(sf);
|
|
196
|
+
return results;
|
|
197
|
+
}
|
|
198
|
+
export function detectUnsafeEnvAccess(sf, checker) {
|
|
199
|
+
const results = [];
|
|
200
|
+
function isEnvAccess(candidate) {
|
|
201
|
+
return (ts.isPropertyAccessExpression(candidate) &&
|
|
202
|
+
ts.isPropertyAccessExpression(candidate.expression) &&
|
|
203
|
+
ts.isIdentifier(candidate.expression.expression) &&
|
|
204
|
+
candidate.expression.expression.text === "process" &&
|
|
205
|
+
ts.isIdentifier(candidate.expression.name) &&
|
|
206
|
+
candidate.expression.name.text === "env");
|
|
207
|
+
}
|
|
208
|
+
function isSafelyDefaulted(node) {
|
|
209
|
+
let current = node;
|
|
210
|
+
let foundDefault = false;
|
|
211
|
+
let pendingNonNullWrappers = [];
|
|
212
|
+
const allowedNonNullWrappers = new Set();
|
|
213
|
+
function containsEnvAccess(child) {
|
|
214
|
+
let found = false;
|
|
215
|
+
function visitEnv(candidate) {
|
|
216
|
+
if (isEnvAccess(candidate)) {
|
|
217
|
+
found = true;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
ts.forEachChild(candidate, visitEnv);
|
|
221
|
+
}
|
|
222
|
+
visitEnv(child);
|
|
223
|
+
return found;
|
|
224
|
+
}
|
|
225
|
+
function isUnsafeNonNullEnvExpression(candidate) {
|
|
226
|
+
if (!ts.isNonNullExpression(candidate) ||
|
|
227
|
+
!containsEnvAccess(candidate.expression) ||
|
|
228
|
+
allowedNonNullWrappers.has(candidate)) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
try {
|
|
232
|
+
return isNullable(checker.getTypeAtLocation(candidate.expression));
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function hasUnsafeEnvNonNullAssertionOnPath(start, root) {
|
|
239
|
+
let candidate = start;
|
|
240
|
+
while (candidate) {
|
|
241
|
+
if (isUnsafeNonNullEnvExpression(candidate)) {
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
if (candidate === root) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
candidate = candidate.parent;
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
while (true) {
|
|
252
|
+
while (ts.isParenthesizedExpression(current.parent) ||
|
|
253
|
+
ts.isNonNullExpression(current.parent)) {
|
|
254
|
+
if (ts.isNonNullExpression(current.parent)) {
|
|
255
|
+
pendingNonNullWrappers.push(current.parent);
|
|
256
|
+
}
|
|
257
|
+
current = current.parent;
|
|
258
|
+
}
|
|
259
|
+
const parent = current.parent;
|
|
260
|
+
if (!ts.isBinaryExpression(parent) ||
|
|
261
|
+
(parent.operatorToken.kind !== ts.SyntaxKind.QuestionQuestionToken &&
|
|
262
|
+
parent.operatorToken.kind !== ts.SyntaxKind.BarBarToken)) {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
if (parent.left === current) {
|
|
266
|
+
if (ts.isNonNullExpression(current) && isEnvAccess(current.expression)) {
|
|
267
|
+
allowedNonNullWrappers.add(current);
|
|
268
|
+
}
|
|
269
|
+
pendingNonNullWrappers.forEach((wrapper) => allowedNonNullWrappers.add(wrapper));
|
|
270
|
+
foundDefault = true;
|
|
271
|
+
}
|
|
272
|
+
pendingNonNullWrappers = [];
|
|
273
|
+
current = parent;
|
|
274
|
+
}
|
|
275
|
+
if (!foundDefault || hasUnsafeEnvNonNullAssertionOnPath(node, current)) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
return !isNullable(checker.getTypeAtLocation(current));
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function visit(node) {
|
|
286
|
+
if (isEnvAccess(node) &&
|
|
287
|
+
!isSafelyDefaulted(node) &&
|
|
288
|
+
!ts.isNonNullExpression(node.parent)) {
|
|
289
|
+
try {
|
|
290
|
+
const envVar = node.name.getText();
|
|
291
|
+
const envType = checker.getTypeAtLocation(node);
|
|
292
|
+
if (isNullable(envType)) {
|
|
293
|
+
const { line, col } = pos(sf, node);
|
|
294
|
+
results.push({
|
|
295
|
+
file: sf.fileName,
|
|
296
|
+
line,
|
|
297
|
+
col,
|
|
298
|
+
expr: node.getText(),
|
|
299
|
+
rootExpr: `process.env.${envVar}`,
|
|
300
|
+
type: checker.typeToString(envType),
|
|
301
|
+
pattern: "Unsafe process.env access",
|
|
302
|
+
confidence: "HIGH",
|
|
303
|
+
crashPath: [
|
|
304
|
+
`process.env.${envVar} -> string | undefined`,
|
|
305
|
+
"If not set in environment -> crash",
|
|
306
|
+
],
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Ignore nodes where type resolution fails.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (ts.isNonNullExpression(node) &&
|
|
315
|
+
isEnvAccess(node.expression) &&
|
|
316
|
+
!isSafelyDefaulted(node)) {
|
|
317
|
+
const envVar = node.expression.name.getText();
|
|
318
|
+
const { line, col } = pos(sf, node);
|
|
319
|
+
results.push({
|
|
320
|
+
file: sf.fileName,
|
|
321
|
+
line,
|
|
322
|
+
col,
|
|
323
|
+
expr: node.getText(),
|
|
324
|
+
rootExpr: `process.env.${envVar}`,
|
|
325
|
+
type: "string | undefined",
|
|
326
|
+
pattern: "Unsafe process.env access",
|
|
327
|
+
confidence: "HIGH",
|
|
328
|
+
crashPath: [
|
|
329
|
+
`process.env.${envVar}! - non-null assertion`,
|
|
330
|
+
"If missing, crash is silently bypassed by compiler",
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
ts.forEachChild(node, visit);
|
|
335
|
+
}
|
|
336
|
+
visit(sf);
|
|
337
|
+
return results;
|
|
338
|
+
}
|
|
339
|
+
export function detectNonNullAssertionOnNullable(sf, checker) {
|
|
340
|
+
const results = [];
|
|
341
|
+
function visit(node) {
|
|
342
|
+
if (ts.isNonNullExpression(node)) {
|
|
343
|
+
if (node.expression.getText().startsWith("process.env")) {
|
|
344
|
+
ts.forEachChild(node, visit);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
const innerType = checker.getTypeAtLocation(node.expression);
|
|
349
|
+
if (isNullable(innerType)) {
|
|
350
|
+
const { line, col } = pos(sf, node);
|
|
351
|
+
results.push({
|
|
352
|
+
file: sf.fileName,
|
|
353
|
+
line,
|
|
354
|
+
col,
|
|
355
|
+
expr: node.getText(),
|
|
356
|
+
rootExpr: node.expression.getText(),
|
|
357
|
+
type: checker.typeToString(innerType),
|
|
358
|
+
pattern: "Non-null assertion on nullable",
|
|
359
|
+
confidence: "MEDIUM",
|
|
360
|
+
crashPath: [
|
|
361
|
+
`${node.expression.getText()} -> ${checker.typeToString(innerType)}`,
|
|
362
|
+
"! suppresses the TypeScript error",
|
|
363
|
+
"If undefined at runtime -> crash",
|
|
364
|
+
],
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
// Ignore nodes where type resolution fails.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
ts.forEachChild(node, visit);
|
|
373
|
+
}
|
|
374
|
+
visit(sf);
|
|
375
|
+
return results;
|
|
376
|
+
}
|
|
377
|
+
export function detectUnsafeAccessAfterAwait(sf, checker) {
|
|
378
|
+
const results = [];
|
|
379
|
+
function analyzeFunction(body) {
|
|
380
|
+
const narrowedVarTypes = new Map();
|
|
381
|
+
const callableBodies = new Map();
|
|
382
|
+
const callStack = new Set();
|
|
383
|
+
function isFunctionLike(node) {
|
|
384
|
+
return (ts.isFunctionDeclaration(node) ||
|
|
385
|
+
ts.isMethodDeclaration(node) ||
|
|
386
|
+
ts.isArrowFunction(node) ||
|
|
387
|
+
ts.isFunctionExpression(node));
|
|
388
|
+
}
|
|
389
|
+
function isImmediatelyInvokedFunction(node) {
|
|
390
|
+
let current = node;
|
|
391
|
+
while (ts.isParenthesizedExpression(current.parent)) {
|
|
392
|
+
current = current.parent;
|
|
393
|
+
}
|
|
394
|
+
return ts.isCallExpression(current.parent) && current.parent.expression === current;
|
|
395
|
+
}
|
|
396
|
+
function collectCallableBodies(node) {
|
|
397
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
398
|
+
const symbol = node.name ? checker.getSymbolAtLocation(node.name) : undefined;
|
|
399
|
+
if (symbol && node.body) {
|
|
400
|
+
callableBodies.set(symbol, node.body);
|
|
401
|
+
collectCallableBodies(node.body);
|
|
402
|
+
}
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (ts.isVariableDeclaration(node) &&
|
|
406
|
+
ts.isIdentifier(node.name) &&
|
|
407
|
+
node.initializer &&
|
|
408
|
+
(ts.isFunctionExpression(node.initializer) || ts.isArrowFunction(node.initializer))) {
|
|
409
|
+
const symbol = checker.getSymbolAtLocation(node.name);
|
|
410
|
+
if (symbol) {
|
|
411
|
+
callableBodies.set(symbol, node.initializer.body);
|
|
412
|
+
collectCallableBodies(node.initializer.body);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (isFunctionLike(node)) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
ts.forEachChild(node, collectCallableBodies);
|
|
420
|
+
}
|
|
421
|
+
function getEarlyReturnGuardIdentifier(node) {
|
|
422
|
+
const condition = unwrapExpression(node.expression);
|
|
423
|
+
let identifier = null;
|
|
424
|
+
function isNullishLiteral(candidate) {
|
|
425
|
+
return (candidate.kind === ts.SyntaxKind.NullKeyword ||
|
|
426
|
+
(ts.isIdentifier(candidate) && candidate.text === "undefined"));
|
|
427
|
+
}
|
|
428
|
+
if (ts.isPrefixUnaryExpression(condition) &&
|
|
429
|
+
condition.operator === ts.SyntaxKind.ExclamationToken &&
|
|
430
|
+
ts.isIdentifier(unwrapExpression(condition.operand))) {
|
|
431
|
+
identifier = unwrapExpression(condition.operand);
|
|
432
|
+
}
|
|
433
|
+
if (ts.isBinaryExpression(condition)) {
|
|
434
|
+
const op = condition.operatorToken.kind;
|
|
435
|
+
if (op === ts.SyntaxKind.EqualsEqualsEqualsToken ||
|
|
436
|
+
op === ts.SyntaxKind.EqualsEqualsToken) {
|
|
437
|
+
const left = unwrapExpression(condition.left);
|
|
438
|
+
const right = unwrapExpression(condition.right);
|
|
439
|
+
if (ts.isIdentifier(left) && isNullishLiteral(right)) {
|
|
440
|
+
identifier = left;
|
|
441
|
+
}
|
|
442
|
+
else if (ts.isIdentifier(right) && isNullishLiteral(left)) {
|
|
443
|
+
identifier = right;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!identifier) {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const isEarlyExit = (statement) => {
|
|
451
|
+
if (ts.isReturnStatement(statement) || ts.isThrowStatement(statement)) {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if (ts.isBlock(statement)) {
|
|
455
|
+
const lastStatement = statement.statements.at(-1);
|
|
456
|
+
return lastStatement ? isEarlyExit(lastStatement) : false;
|
|
457
|
+
}
|
|
458
|
+
return (ts.isIfStatement(statement) &&
|
|
459
|
+
!!statement.elseStatement &&
|
|
460
|
+
isEarlyExit(statement.thenStatement) &&
|
|
461
|
+
isEarlyExit(statement.elseStatement));
|
|
462
|
+
};
|
|
463
|
+
if (!isEarlyExit(node.thenStatement)) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
return identifier;
|
|
467
|
+
}
|
|
468
|
+
function getEarlyReturnGuard(node) {
|
|
469
|
+
const identifier = getEarlyReturnGuardIdentifier(node);
|
|
470
|
+
if (!identifier) {
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const symbol = checker.getSymbolAtLocation(identifier);
|
|
475
|
+
if (!symbol) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const existing = narrowedVarTypes.get(symbol);
|
|
479
|
+
if (existing) {
|
|
480
|
+
return {
|
|
481
|
+
symbol,
|
|
482
|
+
name: existing.name,
|
|
483
|
+
type: existing.type,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const type = checker.getTypeAtLocation(identifier);
|
|
487
|
+
if (!isNullable(type)) {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
symbol,
|
|
492
|
+
name: identifier.getText(),
|
|
493
|
+
type: checker.typeToString(type),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function getAssignmentTargets(node) {
|
|
501
|
+
if (!ts.isBinaryExpression(node) ||
|
|
502
|
+
node.operatorToken.kind < ts.SyntaxKind.FirstAssignment ||
|
|
503
|
+
node.operatorToken.kind > ts.SyntaxKind.LastAssignment) {
|
|
504
|
+
return [];
|
|
505
|
+
}
|
|
506
|
+
const symbols = [];
|
|
507
|
+
function collectIdentifierTargets(target) {
|
|
508
|
+
if (ts.isIdentifier(target)) {
|
|
509
|
+
const symbol = checker.getSymbolAtLocation(target);
|
|
510
|
+
if (symbol) {
|
|
511
|
+
symbols.push(symbol);
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (ts.isArrayLiteralExpression(target)) {
|
|
516
|
+
target.elements.forEach(collectIdentifierTargets);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
520
|
+
for (const property of target.properties) {
|
|
521
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
522
|
+
collectIdentifierTargets(property.name);
|
|
523
|
+
}
|
|
524
|
+
else if (ts.isPropertyAssignment(property)) {
|
|
525
|
+
collectIdentifierTargets(property.initializer);
|
|
526
|
+
}
|
|
527
|
+
else if (ts.isSpreadAssignment(property)) {
|
|
528
|
+
collectIdentifierTargets(property.expression);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
collectIdentifierTargets(node.left);
|
|
534
|
+
return symbols;
|
|
535
|
+
}
|
|
536
|
+
function unwrapExpression(expression) {
|
|
537
|
+
let current = expression;
|
|
538
|
+
while (ts.isParenthesizedExpression(current)) {
|
|
539
|
+
current = current.expression;
|
|
540
|
+
}
|
|
541
|
+
return current;
|
|
542
|
+
}
|
|
543
|
+
function isOutermostPropertyAccess(node) {
|
|
544
|
+
return !(ts.isPropertyAccessExpression(node.parent) &&
|
|
545
|
+
node.parent.questionDotToken === undefined);
|
|
546
|
+
}
|
|
547
|
+
function analyzeCalledClosure(symbol, activeAfterAwait, activeNarrowings, assignedSymbols) {
|
|
548
|
+
const calledBody = callableBodies.get(symbol);
|
|
549
|
+
if (!calledBody || callStack.has(symbol)) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
callStack.add(symbol);
|
|
553
|
+
const closureAssignments = new Set();
|
|
554
|
+
if (ts.isBlock(calledBody)) {
|
|
555
|
+
findViolationsInBlock(calledBody, new Set(activeAfterAwait), new Set(activeNarrowings), closureAssignments);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
findViolations(calledBody, new Set(activeAfterAwait), new Set(activeNarrowings), closureAssignments);
|
|
559
|
+
}
|
|
560
|
+
callStack.delete(symbol);
|
|
561
|
+
closureAssignments.forEach((assignedSymbol) => {
|
|
562
|
+
assignedSymbols.add(assignedSymbol);
|
|
563
|
+
activeAfterAwait.delete(assignedSymbol);
|
|
564
|
+
activeNarrowings.delete(assignedSymbol);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
function findViolations(node, activeAfterAwait, activeNarrowings, assignedSymbols) {
|
|
568
|
+
if (isFunctionLike(node)) {
|
|
569
|
+
if (isImmediatelyInvokedFunction(node)) {
|
|
570
|
+
const iifeAssignments = new Set();
|
|
571
|
+
ts.forEachChild(node, (child) => findViolations(child, new Set(activeAfterAwait), new Set(activeNarrowings), iifeAssignments));
|
|
572
|
+
iifeAssignments.forEach((assignedSymbol) => {
|
|
573
|
+
assignedSymbols.add(assignedSymbol);
|
|
574
|
+
activeAfterAwait.delete(assignedSymbol);
|
|
575
|
+
activeNarrowings.delete(assignedSymbol);
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (ts.isBlock(node)) {
|
|
581
|
+
const isConditionalBranch = ts.isIfStatement(node.parent) &&
|
|
582
|
+
(node.parent.thenStatement === node || node.parent.elseStatement === node);
|
|
583
|
+
const blockAfterAwait = new Set(activeAfterAwait);
|
|
584
|
+
const blockNarrowings = new Set(activeNarrowings);
|
|
585
|
+
const blockAssignments = new Set();
|
|
586
|
+
findViolationsInBlock(node, blockAfterAwait, blockNarrowings, blockAssignments);
|
|
587
|
+
if (isConditionalBranch) {
|
|
588
|
+
blockAfterAwait.forEach((symbol) => activeAfterAwait.add(symbol));
|
|
589
|
+
blockAssignments.forEach((assignedSymbol) => {
|
|
590
|
+
assignedSymbols.add(assignedSymbol);
|
|
591
|
+
// A conditional assignment breaks narrowing, but sibling paths may still be post-await.
|
|
592
|
+
activeNarrowings.delete(assignedSymbol);
|
|
593
|
+
});
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
activeAfterAwait.clear();
|
|
597
|
+
blockAfterAwait.forEach((symbol) => activeAfterAwait.add(symbol));
|
|
598
|
+
activeNarrowings.clear();
|
|
599
|
+
blockNarrowings.forEach((symbol) => activeNarrowings.add(symbol));
|
|
600
|
+
blockAssignments.forEach((assignedSymbol) => assignedSymbols.add(assignedSymbol));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const assignmentTargets = getAssignmentTargets(node);
|
|
604
|
+
if (ts.isAwaitExpression(node)) {
|
|
605
|
+
ts.forEachChild(node, (child) => findViolations(child, activeAfterAwait, activeNarrowings, assignedSymbols));
|
|
606
|
+
activeNarrowings.forEach((symbol) => activeAfterAwait.add(symbol));
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (ts.isPropertyAccessExpression(node) && activeAfterAwait.size > 0) {
|
|
610
|
+
const root = getChainRoot(node.expression);
|
|
611
|
+
const rootSymbol = ts.isIdentifier(root)
|
|
612
|
+
? checker.getSymbolAtLocation(root)
|
|
613
|
+
: undefined;
|
|
614
|
+
if (rootSymbol &&
|
|
615
|
+
activeAfterAwait.has(rootSymbol) &&
|
|
616
|
+
!isOptionalAccess(node) &&
|
|
617
|
+
!hasNonNullAssertion(node) &&
|
|
618
|
+
!isSubChainDuplicate(node, checker) &&
|
|
619
|
+
isOutermostPropertyAccess(node)) {
|
|
620
|
+
try {
|
|
621
|
+
const { line, col } = pos(sf, node);
|
|
622
|
+
const originalType = narrowedVarTypes.get(rootSymbol);
|
|
623
|
+
if (!originalType) {
|
|
624
|
+
ts.forEachChild(node, (child) => findViolations(child, activeAfterAwait, activeNarrowings, assignedSymbols));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
results.push({
|
|
628
|
+
file: sf.fileName,
|
|
629
|
+
line,
|
|
630
|
+
col,
|
|
631
|
+
expr: node.getText(),
|
|
632
|
+
rootExpr: originalType.name,
|
|
633
|
+
type: originalType.type,
|
|
634
|
+
pattern: "Unsafe access after await",
|
|
635
|
+
confidence: "MEDIUM",
|
|
636
|
+
crashPath: [
|
|
637
|
+
`${originalType.name} narrowed from ${originalType.type} to defined`,
|
|
638
|
+
"await suspended execution - external state may have changed",
|
|
639
|
+
`${originalType.name} may be undefined again after resuming`,
|
|
640
|
+
`${node.getText()} -> Cannot read properties of undefined`,
|
|
641
|
+
],
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// Ignore nodes where type resolution fails.
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (ts.isCallExpression(node) &&
|
|
650
|
+
ts.isIdentifier(node.expression) &&
|
|
651
|
+
(activeAfterAwait.size > 0 || activeNarrowings.size > 0)) {
|
|
652
|
+
const symbol = checker.getSymbolAtLocation(node.expression);
|
|
653
|
+
if (symbol) {
|
|
654
|
+
analyzeCalledClosure(symbol, activeAfterAwait, activeNarrowings, assignedSymbols);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
ts.forEachChild(node, (child) => findViolations(child, activeAfterAwait, activeNarrowings, assignedSymbols));
|
|
658
|
+
for (const assignmentTarget of assignmentTargets) {
|
|
659
|
+
assignedSymbols.add(assignmentTarget);
|
|
660
|
+
activeNarrowings.delete(assignmentTarget);
|
|
661
|
+
activeAfterAwait.delete(assignmentTarget);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function findViolationsInBlock(block, activeAfterAwait = new Set(), activeNarrowings = new Set(), assignedSymbols = new Set()) {
|
|
665
|
+
for (const statement of block.statements) {
|
|
666
|
+
findViolations(statement, activeAfterAwait, activeNarrowings, assignedSymbols);
|
|
667
|
+
if (ts.isIfStatement(statement)) {
|
|
668
|
+
const guard = getEarlyReturnGuard(statement);
|
|
669
|
+
if (guard) {
|
|
670
|
+
narrowedVarTypes.set(guard.symbol, {
|
|
671
|
+
name: guard.name,
|
|
672
|
+
type: guard.type,
|
|
673
|
+
});
|
|
674
|
+
activeAfterAwait.delete(guard.symbol);
|
|
675
|
+
activeNarrowings.add(guard.symbol);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
collectCallableBodies(body);
|
|
681
|
+
findViolationsInBlock(body);
|
|
682
|
+
}
|
|
683
|
+
function visit(node) {
|
|
684
|
+
if ((ts.isFunctionDeclaration(node) ||
|
|
685
|
+
ts.isMethodDeclaration(node) ||
|
|
686
|
+
ts.isArrowFunction(node) ||
|
|
687
|
+
ts.isFunctionExpression(node)) &&
|
|
688
|
+
node.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) &&
|
|
689
|
+
node.body &&
|
|
690
|
+
ts.isBlock(node.body)) {
|
|
691
|
+
analyzeFunction(node.body);
|
|
692
|
+
}
|
|
693
|
+
ts.forEachChild(node, visit);
|
|
694
|
+
}
|
|
695
|
+
visit(sf);
|
|
696
|
+
return results;
|
|
697
|
+
}
|
|
698
|
+
export function detectUnsafePromiseAllDestructuring(sf, checker) {
|
|
699
|
+
const results = [];
|
|
700
|
+
function visit(node) {
|
|
701
|
+
if (ts.isVariableDeclaration(node) &&
|
|
702
|
+
ts.isArrayBindingPattern(node.name) &&
|
|
703
|
+
node.initializer &&
|
|
704
|
+
ts.isAwaitExpression(node.initializer)) {
|
|
705
|
+
const awaitedExpr = node.initializer.expression;
|
|
706
|
+
if (ts.isCallExpression(awaitedExpr) &&
|
|
707
|
+
ts.isPropertyAccessExpression(awaitedExpr.expression) &&
|
|
708
|
+
awaitedExpr.expression.name.getText() === "all" &&
|
|
709
|
+
awaitedExpr.expression.expression.getText() === "Promise") {
|
|
710
|
+
try {
|
|
711
|
+
const initType = checker.getTypeAtLocation(node.initializer);
|
|
712
|
+
const typeString = checker.typeToString(initType);
|
|
713
|
+
if (isNullable(initType) || typeString.includes("undefined")) {
|
|
714
|
+
const { line, col } = pos(sf, node);
|
|
715
|
+
results.push({
|
|
716
|
+
file: sf.fileName,
|
|
717
|
+
line,
|
|
718
|
+
col,
|
|
719
|
+
expr: node.getText(),
|
|
720
|
+
rootExpr: "Promise.all",
|
|
721
|
+
type: typeString,
|
|
722
|
+
pattern: "Unsafe Promise.all destructuring",
|
|
723
|
+
confidence: "MEDIUM",
|
|
724
|
+
crashPath: [
|
|
725
|
+
"Promise.all result destructured - elements may be undefined",
|
|
726
|
+
"Accessing properties on undefined -> crash",
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
// Ignore nodes where type resolution fails.
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
ts.forEachChild(node, visit);
|
|
737
|
+
}
|
|
738
|
+
visit(sf);
|
|
739
|
+
return results;
|
|
740
|
+
}
|
|
741
|
+
export function detectUnsafeMapAccess(sf, checker) {
|
|
742
|
+
const results = [];
|
|
743
|
+
function visit(node) {
|
|
744
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
745
|
+
ts.isElementAccessExpression(node.expression) &&
|
|
746
|
+
node.questionDotToken === undefined) {
|
|
747
|
+
try {
|
|
748
|
+
const containerType = checker.getTypeAtLocation(node.expression.expression);
|
|
749
|
+
const containerString = checker.typeToString(containerType);
|
|
750
|
+
const isMapLike = containerString.includes("Record<") ||
|
|
751
|
+
containerString.includes("Map<") ||
|
|
752
|
+
containerString.includes("{ [") ||
|
|
753
|
+
containerString.includes("Index");
|
|
754
|
+
if (isMapLike) {
|
|
755
|
+
const elementType = checker.getTypeAtLocation(node.expression);
|
|
756
|
+
if (isNullable(elementType)) {
|
|
757
|
+
const { line, col } = pos(sf, node);
|
|
758
|
+
const mapExpr = node.expression.getText();
|
|
759
|
+
const prop = node.name.getText();
|
|
760
|
+
results.push({
|
|
761
|
+
file: sf.fileName,
|
|
762
|
+
line,
|
|
763
|
+
col,
|
|
764
|
+
expr: node.getText(),
|
|
765
|
+
rootExpr: mapExpr,
|
|
766
|
+
type: checker.typeToString(elementType),
|
|
767
|
+
pattern: "Unsafe Map/Record access",
|
|
768
|
+
confidence: "HIGH",
|
|
769
|
+
crashPath: [
|
|
770
|
+
`${mapExpr} -> ${checker.typeToString(elementType)} (key may not exist)`,
|
|
771
|
+
`${mapExpr}.${prop} -> Cannot read properties of undefined (reading '${prop}')`,
|
|
772
|
+
],
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
// Ignore nodes where type resolution fails.
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
ts.forEachChild(node, visit);
|
|
782
|
+
}
|
|
783
|
+
visit(sf);
|
|
784
|
+
return results;
|
|
785
|
+
}
|