technical-debt-radar 1.2.1 → 1.3.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/dist/index.js +197 -29
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -87,7 +87,9 @@ var require_constants = __commonJS({
|
|
|
87
87
|
EMPTY_CATCH_BLOCK: "empty-catch-block",
|
|
88
88
|
MISSING_ERROR_LOGGING: "missing-error-logging",
|
|
89
89
|
TRANSACTION_NO_TIMEOUT: "transaction-no-timeout",
|
|
90
|
-
MISSING_NULL_GUARD: "missing-null-guard"
|
|
90
|
+
MISSING_NULL_GUARD: "missing-null-guard",
|
|
91
|
+
MISSING_DTO_VALIDATION: "missing-dto-validation",
|
|
92
|
+
UNGUARDED_ROUTE_TODO: "unguarded-route-todo"
|
|
91
93
|
};
|
|
92
94
|
exports2.PERFORMANCE_RULES = {
|
|
93
95
|
UNBOUNDED_FIND_MANY: "unbounded-find-many",
|
|
@@ -98,7 +100,8 @@ var require_constants = __commonJS({
|
|
|
98
100
|
MISSING_PAGINATION: "missing-pagination-endpoint",
|
|
99
101
|
UNFILTERED_COUNT: "unfiltered-count-large-table",
|
|
100
102
|
RAW_SQL_NO_LIMIT: "raw-sql-no-limit",
|
|
101
|
-
RAW_SQL_UNSAFE: "raw-sql-unsafe"
|
|
103
|
+
RAW_SQL_UNSAFE: "raw-sql-unsafe",
|
|
104
|
+
DANGEROUS_DELETE_ALL: "dangerous-delete-all"
|
|
102
105
|
};
|
|
103
106
|
exports2.MAINTAINABILITY_RULES = {
|
|
104
107
|
HIGH_COMPLEXITY: "high-complexity",
|
|
@@ -11265,7 +11268,7 @@ var require_volume_estimator = __commonJS({
|
|
|
11265
11268
|
if (entryName !== "node_modules" && entryName !== "dist" && entryName !== ".git") {
|
|
11266
11269
|
await scan(fullPath);
|
|
11267
11270
|
}
|
|
11268
|
-
} else if (entryName.endsWith(".model.ts") || entryName.endsWith(".model.js")) {
|
|
11271
|
+
} else if (entryName.endsWith(".model.ts") || entryName.endsWith(".model.js") || entryName.endsWith(".schema.ts") || entryName.endsWith(".schema.js")) {
|
|
11269
11272
|
results.push(fullPath);
|
|
11270
11273
|
}
|
|
11271
11274
|
}
|
|
@@ -11313,10 +11316,13 @@ var require_volume_estimator = __commonJS({
|
|
|
11313
11316
|
} catch {
|
|
11314
11317
|
continue;
|
|
11315
11318
|
}
|
|
11316
|
-
if (!/mongoose\.model|new\s+Schema|new\s+mongoose\.Schema|\bmodel\s*\(\s*['"]/.test(content))
|
|
11319
|
+
if (!/mongoose\.model|new\s+Schema|new\s+mongoose\.Schema|\bmodel\s*\(\s*['"]|@Schema\s*\(/.test(content))
|
|
11317
11320
|
continue;
|
|
11318
11321
|
const models = parseMongooseModels(content);
|
|
11319
|
-
|
|
11322
|
+
const basename2 = path9.basename(filePath).replace(/\.(schema|model)\.(ts|js)$/, "");
|
|
11323
|
+
const filteredModels = models.length > 1 ? models.filter((m) => m.name.toLowerCase() === basename2.toLowerCase()) : models;
|
|
11324
|
+
const finalModels = filteredModels.length > 0 ? filteredModels : models;
|
|
11325
|
+
for (const model of finalModels) {
|
|
11320
11326
|
let size = estimateModelSize(model.collectionName);
|
|
11321
11327
|
if (model.hasCreatedAtIndex) {
|
|
11322
11328
|
if (size === "S" || size === "M") {
|
|
@@ -11332,10 +11338,14 @@ var require_volume_estimator = __commonJS({
|
|
|
11332
11338
|
}
|
|
11333
11339
|
function parseMongooseModels(content) {
|
|
11334
11340
|
const models = [];
|
|
11341
|
+
const seen = /* @__PURE__ */ new Set();
|
|
11335
11342
|
const modelRegex = /(?:mongoose\.)?model\s*\(\s*['"](\w+)['"]/g;
|
|
11336
11343
|
let match;
|
|
11337
11344
|
while ((match = modelRegex.exec(content)) !== null) {
|
|
11338
11345
|
const name = match[1];
|
|
11346
|
+
if (seen.has(name))
|
|
11347
|
+
continue;
|
|
11348
|
+
seen.add(name);
|
|
11339
11349
|
const collectionName = defaultTableName(name);
|
|
11340
11350
|
let hasCreatedAtIndex = false;
|
|
11341
11351
|
if (/\.index\s*\(\s*\{[^}]*(createdAt|timestamp|created_at)[^}]*\}/i.test(content)) {
|
|
@@ -11346,6 +11356,22 @@ var require_volume_estimator = __commonJS({
|
|
|
11346
11356
|
}
|
|
11347
11357
|
models.push({ name, collectionName, hasCreatedAtIndex });
|
|
11348
11358
|
}
|
|
11359
|
+
const schemaRegex = /@Schema\s*\([^)]*\)\s*(?:export\s+)?class\s+(\w+)/g;
|
|
11360
|
+
while ((match = schemaRegex.exec(content)) !== null) {
|
|
11361
|
+
const className = match[1];
|
|
11362
|
+
const name = className;
|
|
11363
|
+
if (seen.has(name))
|
|
11364
|
+
continue;
|
|
11365
|
+
seen.add(name);
|
|
11366
|
+
const collectionName = defaultTableName(name);
|
|
11367
|
+
let hasCreatedAtIndex = false;
|
|
11368
|
+
if (/@Schema\s*\(\s*\{[^}]*timestamps\s*:\s*true/.test(content)) {
|
|
11369
|
+
}
|
|
11370
|
+
if (/\.index\s*\(\s*\{[^}]*(createdAt|timestamp|created_at)[^}]*\}/i.test(content)) {
|
|
11371
|
+
hasCreatedAtIndex = true;
|
|
11372
|
+
}
|
|
11373
|
+
models.push({ name, collectionName, hasCreatedAtIndex });
|
|
11374
|
+
}
|
|
11349
11375
|
return models;
|
|
11350
11376
|
}
|
|
11351
11377
|
function estimateModelSize(modelName) {
|
|
@@ -14007,6 +14033,26 @@ var require_runtime_risk_detector = __commonJS({
|
|
|
14007
14033
|
}
|
|
14008
14034
|
}
|
|
14009
14035
|
});
|
|
14036
|
+
sourceFile.forEachDescendant((node) => {
|
|
14037
|
+
if (!ts_morph_1.Node.isCallExpression(node))
|
|
14038
|
+
return;
|
|
14039
|
+
const expr = node.getExpression();
|
|
14040
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
14041
|
+
return;
|
|
14042
|
+
if (expr.getName() !== "map")
|
|
14043
|
+
return;
|
|
14044
|
+
const args = node.getArguments();
|
|
14045
|
+
if (args.length === 0)
|
|
14046
|
+
return;
|
|
14047
|
+
const callbackText = args[0].getText();
|
|
14048
|
+
if (!/\(\?[=!]|\.\*|\[.*?\]|\\[bBdDwWsS]/.test(callbackText))
|
|
14049
|
+
return;
|
|
14050
|
+
const fileText = sourceFile.getText();
|
|
14051
|
+
if (!/\$regex/.test(fileText))
|
|
14052
|
+
return;
|
|
14053
|
+
const fn = getEnclosingFunction(node, allFunctions);
|
|
14054
|
+
violations.push(makeViolation(shared_1.RUNTIME_RISK_RULES.REDOS_VULNERABLE_REGEX, filePath, node.getStartLineNumber(), "Dynamic regex construction from user input used with $regex \u2014 ReDoS vulnerability", policy, fn?.name, "Use a safe text search method (MongoDB $text index) instead of $regex with user input. Escape special regex characters."));
|
|
14055
|
+
});
|
|
14010
14056
|
}
|
|
14011
14057
|
function isRedosVulnerable(pattern) {
|
|
14012
14058
|
const nestedQuantifier = /([+*])\)?[+*{]/;
|
|
@@ -14441,6 +14487,7 @@ var require_perf_pattern_detector = __commonJS({
|
|
|
14441
14487
|
detectMissingPagination(sourceFile, file.path, fns, policy, violations);
|
|
14442
14488
|
detectUnfilteredCount(sourceFile, file.path, fns, policy, violations);
|
|
14443
14489
|
detectRawSqlNoLimit(sourceFile, file.path, fns, policy, violations);
|
|
14490
|
+
detectDangerousDeleteAll(sourceFile, file.path, fns, policy, violations);
|
|
14444
14491
|
project.removeSourceFile(sourceFile);
|
|
14445
14492
|
}
|
|
14446
14493
|
return applyExceptions(violations, policy);
|
|
@@ -14686,14 +14733,26 @@ var require_perf_pattern_detector = __commonJS({
|
|
|
14686
14733
|
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
14687
14734
|
return void 0;
|
|
14688
14735
|
const obj = expr.getExpression();
|
|
14689
|
-
if (
|
|
14690
|
-
|
|
14691
|
-
|
|
14692
|
-
|
|
14693
|
-
|
|
14694
|
-
|
|
14695
|
-
return
|
|
14696
|
-
|
|
14736
|
+
if (ts_morph_1.Node.isIdentifier(obj)) {
|
|
14737
|
+
const name = obj.getText();
|
|
14738
|
+
if (!/^[A-Z]/.test(name))
|
|
14739
|
+
return void 0;
|
|
14740
|
+
if (/^(Promise|Array|Object|String|Number|Boolean|Buffer|JSON|Math|Date|Map|Set|Error|RegExp|console|process|Sequelize|DataTypes)$/.test(name))
|
|
14741
|
+
return void 0;
|
|
14742
|
+
return name;
|
|
14743
|
+
}
|
|
14744
|
+
if (ts_morph_1.Node.isPropertyAccessExpression(obj)) {
|
|
14745
|
+
const propName = obj.getName();
|
|
14746
|
+
const thisExpr = obj.getExpression();
|
|
14747
|
+
if (ts_morph_1.Node.isThisExpression(thisExpr) || ts_morph_1.Node.isIdentifier(thisExpr) && thisExpr.getText() === "this") {
|
|
14748
|
+
const match = propName.match(/^(\w+?)(Model|Repository|Repo|Service|Collection)$/i);
|
|
14749
|
+
if (match)
|
|
14750
|
+
return match[1].charAt(0).toUpperCase() + match[1].slice(1);
|
|
14751
|
+
if (/^[a-z]/.test(propName))
|
|
14752
|
+
return propName.charAt(0).toUpperCase() + propName.slice(1);
|
|
14753
|
+
}
|
|
14754
|
+
}
|
|
14755
|
+
return void 0;
|
|
14697
14756
|
}
|
|
14698
14757
|
var SEQUELIZE_FIND_METHODS = /* @__PURE__ */ new Set([
|
|
14699
14758
|
"findAll",
|
|
@@ -16059,6 +16118,25 @@ var require_perf_pattern_detector = __commonJS({
|
|
|
16059
16118
|
function escapeRegex(str) {
|
|
16060
16119
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16061
16120
|
}
|
|
16121
|
+
function detectDangerousDeleteAll(sourceFile, filePath, fns, policy, violations) {
|
|
16122
|
+
const DANGEROUS_DELETE_METHODS = /* @__PURE__ */ new Set(["deleteMany", "remove", "clear", "destroyAll"]);
|
|
16123
|
+
sourceFile.forEachDescendant((node) => {
|
|
16124
|
+
if (!ts_morph_1.Node.isCallExpression(node))
|
|
16125
|
+
return;
|
|
16126
|
+
const expr = node.getExpression();
|
|
16127
|
+
if (!ts_morph_1.Node.isPropertyAccessExpression(expr))
|
|
16128
|
+
return;
|
|
16129
|
+
const methodName = expr.getName();
|
|
16130
|
+
if (!DANGEROUS_DELETE_METHODS.has(methodName))
|
|
16131
|
+
return;
|
|
16132
|
+
const args = node.getArguments();
|
|
16133
|
+
const hasFilter = args.length > 0 && !(ts_morph_1.Node.isObjectLiteralExpression(args[0]) && args[0].getProperties().length === 0);
|
|
16134
|
+
if (hasFilter)
|
|
16135
|
+
return;
|
|
16136
|
+
const fn = getEnclosingFn(node, fns);
|
|
16137
|
+
pushIfNotNull(violations, makeViolation(shared_1.PERFORMANCE_RULES.DANGEROUS_DELETE_ALL, filePath, node.getStartLineNumber(), `${methodName}() with empty or no filter \u2014 may delete all records`, policy, void 0, fn?.name, "Add a filter to deleteMany() or use deleteOne() \u2014 deleting all records is rarely intentional"));
|
|
16138
|
+
});
|
|
16139
|
+
}
|
|
16062
16140
|
function applyExceptions(violations, policy) {
|
|
16063
16141
|
return violations.filter((v) => !policy.exceptions.some((ex) => ex.isActive && ex.rule === v.ruleId && v.file.includes(ex.file)));
|
|
16064
16142
|
}
|
|
@@ -16092,6 +16170,8 @@ var require_reliability_detector = __commonJS({
|
|
|
16092
16170
|
detectMissingErrorLogging(sourceFile, file.path, fns, policy, violations);
|
|
16093
16171
|
detectTransactionNoTimeout(sourceFile, file.path, fns, policy, violations);
|
|
16094
16172
|
detectMissingNullGuard(sourceFile, file.path, fns, policy, violations);
|
|
16173
|
+
detectMissingDtoValidation(sourceFile, file.path, fns, policy, violations);
|
|
16174
|
+
detectUnguardedRouteTodo(sourceFile, file.path, fns, policy, violations);
|
|
16095
16175
|
project.removeSourceFile(sourceFile);
|
|
16096
16176
|
}
|
|
16097
16177
|
return applyExceptions(violations, policy);
|
|
@@ -16548,25 +16628,53 @@ var require_reliability_detector = __commonJS({
|
|
|
16548
16628
|
}
|
|
16549
16629
|
return false;
|
|
16550
16630
|
}
|
|
16631
|
+
var SDK_CLIENTS_NO_TIMEOUT = /* @__PURE__ */ new Set([
|
|
16632
|
+
"Replicate",
|
|
16633
|
+
"OpenAI",
|
|
16634
|
+
"Anthropic",
|
|
16635
|
+
"Stripe",
|
|
16636
|
+
"Twilio",
|
|
16637
|
+
"SendGrid"
|
|
16638
|
+
]);
|
|
16551
16639
|
function detectExternalCallNoTimeout(sourceFile, filePath, fns, policy, violations) {
|
|
16552
16640
|
sourceFile.forEachDescendant((node) => {
|
|
16553
|
-
if (
|
|
16554
|
-
|
|
16555
|
-
|
|
16556
|
-
|
|
16557
|
-
|
|
16558
|
-
|
|
16559
|
-
|
|
16641
|
+
if (ts_morph_1.Node.isCallExpression(node)) {
|
|
16642
|
+
let callType = null;
|
|
16643
|
+
for (const [name, matcher] of Object.entries(EXTERNAL_CALL_PATTERNS)) {
|
|
16644
|
+
if (matcher(node)) {
|
|
16645
|
+
callType = name;
|
|
16646
|
+
break;
|
|
16647
|
+
}
|
|
16560
16648
|
}
|
|
16561
|
-
|
|
16562
|
-
|
|
16563
|
-
|
|
16564
|
-
|
|
16565
|
-
|
|
16566
|
-
|
|
16649
|
+
if (!callType)
|
|
16650
|
+
return;
|
|
16651
|
+
if (hasTimeoutOption(node))
|
|
16652
|
+
return;
|
|
16653
|
+
if (isInsideRetryLoop(node))
|
|
16654
|
+
return;
|
|
16655
|
+
const fn = getEnclosingFn(node, fns);
|
|
16656
|
+
violations.push(makeViolation(shared_1.RELIABILITY_RULES.EXTERNAL_CALL_NO_TIMEOUT, filePath, node.getStartLineNumber(), `${callType}() call without timeout \u2014 may hang indefinitely`, policy, fn?.name, `Add a timeout option or use AbortController with setTimeout`));
|
|
16567
16657
|
return;
|
|
16568
|
-
|
|
16569
|
-
|
|
16658
|
+
}
|
|
16659
|
+
if (ts_morph_1.Node.isNewExpression(node)) {
|
|
16660
|
+
const expr = node.getExpression();
|
|
16661
|
+
if (!ts_morph_1.Node.isIdentifier(expr))
|
|
16662
|
+
return;
|
|
16663
|
+
const className = expr.getText();
|
|
16664
|
+
if (!SDK_CLIENTS_NO_TIMEOUT.has(className))
|
|
16665
|
+
return;
|
|
16666
|
+
const args = node.getArguments();
|
|
16667
|
+
const hasTimeout = args.some((arg) => {
|
|
16668
|
+
if (ts_morph_1.Node.isObjectLiteralExpression(arg)) {
|
|
16669
|
+
return /timeout/i.test(arg.getText());
|
|
16670
|
+
}
|
|
16671
|
+
return false;
|
|
16672
|
+
});
|
|
16673
|
+
if (hasTimeout)
|
|
16674
|
+
return;
|
|
16675
|
+
const fn = getEnclosingFn(node, fns);
|
|
16676
|
+
violations.push(makeViolation(shared_1.RELIABILITY_RULES.EXTERNAL_CALL_NO_TIMEOUT, filePath, node.getStartLineNumber(), `new ${className}() without timeout option \u2014 API calls may hang indefinitely`, policy, fn?.name, `Add a timeout option: new ${className}({ timeout: 30000, ... })`));
|
|
16677
|
+
}
|
|
16570
16678
|
});
|
|
16571
16679
|
}
|
|
16572
16680
|
function isInsideRetryLoop(node) {
|
|
@@ -16816,6 +16924,58 @@ var require_reliability_detector = __commonJS({
|
|
|
16816
16924
|
violations.push(makeViolation(shared_1.RELIABILITY_RULES.MISSING_NULL_GUARD, filePath, node.getStartLineNumber(), `Array index access '${varName}[${indexArg.getText()}]' without length guard \u2014 may throw on empty results`, policy, fn?.name, `Add a guard: if (${varName}.length === 0) throw new NotFoundException();`));
|
|
16817
16925
|
});
|
|
16818
16926
|
}
|
|
16927
|
+
function detectMissingDtoValidation(sourceFile, filePath, fns, policy, violations) {
|
|
16928
|
+
if (!filePath.includes("controller"))
|
|
16929
|
+
return;
|
|
16930
|
+
sourceFile.forEachDescendant((node) => {
|
|
16931
|
+
if (!ts_morph_1.Node.isMethodDeclaration(node))
|
|
16932
|
+
return;
|
|
16933
|
+
for (const param of node.getParameters()) {
|
|
16934
|
+
const decorators = param.getDecorators();
|
|
16935
|
+
const hasBody = decorators.some((d) => d.getName() === "Body");
|
|
16936
|
+
const hasQuery = decorators.some((d) => d.getName() === "Query");
|
|
16937
|
+
if (!hasBody && !hasQuery)
|
|
16938
|
+
continue;
|
|
16939
|
+
const typeNode = param.getTypeNode();
|
|
16940
|
+
if (!typeNode)
|
|
16941
|
+
continue;
|
|
16942
|
+
const typeText = typeNode.getText().trim();
|
|
16943
|
+
if (typeText !== "any")
|
|
16944
|
+
continue;
|
|
16945
|
+
const decName = hasBody ? "@Body()" : "@Query()";
|
|
16946
|
+
const fn = getEnclosingFn(node, fns);
|
|
16947
|
+
violations.push(makeViolation(shared_1.RELIABILITY_RULES.MISSING_DTO_VALIDATION, filePath, node.getStartLineNumber(), `${decName} parameter typed as 'any' \u2014 bypasses class-validator validation`, policy, fn?.name, "Create a DTO class with class-validator decorators and use it instead of any"));
|
|
16948
|
+
}
|
|
16949
|
+
});
|
|
16950
|
+
}
|
|
16951
|
+
var ROUTE_DECORATORS = /* @__PURE__ */ new Set(["Get", "Post", "Put", "Delete", "Patch", "All", "Head", "Options"]);
|
|
16952
|
+
var GUARD_TODO_PATTERN = /TODO|FIXME|HACK|guard|auth|security|protected/i;
|
|
16953
|
+
function detectUnguardedRouteTodo(sourceFile, filePath, fns, policy, violations) {
|
|
16954
|
+
if (!filePath.includes("controller"))
|
|
16955
|
+
return;
|
|
16956
|
+
sourceFile.forEachDescendant((node) => {
|
|
16957
|
+
if (!ts_morph_1.Node.isMethodDeclaration(node))
|
|
16958
|
+
return;
|
|
16959
|
+
const decorators = node.getDecorators();
|
|
16960
|
+
const isRoute = decorators.some((d) => ROUTE_DECORATORS.has(d.getName()));
|
|
16961
|
+
if (!isRoute)
|
|
16962
|
+
return;
|
|
16963
|
+
const fullText = node.getFullText();
|
|
16964
|
+
if (!GUARD_TODO_PATTERN.test(fullText))
|
|
16965
|
+
return;
|
|
16966
|
+
const hasGuard = decorators.some((d) => d.getName() === "UseGuards");
|
|
16967
|
+
if (hasGuard)
|
|
16968
|
+
return;
|
|
16969
|
+
const classNode = node.getParent();
|
|
16970
|
+
if (classNode && ts_morph_1.Node.isClassDeclaration(classNode)) {
|
|
16971
|
+
const classGuard = classNode.getDecorators().some((d) => d.getName() === "UseGuards");
|
|
16972
|
+
if (classGuard)
|
|
16973
|
+
return;
|
|
16974
|
+
}
|
|
16975
|
+
const fn = getEnclosingFn(node, fns);
|
|
16976
|
+
violations.push(makeViolation(shared_1.RELIABILITY_RULES.UNGUARDED_ROUTE_TODO, filePath, node.getStartLineNumber(), `Route handler '${node.getName?.() ?? "anonymous"}' has TODO/FIXME comment and no @UseGuards() \u2014 unprotected endpoint`, policy, fn?.name, "Add @UseGuards(JwtAuthGuard) or appropriate guard \u2014 this route is currently unprotected"));
|
|
16977
|
+
});
|
|
16978
|
+
}
|
|
16819
16979
|
function escapeRegex(str) {
|
|
16820
16980
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
16821
16981
|
}
|
|
@@ -19560,7 +19720,15 @@ async function scanCommand(targetPath, options) {
|
|
|
19560
19720
|
commit: getGitCommit(),
|
|
19561
19721
|
duration: Date.now() - scanStart,
|
|
19562
19722
|
usedAi: shouldRunAi,
|
|
19563
|
-
aiCreditsUsed: 0
|
|
19723
|
+
aiCreditsUsed: 0,
|
|
19724
|
+
// PR metadata from CI environment (set by GitHub Action or GitLab CI)
|
|
19725
|
+
...process.env.RADAR_PR_NUMBER ? {
|
|
19726
|
+
prNumber: parseInt(process.env.RADAR_PR_NUMBER, 10),
|
|
19727
|
+
prTitle: process.env.RADAR_PR_TITLE,
|
|
19728
|
+
prUrl: process.env.RADAR_PR_URL,
|
|
19729
|
+
platform: process.env.RADAR_PLATFORM ?? "github",
|
|
19730
|
+
mode
|
|
19731
|
+
} : {}
|
|
19564
19732
|
};
|
|
19565
19733
|
try {
|
|
19566
19734
|
await client.reportScan(reportData);
|