@blundergoat/gruff-ts 0.1.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/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// Per-line and per-source line rules: security/modernisation regex passes, type-safety
|
|
2
|
+
// (ts-comment, non-null, double-cast, exported-any), reliability (async-forEach, floating-promise,
|
|
3
|
+
// non-Error throw, useless/swallowed catches), and the naming-pusher fanout used by both line
|
|
4
|
+
// detection and the block-rule parameter pass. Dead-code rules (unused imports, unreachable) are
|
|
5
|
+
// invoked by the cli orchestrator before/after this module so the stable per-line emission order
|
|
6
|
+
// stays a single contract.
|
|
7
|
+
import { type SourceFile } from "./discovery.ts";
|
|
8
|
+
import { makeFinding } from "./findings.ts";
|
|
9
|
+
import { escapeRegex, finding, isCommentedOutCode } from "./findings-helpers.ts";
|
|
10
|
+
import { type NamingSurface, pushAbbreviationAt, pushBooleanPrefixAt, pushIdentifierQualityAt, pushNegativeBooleanAt, pushShortVariableAt } from "./naming-pushers.ts";
|
|
11
|
+
import { analyseReliabilityLine, analyseSwallowedCatches, analyseTypeSafetyLine, analyseUselessCatches } from "./safety-rules.ts";
|
|
12
|
+
import { analyseSecurityFlowLine } from "./security-flow-rules.ts";
|
|
13
|
+
import { codeLineForMatching } from "./source-text.ts";
|
|
14
|
+
import { byteLine } from "./text-scans.ts";
|
|
15
|
+
import type { Config, Finding, Pillar, Severity } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// Descriptor for one regex-backed line rule. `pattern` is the cheap test and `globalPattern`
|
|
18
|
+
// (optional) is used when the rule needs all matches for emission, not just the first hit.
|
|
19
|
+
interface LineRuleCheck {
|
|
20
|
+
ruleId: string;
|
|
21
|
+
pattern: RegExp;
|
|
22
|
+
globalPattern?: RegExp;
|
|
23
|
+
message: string;
|
|
24
|
+
severity: Severity;
|
|
25
|
+
pillar: Pillar;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
* Per-line scratch state shared across every line rule in a single pass. `codeLine` is the
|
|
30
|
+
* comment-masked variant - checks that must stay stable against literal content operate on it.
|
|
31
|
+
*/
|
|
32
|
+
interface LineRuleContext {
|
|
33
|
+
file: SourceFile;
|
|
34
|
+
line: string;
|
|
35
|
+
codeLine: string;
|
|
36
|
+
lineNumber: number;
|
|
37
|
+
config: Config;
|
|
38
|
+
findings: Finding[];
|
|
39
|
+
codeChecks: LineRuleCheck[];
|
|
40
|
+
literalChecks: LineRuleCheck[];
|
|
41
|
+
variables: RegExp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const CODE_LINE_CHECKS = codeLineChecks();
|
|
45
|
+
const LITERAL_LINE_CHECKS = literalLineChecks();
|
|
46
|
+
const VARIABLE_DECLARATIONS = /\b(?:const|let|for\s*\(\s*const|for\s*\(\s*let)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Per-line rule pipeline plus the two multi-line catch detectors. Excludes analyseUnusedImports
|
|
50
|
+
* and analyseUnreachable so the dead-code module can own them; the orchestrator in cli.ts wraps
|
|
51
|
+
* this call with those rules to preserve the stable, deterministic emission order.
|
|
52
|
+
*/
|
|
53
|
+
export function analyseLineRules(file: SourceFile, source: string, codeSource: string, config: Config, findings: Finding[]): void {
|
|
54
|
+
const sourceLines = source.split(/\r?\n/);
|
|
55
|
+
const codeLines = codeSource.split(/\r?\n/);
|
|
56
|
+
const context: LineRuleContext = {
|
|
57
|
+
file,
|
|
58
|
+
line: "",
|
|
59
|
+
codeLine: "",
|
|
60
|
+
lineNumber: 0,
|
|
61
|
+
config,
|
|
62
|
+
findings,
|
|
63
|
+
codeChecks: CODE_LINE_CHECKS,
|
|
64
|
+
literalChecks: LITERAL_LINE_CHECKS,
|
|
65
|
+
variables: VARIABLE_DECLARATIONS,
|
|
66
|
+
};
|
|
67
|
+
sourceLines.forEach((line, index) => {
|
|
68
|
+
context.line = line;
|
|
69
|
+
context.codeLine = codeLines[index] ?? codeLineForMatching(line);
|
|
70
|
+
context.lineNumber = index + 1;
|
|
71
|
+
context.variables.lastIndex = 0;
|
|
72
|
+
analyseLineRuleContext(context);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
analyseProcessExecCalls(file, source, codeSource, findings);
|
|
76
|
+
analyseUselessCatches(file, codeSource, findings);
|
|
77
|
+
analyseSwallowedCatches(file, source, codeSource, findings);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// All per-line checks for a single line in their stable, deterministic emission order. Each helper
|
|
81
|
+
// either no-ops (rule skipped or no match) or appends to `findings`.
|
|
82
|
+
function analyseLineRuleContext(context: LineRuleContext): void {
|
|
83
|
+
analyseTypeSafetyLine(context.file, context.line, context.codeLine, context.lineNumber, context.findings);
|
|
84
|
+
analyseReliabilityLine(context.file, context.codeLine, context.lineNumber, context.findings);
|
|
85
|
+
pushCommentedOutCodeFinding(context);
|
|
86
|
+
pushBooleanPrefixFinding(context);
|
|
87
|
+
pushHungarianNotationFindings(context);
|
|
88
|
+
pushOptionalChainingFindings(context);
|
|
89
|
+
pushNullishCoalescingFindings(context);
|
|
90
|
+
pushLooseEqualityFinding(context);
|
|
91
|
+
pushStringTimerFinding(context);
|
|
92
|
+
analyseSecurityFlowLine(context.file, context.codeLine, context.lineNumber, context.findings);
|
|
93
|
+
pushPatternCheckFindings(context);
|
|
94
|
+
pushVariableNameFindings(context);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Code-shape rules: those that must match against the masked code (no comment or literal noise).
|
|
98
|
+
// Targets the eval / new-Function / Math.random / innerHTML / proto-access family of security/waste signals.
|
|
99
|
+
function codeLineChecks(): LineRuleCheck[] {
|
|
100
|
+
return [
|
|
101
|
+
{ ruleId: "security.eval-call", pattern: /\beval\s*\(/, message: "eval() executes dynamic code.", severity: "error", pillar: "security" },
|
|
102
|
+
{ ruleId: "security.new-function", pattern: /\bnew\s+Function\s*\(|(?:^|[=(:,])\s*Function\s*\(/, message: "Function constructor executes dynamic code.", severity: "error", pillar: "security" },
|
|
103
|
+
{ ruleId: "security.insecure-random", pattern: /\bMath\.random\s*\(/, message: "Math.random() is not suitable for security-sensitive randomness.", severity: "warning", pillar: "security" },
|
|
104
|
+
{ ruleId: "security.inner-html", pattern: /\.innerHTML\s*=(?!\s*(?:""|''))|\bdangerouslySetInnerHTML\b/, message: "HTML injection sink can introduce XSS.", severity: "warning", pillar: "security" },
|
|
105
|
+
{ ruleId: "security.proto-access", pattern: /\.__proto__\b/, message: "Direct __proto__ access can enable prototype pollution.", severity: "warning", pillar: "security" },
|
|
106
|
+
{ ruleId: "security.document-write", pattern: /\bdocument\.write\s*\(/, message: "document.write() can introduce injection risks.", severity: "warning", pillar: "security" },
|
|
107
|
+
{ ruleId: "waste.redundant-boolean-cast", pattern: /\b(?:if|while)\s*\(\s*(?:!!\s*[A-Za-z_$][A-Za-z0-9_$.]*|Boolean\s*\()/, message: "Condition contains a redundant boolean cast.", severity: "advisory", pillar: "waste" },
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Rules that need to see the raw line including literals (e.g., `"javascript:"` URL detection,
|
|
112
|
+
// `"md5"` weak-crypto match). Global patterns are auto-generated so global-match operations stay
|
|
113
|
+
// safe - see `withGlobalPattern`.
|
|
114
|
+
function literalLineChecks(): LineRuleCheck[] {
|
|
115
|
+
const checks: LineRuleCheck[] = [
|
|
116
|
+
{ ruleId: "security.weak-crypto", pattern: /\b(?:createHash|createHmac)\s*\(\s*["'](?:md5|sha1)["']|\bcreateCipher\s*\(|\b(?:secureProtocol|minVersion|maxVersion)\s*:\s*["'](?:SSLv2_method|SSLv3_method|TLSv1(?:_method)?|TLSv1\.1)["']/i, message: "Weak cryptographic primitive is used.", severity: "warning", pillar: "security" },
|
|
117
|
+
{ ruleId: "security.disabled-tls-verification", pattern: /\b(?:process\.env\.)?NODE_TLS_REJECT_UNAUTHORIZED\b\s*=\s*["']0["']|\brejectUnauthorized\s*:\s*false\b/i, message: "TLS certificate verification is disabled.", severity: "error", pillar: "security" },
|
|
118
|
+
{ ruleId: "security.javascript-url", pattern: /["'`]\s*javascript\s*:(?!\s+URL\b)/i, message: "javascript: URL literal can execute script.", severity: "error", pillar: "security" },
|
|
119
|
+
{ ruleId: "security.proto-access", pattern: /\[\s*["']__proto__["']\s*\]/, message: "Direct __proto__ access can enable prototype pollution.", severity: "warning", pillar: "security" },
|
|
120
|
+
{ ruleId: "security.sql-concatenation", pattern: /\b(?:query|execute|raw)\s*\(\s*(?:`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*\$\{|["'][^"']*(?:SELECT|INSERT|UPDATE|DELETE)[^"']*["']\s*\+)/i, message: "SQL text is composed with runtime string interpolation.", severity: "warning", pillar: "security" },
|
|
121
|
+
{ ruleId: "modernisation.date-now-candidate", pattern: /\bnew\s+Date\s*\(\s*\)\s*\.getTime\s*\(\s*\)|\bNumber\s*\(\s*new\s+Date\s*\(\s*\)\s*\)/, message: "Current-time expression can use Date.now().", severity: "advisory", pillar: "modernisation" },
|
|
122
|
+
{ ruleId: "modernisation.object-spread-candidate", pattern: /\bObject\.assign\s*\(\s*\{\s*\}\s*,/, message: "Object.assign clone can usually use object spread.", severity: "advisory", pillar: "modernisation" },
|
|
123
|
+
{ ruleId: "waste.console-log", pattern: /\bconsole\.(log|debug)\s*\(/, message: "console logging is committed in source.", severity: "advisory", pillar: "waste" },
|
|
124
|
+
{ ruleId: "waste.any-type", pattern: /:\s*any\b|as\s+any\b/, message: "any weakens TypeScript's type guarantees.", severity: "warning", pillar: "waste" },
|
|
125
|
+
{ ruleId: "modernisation.var-declaration", pattern: /\bvar\s+[A-Za-z_$]/, message: "var declaration should usually be let or const.", severity: "advisory", pillar: "modernisation" },
|
|
126
|
+
];
|
|
127
|
+
return checks.map(withGlobalPattern);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Returns a new check whose `globalPattern` has the `g` flag, leaving the original `pattern`
|
|
131
|
+
// untouched. Required because `pattern.exec` stateful iteration would corrupt callers that share
|
|
132
|
+
// a check across multiple files.
|
|
133
|
+
function withGlobalPattern(check: LineRuleCheck): LineRuleCheck {
|
|
134
|
+
return {
|
|
135
|
+
...check,
|
|
136
|
+
globalPattern: check.pattern.flags.includes("g") ? check.pattern : new RegExp(check.pattern.source, `${check.pattern.flags}g`),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* Targets `// const x = …;`-style commented-out code. The detector is intentionally conservative
|
|
142
|
+
* because clever false positives drown the rule. Reports the stable `waste.commented-out-code` finding.
|
|
143
|
+
*/
|
|
144
|
+
function pushCommentedOutCodeFinding(context: LineRuleContext): void {
|
|
145
|
+
if (isCommentedOutCode(context.line)) {
|
|
146
|
+
context.findings.push(finding({ ruleId: "waste.commented-out-code", message: "Comment appears to contain disabled source code.", file: context.file, line: context.lineNumber, severity: "advisory", pillar: "waste" }));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Detects typed boolean declarations (or those with a literal true/false initializer) and runs
|
|
151
|
+
// the boolean-prefix and negative-boolean checks. Surface is fixed to "declaration".
|
|
152
|
+
function pushBooleanPrefixFinding(context: LineRuleContext): void {
|
|
153
|
+
const booleanDeclaration = context.codeLine.match(/\b(?:const|let|var|public|private|protected)\s+([A-Za-z_$][A-Za-z0-9_$]*)\??(?:\s*:\s*boolean|\s*=\s*(?:true|false)\b)/);
|
|
154
|
+
const name = booleanDeclaration?.[1] ?? "";
|
|
155
|
+
if (!name) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
pushBooleanPrefixAt(context.file, context.lineNumber, name, context.config, context.findings, "declaration");
|
|
159
|
+
pushNegativeBooleanAt(context.file, context.lineNumber, name, context.config, context.findings, "declaration");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
// Walks all `strFoo` / `intBar` / `arrBaz` identifiers on the line. The prefix regex is
|
|
165
|
+
// auto-generated from `config.hungarianPrefixes`. Reports `naming.hungarian-notation`.
|
|
166
|
+
function pushHungarianNotationFindings(context: LineRuleContext): void {
|
|
167
|
+
const regex = hungarianPrefixRegex(context.config.hungarianPrefixes);
|
|
168
|
+
if (regex === null) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const hungarian of context.codeLine.matchAll(regex)) {
|
|
172
|
+
const name = hungarian[1] ?? "";
|
|
173
|
+
context.findings.push(
|
|
174
|
+
makeFinding({
|
|
175
|
+
ruleId: "naming.hungarian-notation",
|
|
176
|
+
message: `Identifier \`${name}\` uses type-style Hungarian notation.`,
|
|
177
|
+
filePath: context.file.displayPath,
|
|
178
|
+
line: context.lineNumber,
|
|
179
|
+
severity: "advisory",
|
|
180
|
+
pillar: "naming",
|
|
181
|
+
confidence: "medium",
|
|
182
|
+
symbol: name,
|
|
183
|
+
remediation: "Name the domain concept instead of the storage type.",
|
|
184
|
+
metadata: { identifierName: name },
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Detects `foo && foo.bar` patterns where `foo?.bar` would say the same thing. Backreference
|
|
191
|
+
// in the regex enforces identical identifiers on both sides. Reports `modernisation.optional-chaining-candidate`.
|
|
192
|
+
function pushOptionalChainingFindings(context: LineRuleContext): void {
|
|
193
|
+
for (const optional of context.codeLine.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*&&\s*\1\.[A-Za-z_$][A-Za-z0-9_$]*/g)) {
|
|
194
|
+
const name = optional[1] ?? "";
|
|
195
|
+
context.findings.push(
|
|
196
|
+
makeFinding({
|
|
197
|
+
ruleId: "modernisation.optional-chaining-candidate",
|
|
198
|
+
message: `Guarded property access on \`${name}\` can usually use optional chaining.`,
|
|
199
|
+
filePath: context.file.displayPath,
|
|
200
|
+
line: context.lineNumber,
|
|
201
|
+
severity: "advisory",
|
|
202
|
+
pillar: "modernisation",
|
|
203
|
+
confidence: "medium",
|
|
204
|
+
symbol: name,
|
|
205
|
+
remediation: "Use optional chaining for the guarded property access.",
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Targets `x || defaultValue` patterns where the fallback is a literal - `??` would preserve
|
|
212
|
+
// legitimately-falsy values (0, "", false). Reports `modernisation.nullish-coalescing-candidate`.
|
|
213
|
+
function pushNullishCoalescingFindings(context: LineRuleContext): void {
|
|
214
|
+
for (const fallback of context.codeLine.matchAll(/=\s*([A-Za-z_$][A-Za-z0-9_$.]*)\s*\|\|\s*(["'`]\s*["'`]|\d+|true|false)/g)) {
|
|
215
|
+
const name = fallback[1] ?? "";
|
|
216
|
+
context.findings.push(
|
|
217
|
+
makeFinding({
|
|
218
|
+
ruleId: "modernisation.nullish-coalescing-candidate",
|
|
219
|
+
message: `Fallback for \`${name}\` can usually use nullish coalescing to preserve falsy values.`,
|
|
220
|
+
filePath: context.file.displayPath,
|
|
221
|
+
line: context.lineNumber,
|
|
222
|
+
severity: "advisory",
|
|
223
|
+
pillar: "modernisation",
|
|
224
|
+
confidence: "medium",
|
|
225
|
+
symbol: name,
|
|
226
|
+
remediation: "Use ?? when only null or undefined should trigger the fallback.",
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* Loose `==` / `!=` against non-null operands. The `looseEqualityOperator` helper excludes intentional
|
|
234
|
+
* `x == null` checks (which legitimately match null and undefined). Reports the stable
|
|
235
|
+
* `modernisation.loose-equality` finding.
|
|
236
|
+
*/
|
|
237
|
+
function pushLooseEqualityFinding(context: LineRuleContext): void {
|
|
238
|
+
const looseOperator = looseEqualityOperator(context.codeLine);
|
|
239
|
+
if (looseOperator) {
|
|
240
|
+
context.findings.push(finding({ ruleId: "modernisation.loose-equality", message: `Loose equality operator ${looseOperator} may coerce values.`, file: context.file, line: context.lineNumber, severity: "advisory", pillar: "modernisation" }));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/*
|
|
245
|
+
* `setTimeout("alert(1)", …)` and friends evaluate the string as code, an `eval`-equivalent.
|
|
246
|
+
* Reports the stable `security.string-timer` finding when a literal string callback is detected.
|
|
247
|
+
*/
|
|
248
|
+
function pushStringTimerFinding(context: LineRuleContext): void {
|
|
249
|
+
if (stringTimerCandidate(context.codeLine)) {
|
|
250
|
+
context.findings.push(finding({ ruleId: "security.string-timer", message: "Timer callback is provided as a string.", file: context.file, line: context.lineNumber, severity: "warning", pillar: "security" }));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Runs the descriptor-driven line checks split into code-shape vs literal-aware. Literal checks
|
|
255
|
+
// use `rawPatternStartsInCode` to confirm the match starts in real code, not inside a comment.
|
|
256
|
+
// Reports each matching rule's stable line-anchored finding.
|
|
257
|
+
function pushPatternCheckFindings(context: LineRuleContext): void {
|
|
258
|
+
for (const check of context.codeChecks) {
|
|
259
|
+
if (check.pattern.test(context.codeLine)) {
|
|
260
|
+
context.findings.push(finding({ ruleId: check.ruleId, message: check.message, file: context.file, line: context.lineNumber, severity: check.severity, pillar: check.pillar }));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const check of context.literalChecks) {
|
|
264
|
+
if (isSuppressedByPathContext(check.ruleId, context.file.displayPath)) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (rawPatternStartsInCode(context.line, context.codeLine, check.globalPattern ?? check.pattern)) {
|
|
268
|
+
context.findings.push(finding({ ruleId: check.ruleId, message: check.message, file: context.file, line: context.lineNumber, severity: check.severity, pillar: check.pillar }));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Per-rule path-context allowlist. CLI entry points, build scripts, and server diagnostic modules
|
|
274
|
+
// are expected to write to stdout; the console-log rule would otherwise drown them in advisories.
|
|
275
|
+
function isSuppressedByPathContext(ruleId: string, displayPath: string): boolean {
|
|
276
|
+
if (ruleId !== "waste.console-log") {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
return /(?:^|\/)(?:scripts|bin)\//.test(displayPath) || /(?:^|\/)cli[/.]/i.test(displayPath) || /(?:^|\/)server[/.]/i.test(displayPath);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Per-line variable-name pass that runs short/identifier-quality/abbreviation checks on both
|
|
283
|
+
// regular `const`/`let` declarations and destructured names. Reports any findings produced.
|
|
284
|
+
function pushVariableNameFindings(context: LineRuleContext): void {
|
|
285
|
+
for (const match of context.codeLine.matchAll(context.variables)) {
|
|
286
|
+
const name = match[1] ?? "";
|
|
287
|
+
pushShortVariableFinding(context, name);
|
|
288
|
+
pushIdentifierQualityFinding(context, name);
|
|
289
|
+
pushAbbreviationAt(context.file, context.lineNumber, name, context.config, context.findings, "declaration");
|
|
290
|
+
}
|
|
291
|
+
for (const name of destructuredLocalNames(context.codeLine)) {
|
|
292
|
+
pushShortVariableAt(context.file, context.lineNumber, name, context.config, context.findings, "destructure");
|
|
293
|
+
pushIdentifierQualityAt(context.file, context.lineNumber, name, context.config, context.findings, "destructure");
|
|
294
|
+
pushAbbreviationAt(context.file, context.lineNumber, name, context.config, context.findings, "destructure");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Walks `const { foo, bar: alias } = ...` patterns. Aliased names (the part after `:`) become the
|
|
299
|
+
// local binding; defaults are stripped. Required because the naming rules check the local name only.
|
|
300
|
+
function destructuredLocalNames(codeLine: string): string[] {
|
|
301
|
+
const names: string[] = [];
|
|
302
|
+
for (const block of codeLine.matchAll(/\b(?:const|let)\s+\{([^}]+)\}\s*=/g)) {
|
|
303
|
+
const inner = block[1] ?? "";
|
|
304
|
+
for (const part of inner.split(",")) {
|
|
305
|
+
const trimmed = part.trim().replace(/\s*=[^,]*$/, "");
|
|
306
|
+
const aliased = trimmed.match(/[A-Za-z_$][A-Za-z0-9_$]*\s*:\s*([A-Za-z_$][A-Za-z0-9_$]*)/);
|
|
307
|
+
const plain = trimmed.match(/^([A-Za-z_$][A-Za-z0-9_$]*)$/);
|
|
308
|
+
const name = aliased?.[1] ?? plain?.[1];
|
|
309
|
+
if (name) {
|
|
310
|
+
names.push(name);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return names;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Thin wrapper that fills in `"declaration"` for the surface field. Same back-end as parameter
|
|
318
|
+
// and destructure callers, which use their own surface labels.
|
|
319
|
+
function pushShortVariableFinding(context: LineRuleContext, name: string): void {
|
|
320
|
+
pushShortVariableAt(context.file, context.lineNumber, name, context.config, context.findings, "declaration");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
// Thin wrapper that fills in `"declaration"` for the surface field; parameter and destructure
|
|
325
|
+
// callers use their own surface labels via the underlying `pushIdentifierQualityAt`.
|
|
326
|
+
function pushIdentifierQualityFinding(context: LineRuleContext, name: string): void {
|
|
327
|
+
pushIdentifierQualityAt(context.file, context.lineNumber, name, context.config, context.findings, "declaration");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
// True iff the pattern matches somewhere on the raw line *and* the match's start position falls on
|
|
333
|
+
// a code character in `codeLine`. Required for literal-rule checks where the raw line is needed to
|
|
334
|
+
// see the literal content, but the match must still begin in executable code (not inside a comment).
|
|
335
|
+
function rawPatternStartsInCode(rawLine: string, codeLine: string, pattern: RegExp): boolean {
|
|
336
|
+
const globalPattern = pattern;
|
|
337
|
+
let match: RegExpExecArray | null;
|
|
338
|
+
globalPattern.lastIndex = 0;
|
|
339
|
+
while ((match = globalPattern["exec"](rawLine)) !== null) {
|
|
340
|
+
const index = match.index ?? 0;
|
|
341
|
+
if (isNonWhitespaceCharacter(codeLine[index] ?? "")) {
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
if (match[0] === "") {
|
|
345
|
+
globalPattern.lastIndex += 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Used by `rawPatternStartsInCode` to confirm a position holds executable code rather than the
|
|
352
|
+
// space character produced by `maskNonCode`.
|
|
353
|
+
function isNonWhitespaceCharacter(character: string): boolean {
|
|
354
|
+
return character !== "" && character !== " " && character !== "\t" && character !== "\r" && character !== "\n";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Returns the loose operator (`==` or `!=`) when present and not part of `===`/`!==`/null check.
|
|
358
|
+
// Used by the modernisation rule; sufficient context lookback keeps `x == null` quiet.
|
|
359
|
+
function looseEqualityOperator(codeLine: string): string | undefined {
|
|
360
|
+
for (const match of codeLine.matchAll(/[=!]=/g)) {
|
|
361
|
+
const index = match.index ?? 0;
|
|
362
|
+
const operator = match[0] ?? "";
|
|
363
|
+
if (!isLooseEqualityCandidate(codeLine, index, operator)) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
return operator;
|
|
367
|
+
}
|
|
368
|
+
return undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Two-pass filter: reject `===`/`!==` (strict equality) and reject `x == null` (intentional double-test).
|
|
372
|
+
function isLooseEqualityCandidate(codeLine: string, index: number, operator: string): boolean {
|
|
373
|
+
return !isStrictEqualityOperator(codeLine, index, operator) && !isNullEqualityComparison(codeLine, index, operator);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Looks one char before and after - `===` shows up as `==` plus a trailing `=`. The leading `!` /
|
|
377
|
+
// `=` check catches `!==` and `===` from either side.
|
|
378
|
+
function isStrictEqualityOperator(codeLine: string, index: number, operator: string): boolean {
|
|
379
|
+
const before = codeLine[index - 1] ?? "";
|
|
380
|
+
const after = codeLine[index + operator.length] ?? "";
|
|
381
|
+
return before === "=" || before === "!" || after === "=";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// `x == null` matches both null and undefined and is a documented idiom - exempting it is the
|
|
385
|
+
// rule's intentional false-positive escape hatch. 24-character lookback window is large enough to
|
|
386
|
+
// span `someObject.field == null` without matching unrelated tokens.
|
|
387
|
+
function isNullEqualityComparison(codeLine: string, index: number, operator: string): boolean {
|
|
388
|
+
const left = codeLine.slice(Math.max(0, index - 24), index).trimEnd();
|
|
389
|
+
const right = codeLine.slice(index + operator.length, Math.min(codeLine.length, index + operator.length + 24)).trimStart();
|
|
390
|
+
return /\bnull$/.test(left) || /^null\b/.test(right);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Two cases: a bare `setTimeout("…")` call, or one accessed via window/self/globalThis. Both
|
|
394
|
+
// invoke `eval`-equivalent string-to-code semantics in browsers.
|
|
395
|
+
function stringTimerCandidate(codeLine: string): boolean {
|
|
396
|
+
return (
|
|
397
|
+
/(?:^|[^.\w$])(?:setTimeout|setInterval|execScript)\s*\(\s*["'`]/.test(codeLine) ||
|
|
398
|
+
/\b(?:window|self|globalThis)\.(?:setTimeout|setInterval|execScript)\s*\(\s*["'`]/.test(codeLine)
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/*
|
|
403
|
+
* Targets child_process-style calls across single- and multi-line expressions. Stable invariant:
|
|
404
|
+
* masked-source matching keeps fixture strings quiet. Unmatched calls recover by being skipped
|
|
405
|
+
* rather than guessed, while fixed literal-command argv calls stay quiet.
|
|
406
|
+
*/
|
|
407
|
+
function analyseProcessExecCalls(file: SourceFile, rawSource: string, codeSource: string, findings: Finding[]): void {
|
|
408
|
+
const processCallPattern = /\b(?:(child_process|childProcess|cp)\.)?(exec|spawn|execFile|execSync|execFileSync|spawnSync|fork)\s*\(/g;
|
|
409
|
+
for (const match of codeSource.matchAll(processCallPattern)) {
|
|
410
|
+
const start = match.index ?? 0;
|
|
411
|
+
const callName = match[2] ?? "";
|
|
412
|
+
if (!callName || isMemberProcessExecFalsePositive(codeSource, start, Boolean(match[1]))) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const openParen = start + match[0].length - 1;
|
|
416
|
+
const closeParen = matchingCloseParen(codeSource, openParen);
|
|
417
|
+
if (closeParen === undefined) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const codeSegment = codeSource.slice(start, closeParen + 1);
|
|
421
|
+
const rawSegment = rawSource.slice(start, closeParen + 1);
|
|
422
|
+
if (isSafeProcessExecCall(file, callName, rawSource, start, rawSegment, codeSegment)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
findings.push(finding({ ruleId: "security.process-exec", message: "Child-process execution is used; validate arguments are not user-controlled.", file, line: byteLine(codeSource, start), severity: "warning", pillar: "security" }));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Excludes ordinary member calls such as `pattern.exec(...)`; module receivers like `cp.exec(...)`
|
|
430
|
+
// remain valid process-exec candidates.
|
|
431
|
+
function isMemberProcessExecFalsePositive(codeSource: string, start: number, hasProcessReceiver: boolean): boolean {
|
|
432
|
+
return !hasProcessReceiver && codeSource[start - 1] === ".";
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Tiny parenthesis matcher over masked source. Strings and comments are already blanked by
|
|
436
|
+
// `maskNonCode`, so nested call parentheses are the only structure this needs to balance.
|
|
437
|
+
function matchingCloseParen(source: string, openParen: number): number | undefined {
|
|
438
|
+
let depth = 0;
|
|
439
|
+
for (let index = openParen; index < source.length; index += 1) {
|
|
440
|
+
const character = source[index];
|
|
441
|
+
if (character === "(") {
|
|
442
|
+
depth += 1;
|
|
443
|
+
} else if (character === ")") {
|
|
444
|
+
depth -= 1;
|
|
445
|
+
if (depth === 0) {
|
|
446
|
+
return index;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Fixed command vectors and known safe wrappers are intentionally not shell-interpolated, so this
|
|
454
|
+
// rule stays focused on dynamic commands and shell-enabled process calls.
|
|
455
|
+
function isSafeProcessExecCall(file: SourceFile, callName: string, rawSource: string, callStart: number, rawSegment: string, codeSegment: string): boolean {
|
|
456
|
+
return (
|
|
457
|
+
isFixedArgvProcessCallSegment(callName, rawSource, callStart, rawSegment, codeSegment) ||
|
|
458
|
+
isSafeExecWrapperCall(file, callName, codeSegment) ||
|
|
459
|
+
isFixedTestHarnessProcessCall(file, callName, rawSegment, codeSegment)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Suppresses argv-form process calls where the command is fixed by a literal, `process.execPath`,
|
|
464
|
+
// a local const literal selector, or a terminal-binary version probe.
|
|
465
|
+
function isFixedArgvProcessCallSegment(callName: string, rawSource: string, callStart: number, rawSegment: string, codeSegment: string): boolean {
|
|
466
|
+
if (/\bshell\s*:\s*true\b/.test(codeSegment)) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
if (!["spawn", "spawnSync", "execFile", "execFileSync"].includes(callName)) {
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
const argsText = rawSegment.slice(rawSegment.indexOf("(") + 1, rawSegment.lastIndexOf(")"));
|
|
473
|
+
return (
|
|
474
|
+
/^\s*(?:["'][^"']+["']|process\.execPath)\s*(?:,\s*(?:\[|[A-Za-z_$][A-Za-z0-9_$]*)|$)/s.test(argsText) ||
|
|
475
|
+
isFixedTerminalVersionProbe(argsText) ||
|
|
476
|
+
hasConstLiteralCommandVector(rawSource, callStart, argsText)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Agent/tool discovery commonly runs a manifest-backed terminal binary with `--version`. The
|
|
481
|
+
// argument vector is fixed, and the binary field is a known capability slot rather than request data.
|
|
482
|
+
function isFixedTerminalVersionProbe(argsText: string): boolean {
|
|
483
|
+
return /^\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*\.terminalBinary\s*,\s*\[\s*["']--version["']\s*\]/s.test(argsText);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// The safe-exec module is the wrapper implementation: it validates against an allow-list, rejects
|
|
487
|
+
// unsafe args, sets `shell: false`, and bounds execution. Flagging its one spawn call is noise.
|
|
488
|
+
function isSafeExecWrapperCall(file: SourceFile, callName: string, codeSegment: string): boolean {
|
|
489
|
+
return /(?:^|\/)safe-exec\.ts$/.test(file.displayPath) && ["spawn", "spawnSync"].includes(callName) && /\bshell\s*:\s*false\b/.test(codeSegment);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Test harnesses often launch the current Node process or syntax-check a local shell script. Keep
|
|
493
|
+
// dynamic production process calls visible while suppressing those fixed test-driver invocations.
|
|
494
|
+
function isFixedTestHarnessProcessCall(file: SourceFile, callName: string, rawSegment: string, codeSegment: string): boolean {
|
|
495
|
+
if (!isTestLikeProcessExecPath(file.displayPath) || /\bshell\s*:\s*true\b/.test(codeSegment)) {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
const argsText = rawSegment.slice(rawSegment.indexOf("(") + 1, rawSegment.lastIndexOf(")"));
|
|
499
|
+
if (/^\s*process\.execPath\s*,\s*\[/.test(argsText)) {
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
return ["exec", "execSync"].includes(callName) && /^\s*(?:"node scripts\/[^"]+"|'node scripts\/[^']+'|`(?:bash -n|wc -l\b)[^`]*`)/s.test(argsText);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Test-only paths get a few extra fixed-harness exemptions; production paths still report them.
|
|
506
|
+
function isTestLikeProcessExecPath(path: string): boolean {
|
|
507
|
+
return /(?:^|\/)(?:test|tests)\/|(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Handles local command variables such as `const whichCmd = platform ? "where" : "which"` when
|
|
511
|
+
// the call still uses an argv array. Calls or templates in the declaration remain reportable.
|
|
512
|
+
function hasConstLiteralCommandVector(rawSource: string, callStart: number, argsText: string): boolean {
|
|
513
|
+
const firstArg = argsText.match(/^\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*,\s*(?:\[|[A-Za-z_$][A-Za-z0-9_$]*)/s)?.[1];
|
|
514
|
+
if (!firstArg) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
const declaration = rawSource.slice(0, callStart).match(new RegExp(`\\bconst\\s+${escapeRegex(firstArg)}\\s*=\\s*([^;]+);\\s*$`, "s"));
|
|
518
|
+
const initializer = declaration?.[1] ?? "";
|
|
519
|
+
return /["'][^"']+["']/.test(initializer) && !/[`$()[\]{}]/.test(initializer);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
const HUNGARIAN_PREFIX_REGEX_CACHE = new WeakMap<Set<string>, RegExp | null>();
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
// Counterpart to `booleanPrefixRegex` for the `naming.hungarian-notation` rule. Returns a global
|
|
528
|
+
// regex (callers iterate matches) anchored to declaration keywords + visibility modifiers, so a
|
|
529
|
+
// reference to `IUser` inside a comment is not flagged - the regex is part of the stable contract.
|
|
530
|
+
function hungarianPrefixRegex(prefixes: Set<string>): RegExp | null {
|
|
531
|
+
if (HUNGARIAN_PREFIX_REGEX_CACHE.has(prefixes)) {
|
|
532
|
+
return HUNGARIAN_PREFIX_REGEX_CACHE.get(prefixes) ?? null;
|
|
533
|
+
}
|
|
534
|
+
const escapedPrefixes = [...prefixes].map(escapeRegex);
|
|
535
|
+
const regex = prefixes.size === 0 ? null : new RegExp(`\\b(?:const|let|var|public|private|protected)\\s+((?:${escapedPrefixes.join("|")})[A-Z][A-Za-z0-9_$]*)`, "g");
|
|
536
|
+
HUNGARIAN_PREFIX_REGEX_CACHE.set(prefixes, regex);
|
|
537
|
+
return regex;
|
|
538
|
+
}
|