@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,355 @@
|
|
|
1
|
+
// Type-safety and reliability rule packs: TS directive rationales, non-null assertions, double
|
|
2
|
+
// casts, exported `any`, async forEach, floating promises, non-Error throws, useless catches,
|
|
3
|
+
// swallowed catches. Each rule emits findings in the stable, deterministic per-line/per-source order.
|
|
4
|
+
import { hasSuppressionRationale } from "./comment-rules.ts";
|
|
5
|
+
import { type SourceFile } from "./discovery.ts";
|
|
6
|
+
import { makeFinding } from "./findings.ts";
|
|
7
|
+
import { byteLine } from "./text-scans.ts";
|
|
8
|
+
import type { Finding } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// Four-rule TypeScript safety pass: directive comment, non-null assertion, double cast, exported any.
|
|
11
|
+
// Stable, deterministic ordering keeps the per-line findings in a known sequence.
|
|
12
|
+
export function analyseTypeSafetyLine(file: SourceFile, line: string, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
13
|
+
pushTsDirectiveFinding(file, line, lineNumber, findings);
|
|
14
|
+
pushNonNullAssertionFindings(file, codeLine, lineNumber, findings);
|
|
15
|
+
pushDoubleCastFindings(file, codeLine, lineNumber, findings);
|
|
16
|
+
pushExportedAnyFinding(file, codeLine, lineNumber, findings);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Three reliability rules per line: async-forEach, floating-promise, non-Error throw. Order is
|
|
20
|
+
// the stable contract - reshuffling shifts per-block emission and churns baselines.
|
|
21
|
+
export function analyseReliabilityLine(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
22
|
+
pushAsyncForEachFinding(file, codeLine, lineNumber, findings);
|
|
23
|
+
pushFloatingPromiseFinding(file, codeLine, lineNumber, findings);
|
|
24
|
+
pushNonErrorThrowFinding(file, codeLine, lineNumber, findings);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* `catch (e) { throw e; }` patterns. The backreference enforces "same binding name" so a real
|
|
29
|
+
* `catch (e) { throw new Wrapped(e); }` does not trip. Reports the stable `waste.useless-catch` finding.
|
|
30
|
+
*/
|
|
31
|
+
export function analyseUselessCatches(file: SourceFile, source: string, findings: Finding[]): void {
|
|
32
|
+
for (const match of source.matchAll(/\bcatch\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\)\s*\{\s*throw\s+\1\s*;?\s*\}/g)) {
|
|
33
|
+
const binding = match[1] ?? "";
|
|
34
|
+
findings.push(
|
|
35
|
+
makeFinding({
|
|
36
|
+
ruleId: "waste.useless-catch",
|
|
37
|
+
message: `catch block only rethrows \`${binding}\` without adding handling.`,
|
|
38
|
+
filePath: file.displayPath,
|
|
39
|
+
line: byteLine(source, match.index ?? 0),
|
|
40
|
+
severity: "advisory",
|
|
41
|
+
pillar: "waste",
|
|
42
|
+
confidence: "high",
|
|
43
|
+
remediation: "Remove the catch block or add meaningful handling.",
|
|
44
|
+
metadata: { binding },
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* Empty / comment-only catch bodies. Strips comments before testing, because a catch body with
|
|
52
|
+
* only `// intentional` is still a swallowed catch but a real `console.error` is not. Reports
|
|
53
|
+
* the stable `waste.swallowed-catch` finding.
|
|
54
|
+
*/
|
|
55
|
+
export function analyseSwallowedCatches(file: SourceFile, rawSource: string, codeSource: string, findings: Finding[]): void {
|
|
56
|
+
for (const match of codeSource.matchAll(/\bcatch\s*(?:\(([^)]*)\))?\s*\{([\s\S]*?)\}/g)) {
|
|
57
|
+
const body = match[2] ?? "";
|
|
58
|
+
const rawBody = rawCatchBody(rawSource, codeSource, match);
|
|
59
|
+
if (!isSwallowedCatchBody(body) || hasIntentionalCatchRationale(rawBody)) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const binding = (match[1] ?? "").trim();
|
|
63
|
+
findings.push(
|
|
64
|
+
makeFinding({
|
|
65
|
+
ruleId: "waste.swallowed-catch",
|
|
66
|
+
message: "catch block swallows an error without rethrowing, returning, or reporting it.",
|
|
67
|
+
filePath: file.displayPath,
|
|
68
|
+
line: byteLine(rawSource, match.index ?? 0),
|
|
69
|
+
severity: "warning",
|
|
70
|
+
pillar: "waste",
|
|
71
|
+
confidence: "medium",
|
|
72
|
+
remediation: "Handle the error explicitly, rethrow it, or document an intentional ignore path.",
|
|
73
|
+
metadata: { ...(binding ? { binding } : {}) },
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Maps a catch-body match from masked code back to raw source so rationale comments remain visible.
|
|
80
|
+
function rawCatchBody(rawSource: string, codeSource: string, match: RegExpMatchArray): string {
|
|
81
|
+
const start = match.index ?? 0;
|
|
82
|
+
const openBrace = codeSource.indexOf("{", start);
|
|
83
|
+
const closeBrace = start + (match[0]?.length ?? 0) - 1;
|
|
84
|
+
return openBrace === -1 || closeBrace <= openBrace ? "" : rawSource.slice(openBrace + 1, closeBrace);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Comment-only catches are acceptable when the comment gives a rationale such as "already closed",
|
|
88
|
+
// "cache write failure is non-fatal", or "composition continues"; placeholders still surface.
|
|
89
|
+
function hasIntentionalCatchRationale(body: string): boolean {
|
|
90
|
+
return (
|
|
91
|
+
/(?:\/\/|\/\*)/.test(body) &&
|
|
92
|
+
(hasSuppressionRationale(body) ||
|
|
93
|
+
/\b(?:already (?:closed|dead|gone)|optional|best effort|missing|unreadable|not a directory|doesn't exist|not available|non-fatal|cache write failure|composition continues|try next location|server unavailable|must not affect|explicit launch|malformed messages|template missing|sets [A-Za-z_$][A-Za-z0-9_$]* = false|skip agents? that fail)\b/i.test(body))
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/*
|
|
98
|
+
* `@ts-ignore` / `@ts-expect-error` without an explanatory note. `tsDirectiveWithoutRationale`
|
|
99
|
+
* applies the rationale heuristic. Reports the stable `modernisation.ts-comment-without-rationale`
|
|
100
|
+
* finding.
|
|
101
|
+
*/
|
|
102
|
+
function pushTsDirectiveFinding(file: SourceFile, line: string, lineNumber: number, findings: Finding[]): void {
|
|
103
|
+
const directive = tsDirectiveWithoutRationale(line);
|
|
104
|
+
if (!directive) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
findings.push(
|
|
108
|
+
makeFinding({
|
|
109
|
+
ruleId: "modernisation.ts-comment-without-rationale",
|
|
110
|
+
message: `${directive.directive} suppresses TypeScript without a nearby rationale.`,
|
|
111
|
+
filePath: file.displayPath,
|
|
112
|
+
line: lineNumber,
|
|
113
|
+
severity: "warning",
|
|
114
|
+
pillar: "modernisation",
|
|
115
|
+
confidence: "medium",
|
|
116
|
+
remediation: "Add a short reason after the directive or remove the suppression.",
|
|
117
|
+
metadata: { directive: directive.directive },
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Walks every `foo.bar!` non-null assertion on the line. The lookahead enforces a real expression
|
|
123
|
+
// boundary so `!=` doesn't get misread. Reports `modernisation.non-null-assertion` with stable metadata.
|
|
124
|
+
function pushNonNullAssertionFindings(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
125
|
+
for (const match of codeLine.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)!(?=\.|\[|\)|,|;|\s+(?:as|in|instanceof)\b|\s*$)/g)) {
|
|
126
|
+
const expression = match[1] ?? "";
|
|
127
|
+
findings.push(
|
|
128
|
+
makeFinding({
|
|
129
|
+
ruleId: "modernisation.non-null-assertion",
|
|
130
|
+
message: `Non-null assertion on \`${expression}\` bypasses TypeScript's null checks.`,
|
|
131
|
+
filePath: file.displayPath,
|
|
132
|
+
line: lineNumber,
|
|
133
|
+
severity: "warning",
|
|
134
|
+
pillar: "modernisation",
|
|
135
|
+
confidence: "medium",
|
|
136
|
+
symbol: expression,
|
|
137
|
+
remediation: "Narrow the value with a guard or handle the null/undefined case explicitly.",
|
|
138
|
+
metadata: { expression },
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `as unknown as Foo` and `as any as Foo` double-cast patterns. Both source and target types are
|
|
145
|
+
// captured in stable metadata so reviewers can see what's being coerced. Reports `modernisation.double-cast`.
|
|
146
|
+
function pushDoubleCastFindings(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
147
|
+
for (const match of codeLine.matchAll(/\bas\s+(unknown|any)\s+as\s+([^;,\n]+)/g)) {
|
|
148
|
+
const sourceType = match[1] ?? "";
|
|
149
|
+
const targetType = (match[2] ?? "").trim().replace(/[.)]+$/, "");
|
|
150
|
+
findings.push(
|
|
151
|
+
makeFinding({
|
|
152
|
+
ruleId: "modernisation.double-cast",
|
|
153
|
+
message: `Double cast through \`${sourceType}\` bypasses structural type checks.`,
|
|
154
|
+
filePath: file.displayPath,
|
|
155
|
+
line: lineNumber,
|
|
156
|
+
severity: "warning",
|
|
157
|
+
pillar: "modernisation",
|
|
158
|
+
confidence: "medium",
|
|
159
|
+
remediation: "Prefer a typed parser, type guard, or narrower assertion at the trust boundary.",
|
|
160
|
+
metadata: { sourceType, targetType },
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* `any` in an exported declaration's public surface. Only one finding per line because a single
|
|
168
|
+
* `export` with multiple any-typed fields is one design problem, not many. Reports the stable
|
|
169
|
+
* `waste.exported-any` finding.
|
|
170
|
+
*/
|
|
171
|
+
function pushExportedAnyFinding(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
172
|
+
const exportedAny = exportedAnySymbol(codeLine);
|
|
173
|
+
if (!exportedAny) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
findings.push(
|
|
177
|
+
makeFinding({
|
|
178
|
+
ruleId: "waste.exported-any",
|
|
179
|
+
message: `Exported API \`${exportedAny}\` exposes \`any\` in its public contract.`,
|
|
180
|
+
filePath: file.displayPath,
|
|
181
|
+
line: lineNumber,
|
|
182
|
+
severity: "warning",
|
|
183
|
+
pillar: "waste",
|
|
184
|
+
confidence: "medium",
|
|
185
|
+
symbol: exportedAny,
|
|
186
|
+
remediation: "Use a named interface, unknown plus validation, or a precise generic type.",
|
|
187
|
+
metadata: { symbolName: exportedAny },
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// `arr.forEach(async …)` is a near-universal anti-pattern: the array iterator does not await the
|
|
193
|
+
// returned promise, so errors swallow silently. Reports the stable `security.async-foreach` finding.
|
|
194
|
+
function pushAsyncForEachFinding(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
195
|
+
if (!/\.forEach\s*\(\s*async\b/.test(codeLine)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
findings.push(
|
|
199
|
+
makeFinding({
|
|
200
|
+
ruleId: "security.async-foreach",
|
|
201
|
+
message: "async callbacks passed to forEach are not awaited by the caller.",
|
|
202
|
+
filePath: file.displayPath,
|
|
203
|
+
line: lineNumber,
|
|
204
|
+
severity: "warning",
|
|
205
|
+
pillar: "security",
|
|
206
|
+
confidence: "medium",
|
|
207
|
+
remediation: "Use for...of with await, Promise.all, or an explicit queue.",
|
|
208
|
+
metadata: { callName: "forEach" },
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/*
|
|
214
|
+
* A promise-shaped call started as a bare statement, with no `await`, `return`, `void`, or chain.
|
|
215
|
+
* Such promises lose their reject path - exceptions land in an unhandled-rejection. Reports
|
|
216
|
+
* the stable `security.floating-promise` finding.
|
|
217
|
+
*/
|
|
218
|
+
function pushFloatingPromiseFinding(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
219
|
+
const floating = floatingPromiseCall(codeLine);
|
|
220
|
+
if (!floating) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
findings.push(
|
|
224
|
+
makeFinding({
|
|
225
|
+
ruleId: "security.floating-promise",
|
|
226
|
+
message: `Promise-like call \`${floating}\` is started without await, return, or void.`,
|
|
227
|
+
filePath: file.displayPath,
|
|
228
|
+
line: lineNumber,
|
|
229
|
+
severity: "warning",
|
|
230
|
+
pillar: "security",
|
|
231
|
+
confidence: "medium",
|
|
232
|
+
symbol: floating,
|
|
233
|
+
remediation: "Await it, return it, or prefix with void when fire-and-forget is intentional.",
|
|
234
|
+
metadata: { callName: floating },
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/*
|
|
240
|
+
* `throw "string"` / `throw { …object }` / `throw 42`. JavaScript permits it but the stack trace
|
|
241
|
+
* is missing and the caller can't pattern-match an Error subclass. Reports the stable
|
|
242
|
+
* `security.throw-non-error` finding.
|
|
243
|
+
*/
|
|
244
|
+
function pushNonErrorThrowFinding(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
245
|
+
const thrown = nonErrorThrowExpression(codeLine);
|
|
246
|
+
if (!thrown) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
findings.push(
|
|
250
|
+
makeFinding({
|
|
251
|
+
ruleId: "security.throw-non-error",
|
|
252
|
+
message: "Throwing non-Error values loses stack and error-shape information.",
|
|
253
|
+
filePath: file.displayPath,
|
|
254
|
+
line: lineNumber,
|
|
255
|
+
severity: "warning",
|
|
256
|
+
pillar: "security",
|
|
257
|
+
confidence: "medium",
|
|
258
|
+
remediation: "Throw an Error subclass with a clear message and structured properties.",
|
|
259
|
+
metadata: { expression: thrown },
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Returns the directive name only when the suffix following a TypeScript suppression directive
|
|
265
|
+
// has no meaningful rationale. Heuristic is intentionally lenient - three real words usually means
|
|
266
|
+
// the maintainer wrote a reason.
|
|
267
|
+
function tsDirectiveWithoutRationale(line: string): { directive: string } | undefined {
|
|
268
|
+
const match = line.match(/@ts-(ignore|expect-error)\b(.*)$/);
|
|
269
|
+
if (!match?.[1]) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
const rationale = match[2] ?? "";
|
|
273
|
+
if (hasDirectiveRationale(rationale)) {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
return { directive: `@ts-${match[1]}` };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Two-way pass: an explicit suppression rationale token (tracking URL / issue ID / owner / date)
|
|
280
|
+
// or at least three real English-shaped words. The disjunction keeps maintainers from having to
|
|
281
|
+
// remember a specific format.
|
|
282
|
+
function hasDirectiveRationale(directiveSuffix: string): boolean {
|
|
283
|
+
const cleaned = directiveSuffix.replace(/^[-:\s]+/, "").trim();
|
|
284
|
+
const words = cleaned.match(/[A-Za-z]{3,}/g) ?? [];
|
|
285
|
+
return hasSuppressionRationale(cleaned) || words.length >= 3;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Two-shot scan: line must have both `export` and `any` before the regex is invoked, because the
|
|
289
|
+
// regex is expensive and most lines do not have both.
|
|
290
|
+
function exportedAnySymbol(codeLine: string): string | undefined {
|
|
291
|
+
if (!/\bexport\b/.test(codeLine) || !/\bany\b/.test(codeLine)) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const match = codeLine.match(/\bexport\s+(?:async\s+)?(?:function|const|let|var|class|interface|type)\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
|
|
295
|
+
return match?.[1];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Two predicates compose: must be a bare statement (not handled), and must be a promise-shaped
|
|
299
|
+
// call. Returning undefined keeps the per-line emission stable when either gate fails.
|
|
300
|
+
function floatingPromiseCall(codeLine: string): string | undefined {
|
|
301
|
+
const trimmed = codeLine.trim();
|
|
302
|
+
if (isHandledPromiseStatement(trimmed)) {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const callName = leadingCallName(trimmed);
|
|
306
|
+
if (!callName) {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
return isPromiseLikeCall(callName) ? callName : undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Five "this is intentional" forms: await, return, void, throw, yield, or a binding. Any one keeps
|
|
313
|
+
// the line out of floating-promise reporting.
|
|
314
|
+
function isHandledPromiseStatement(trimmedLine: string): boolean {
|
|
315
|
+
return trimmedLine.length === 0 || /^(?:await|return|void|throw|yield)\b/.test(trimmedLine) || /^(?:const|let|var)\s+/.test(trimmedLine);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Picks the dotted callable name at the start of the line. Empty string for non-call statements
|
|
319
|
+
// signals "not a candidate" to the caller without throwing.
|
|
320
|
+
function leadingCallName(trimmedLine: string): string {
|
|
321
|
+
const match = trimmedLine.match(/^([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)\s*\(/);
|
|
322
|
+
return match?.[1] ?? "";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Heuristic: `fetch`, anything ending in `Async`, or anything ending in `Promise`. False positives
|
|
326
|
+
// are tolerated because the rule's remediation ("await or void it") is also the universal best practice.
|
|
327
|
+
function isPromiseLikeCall(callName: string): boolean {
|
|
328
|
+
const localName = callName.split(".").at(-1) ?? callName;
|
|
329
|
+
return callName === "fetch" || /(?:Async|Promise)$/.test(localName);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Allow `throw new XxxError(...)` and `throw e` (bare identifier - usually a rethrow), reject literals.
|
|
333
|
+
// Returns a truncated preview because the full expression can be arbitrarily long.
|
|
334
|
+
function nonErrorThrowExpression(codeLine: string): string | undefined {
|
|
335
|
+
const match = codeLine.match(/\bthrow\s+(.+?);?$/);
|
|
336
|
+
const expression = (match?.[1] ?? "").trim();
|
|
337
|
+
if (!expression) {
|
|
338
|
+
return undefined;
|
|
339
|
+
}
|
|
340
|
+
if (/^(?:new\s+[A-Za-z_$][A-Za-z0-9_$]*Error\b|[A-Za-z_$][A-Za-z0-9_$]*)/.test(expression)) {
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
return /^(?:["'`]|\d|\{|\[|true\b|false\b|null\b|undefined\b)/.test(expression) ? expression.slice(0, 40) : undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Strips both line and block comments before testing for emptiness. A catch body holding only a
|
|
347
|
+
// throwaway placeholder comment reads as a deliberate swallow but documents nothing about the
|
|
348
|
+
// recovery path - that is still the rule's signal.
|
|
349
|
+
function isSwallowedCatchBody(body: string): boolean {
|
|
350
|
+
const meaningful = body
|
|
351
|
+
.replace(/\/\/.*$/gm, "")
|
|
352
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
353
|
+
.trim();
|
|
354
|
+
return meaningful === "";
|
|
355
|
+
}
|
package/src/scoring.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Score, grade, and fail-on helpers derived from finding severities for reports and CLI exits.
|
|
2
|
+
import { grade } from "./report-renderers.ts";
|
|
3
|
+
import type { AnalysisReport, FailThreshold, Finding, Pillar, Severity } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// Builds the per-pillar and per-file score breakdown that ships in `gruff.analysis.v1`. The composite
|
|
6
|
+
// score is the mean of pillar scores so adding a pillar shifts the headline number, and `topOffenders`
|
|
7
|
+
// is intentionally truncated to 10 - both shapes are part of the public report contract.
|
|
8
|
+
function scoreReport(findings: Finding[]): AnalysisReport["score"] {
|
|
9
|
+
const byPillar = new Map<Pillar, Finding[]>();
|
|
10
|
+
const byFile = new Map<string, Finding[]>();
|
|
11
|
+
for (const finding of findings) {
|
|
12
|
+
byPillar.set(finding.pillar, [...(byPillar.get(finding.pillar) ?? []), finding]);
|
|
13
|
+
byFile.set(finding.filePath, [...(byFile.get(finding.filePath) ?? []), finding]);
|
|
14
|
+
}
|
|
15
|
+
const pillars = [...byPillar.entries()].map(([pillar, pillarFindings]) => {
|
|
16
|
+
const penalty = pillarFindings.reduce((sum, finding) => sum + severityPenalty(finding.severity), 0);
|
|
17
|
+
return { pillar, score: Math.max(0, 100 - penalty), findings: pillarFindings.length };
|
|
18
|
+
});
|
|
19
|
+
const composite = pillars.length === 0 ? 100 : pillars.reduce((sum, pillar) => sum + pillar.score, 0) / pillars.length;
|
|
20
|
+
const topOffenders = [...byFile.entries()]
|
|
21
|
+
.map(([filePath, fileFindings]) => ({
|
|
22
|
+
filePath,
|
|
23
|
+
score: Math.max(0, 100 - fileFindings.reduce((sum, finding) => sum + severityPenalty(finding.severity), 0)),
|
|
24
|
+
findings: fileFindings.length,
|
|
25
|
+
}))
|
|
26
|
+
.sort((left, right) => left.score - right.score)
|
|
27
|
+
.slice(0, 10);
|
|
28
|
+
return { composite, grade: grade(composite), pillars, topOffenders };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Severity tallies emitted in the report summary. The four-key shape (advisory/warning/error/total)
|
|
32
|
+
// is part of the `gruff.analysis.v1` schema and consumers rely on `total` matching the array length.
|
|
33
|
+
function summarize(findings: Finding[]) {
|
|
34
|
+
return {
|
|
35
|
+
advisory: findings.filter((finding) => finding.severity === "advisory").length,
|
|
36
|
+
warning: findings.filter((finding) => finding.severity === "warning").length,
|
|
37
|
+
error: findings.filter((finding) => finding.severity === "error").length,
|
|
38
|
+
total: findings.length,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Process exit contract: 2 when diagnostics were emitted (parse/IO failures the user must know about),
|
|
43
|
+
// 1 when any finding crosses `failOn`, 0 otherwise. CI scripts and the dashboard runner depend on
|
|
44
|
+
// this three-value invariant; reshuffling the precedence is a stable-contract regression.
|
|
45
|
+
function exitFor(report: AnalysisReport, failOn: FailThreshold): number {
|
|
46
|
+
if (report.diagnostics.length > 0) {
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
return report.findings.some((finding) => thresholdTriggered(failOn, finding.severity)) ? 1 : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Severity ladder: "none" never triggers, "advisory" triggers on anything, "warning" needs at least
|
|
53
|
+
// warning, "error" needs error. Order is intentional - `failOn=warning` must still trigger on errors.
|
|
54
|
+
function thresholdTriggered(thresholdValue: FailThreshold, severity: Severity): boolean {
|
|
55
|
+
if (thresholdValue === "none") {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (thresholdValue === "advisory") {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (thresholdValue === "warning") {
|
|
62
|
+
return severity === "warning" || severity === "error";
|
|
63
|
+
}
|
|
64
|
+
return severity === "error";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Penalty weights tuned so a handful of errors visibly drag a pillar below a passing grade while
|
|
68
|
+
// a long tail of advisories cannot single-handedly fail a healthy pillar. Adjusting these shifts
|
|
69
|
+
// every historical score and the grade letters in `scores.jsonl`.
|
|
70
|
+
function severityPenalty(severity: Severity): number {
|
|
71
|
+
return severity === "error" ? 8 : severity === "warning" ? 4 : 1.5;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export { scoreReport, summarize, exitFor };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Conservative source-to-sink security candidates. These are deliberately same-line checks: they
|
|
2
|
+
// report only when an external-input token is visibly inside a known risky sink expression.
|
|
3
|
+
import type { SourceFile } from "./discovery.ts";
|
|
4
|
+
import { makeFinding } from "./findings.ts";
|
|
5
|
+
import type { Finding } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// External input token that can be visibly matched on one source line.
|
|
8
|
+
interface SourceToken {
|
|
9
|
+
kind: string;
|
|
10
|
+
pattern: RegExp;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// One source-to-sink heuristic: a risky call pattern plus the remediation attached to its finding.
|
|
14
|
+
interface SecurityFlowRule {
|
|
15
|
+
ruleId: string;
|
|
16
|
+
message: string;
|
|
17
|
+
sinkKind: string;
|
|
18
|
+
remediation: string;
|
|
19
|
+
callPattern: RegExp;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SOURCE_TOKENS: readonly SourceToken[] = [
|
|
23
|
+
{ kind: "request", pattern: /\b(?:req|request|ctx)\.(?:query|params|body|headers|cookies)\b/ },
|
|
24
|
+
{ kind: "cli-argument", pattern: /\bprocess\.argv(?:\s*\[|\b)/ },
|
|
25
|
+
{ kind: "environment", pattern: /\bprocess\.env\.[A-Za-z_$][A-Za-z0-9_$]*/ },
|
|
26
|
+
{ kind: "browser-location", pattern: /\blocation\.search\b|\bURLSearchParams\s*\(\s*location\.search\b/ },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const SECURITY_FLOW_RULES: readonly SecurityFlowRule[] = [
|
|
30
|
+
{
|
|
31
|
+
ruleId: "security.path-traversal-candidate",
|
|
32
|
+
message: "External input reaches a filesystem path sink.",
|
|
33
|
+
sinkKind: "filesystem-path",
|
|
34
|
+
remediation: "Validate the path against an allowlist, resolve it under a safe root, and reject traversal.",
|
|
35
|
+
callPattern: /\b(?:fs\.)?(?:readFile|readFileSync|writeFile|writeFileSync|appendFile|appendFileSync|createReadStream|createWriteStream|stat|statSync|readdir|readdirSync|unlink|unlinkSync|rm|rmSync|mkdir|mkdirSync)\s*\(/g,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
ruleId: "security.ssrf-candidate",
|
|
39
|
+
message: "External input reaches a network request sink.",
|
|
40
|
+
sinkKind: "network-request",
|
|
41
|
+
remediation: "Validate destinations against an allowlist and block internal or metadata-service hosts.",
|
|
42
|
+
callPattern: /\b(?:fetch|axios\.(?:get|post|put|patch|delete|request)|(?:http|https)\.(?:request|get))\s*\(/g,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
ruleId: "security.open-redirect-candidate",
|
|
46
|
+
message: "External input reaches a redirect or browser navigation sink.",
|
|
47
|
+
sinkKind: "redirect",
|
|
48
|
+
remediation: "Redirect only to relative paths or destinations from an allowlist.",
|
|
49
|
+
callPattern: /\b(?:(?:res|reply|response)\.redirect|redirect|(?:location|window\.location)\.(?:assign|replace))\s*\(|\b(?:location|window\.location)\.href\s*=/g,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
ruleId: "security.dynamic-regexp",
|
|
53
|
+
message: "External input is used to build a regular expression.",
|
|
54
|
+
sinkKind: "regular-expression",
|
|
55
|
+
remediation: "Use a fixed pattern, escape user input, or enforce strict length and character limits.",
|
|
56
|
+
callPattern: /\b(?:new\s+RegExp|RegExp)\s*\(/g,
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
// Same-line matching is intentional: reports stable candidate findings without implying full taint analysis.
|
|
61
|
+
export function analyseSecurityFlowLine(file: SourceFile, codeLine: string, lineNumber: number, findings: Finding[]): void {
|
|
62
|
+
const source = sourceToken(codeLine);
|
|
63
|
+
if (!source) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const rule of SECURITY_FLOW_RULES) {
|
|
67
|
+
if (sourceAppearsInSink(codeLine, source.pattern, rule.callPattern)) {
|
|
68
|
+
findings.push(securityFlowFinding(file, lineNumber, rule, source.kind));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Returns the first visible external-input token so emitted metadata stays deterministic.
|
|
74
|
+
function sourceToken(codeLine: string): SourceToken | undefined {
|
|
75
|
+
return SOURCE_TOKENS.find((source) => source.pattern.test(codeLine));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Confirms the external-input token appears inside the same bounded sink call segment.
|
|
79
|
+
function sourceAppearsInSink(codeLine: string, sourcePattern: RegExp, callPattern: RegExp): boolean {
|
|
80
|
+
callPattern.lastIndex = 0;
|
|
81
|
+
for (const call of codeLine.matchAll(callPattern)) {
|
|
82
|
+
const callStart = call.index ?? 0;
|
|
83
|
+
if (sourcePattern.test(singleLineCallSegment(codeLine, callStart))) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Keeps matching on one visible call because multiline dataflow is deliberately out of scope.
|
|
91
|
+
function singleLineCallSegment(codeLine: string, callStart: number): string {
|
|
92
|
+
// maxSegmentLength limit: 240 chars covers normal calls while avoiding later same-line matches.
|
|
93
|
+
const maxSegmentLength = 240;
|
|
94
|
+
const segment = codeLine.slice(callStart, callStart + maxSegmentLength);
|
|
95
|
+
const close = segment.indexOf(")");
|
|
96
|
+
return close === -1 ? segment : segment.slice(0, close + 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Stable finding contract for source-to-sink rules: medium confidence plus source/sink metadata only.
|
|
100
|
+
function securityFlowFinding(file: SourceFile, line: number, rule: SecurityFlowRule, sourceKind: string): Finding {
|
|
101
|
+
return makeFinding({
|
|
102
|
+
ruleId: rule.ruleId,
|
|
103
|
+
message: rule.message,
|
|
104
|
+
filePath: file.displayPath,
|
|
105
|
+
line,
|
|
106
|
+
severity: "warning",
|
|
107
|
+
pillar: "security",
|
|
108
|
+
confidence: "medium",
|
|
109
|
+
remediation: rule.remediation,
|
|
110
|
+
metadata: { sourceKind, sinkKind: rule.sinkKind },
|
|
111
|
+
});
|
|
112
|
+
}
|