@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,334 @@
|
|
|
1
|
+
// Detects large test/fixture sources that need a purpose comment. Three candidate kinds in one
|
|
2
|
+
// pass - template-literal fixtures, generated array fixtures, and high-setup test blocks - then
|
|
3
|
+
// reports `docs.fixture-purpose-missing` for each candidate without a nearby explanation comment.
|
|
4
|
+
import { type FunctionBlock, setupLineCount } from "./blocks.ts";
|
|
5
|
+
import { type CommentRecord } from "./comment-scanner.ts";
|
|
6
|
+
import { threshold } from "./config.ts";
|
|
7
|
+
import { type SourceFile } from "./discovery.ts";
|
|
8
|
+
import { makeFinding } from "./findings.ts";
|
|
9
|
+
import { isFixtureLikePath, isTestPath } from "./project-rules.ts";
|
|
10
|
+
import type { Config, Finding } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// Below 12 lines, a fixture is short enough to read at a glance - requiring a purpose header
|
|
13
|
+
// would just be noise; above this threshold, the next reader needs the intent spelled out.
|
|
14
|
+
const FIXTURE_PURPOSE_MIN_LINES = 12;
|
|
15
|
+
|
|
16
|
+
// A potential `docs.fixture-purpose-missing` finding location. `targetKind` distinguishes
|
|
17
|
+
// template fixtures from generated fixtures so the metadata stays useful for downstream tooling.
|
|
18
|
+
interface FixturePurposeCandidate {
|
|
19
|
+
line: number;
|
|
20
|
+
symbol: string;
|
|
21
|
+
targetKind: string;
|
|
22
|
+
lineCount: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Argument bundle for `pushFixturePurposeFindings`. The eight-field call surface is kept as a
|
|
27
|
+
* stable struct so adding a new input does not silently break every per-block helper's positional
|
|
28
|
+
* argument list.
|
|
29
|
+
*/
|
|
30
|
+
export interface FixturePurposeInput {
|
|
31
|
+
file: SourceFile;
|
|
32
|
+
source: string;
|
|
33
|
+
codeSource: string;
|
|
34
|
+
lines: string[];
|
|
35
|
+
comments: CommentRecord[];
|
|
36
|
+
blocks: FunctionBlock[];
|
|
37
|
+
config: Config;
|
|
38
|
+
findings: Finding[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Test/fixture paths only - gated up front so production source never reports fixture-purpose
|
|
42
|
+
// findings. Reports the stable `docs.fixture-purpose-missing` finding for each candidate.
|
|
43
|
+
export function pushFixturePurposeFindings(input: FixturePurposeInput): void {
|
|
44
|
+
const { file, source, codeSource, lines, comments, blocks, config, findings } = input;
|
|
45
|
+
if (!isTestPath(file.displayPath) && !isFixtureLikePath(file.displayPath)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const candidate of fixturePurposeCandidates(source, codeSource, blocks, config)) {
|
|
49
|
+
if (hasFixturePurposeComment(lines, comments, candidate.line)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
findings.push(
|
|
53
|
+
makeFinding({
|
|
54
|
+
ruleId: "docs.fixture-purpose-missing",
|
|
55
|
+
message: `Large fixture source near \`${candidate.symbol}\` is missing a purpose comment.`,
|
|
56
|
+
filePath: file.displayPath,
|
|
57
|
+
line: candidate.line,
|
|
58
|
+
severity: "advisory",
|
|
59
|
+
pillar: "documentation",
|
|
60
|
+
confidence: "medium",
|
|
61
|
+
symbol: candidate.symbol,
|
|
62
|
+
remediation: "Add a nearby comment explaining what scanner path, regression, or fixture behavior this source covers.",
|
|
63
|
+
metadata: {
|
|
64
|
+
targetKind: candidate.targetKind,
|
|
65
|
+
fixtureLines: candidate.lineCount,
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Three candidate kinds collected in one pass: template-literal fixtures, generated array fixtures,
|
|
73
|
+
// and test setup blocks. `occupiedLines` tracks the first two so the third doesn't double-report.
|
|
74
|
+
function fixturePurposeCandidates(source: string, codeSource: string, blocks: FunctionBlock[], config: Config): FixturePurposeCandidate[] {
|
|
75
|
+
if (!hasFixturePurposeCandidateSignal(codeSource, blocks)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const candidates: FixturePurposeCandidate[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const occupiedLines = new Set<number>();
|
|
81
|
+
const lines = source.split(/\r?\n/);
|
|
82
|
+
const codeLines = codeSource.split(/\r?\n/);
|
|
83
|
+
const lineOffsets = sourceLineStartOffsets(source);
|
|
84
|
+
|
|
85
|
+
codeLines.forEach((codeLine, index) => {
|
|
86
|
+
const lineNumber = index + 1;
|
|
87
|
+
const templateCandidate = fixtureTemplateCandidate(source, lineOffsets, codeLine, lineNumber);
|
|
88
|
+
if (templateCandidate) {
|
|
89
|
+
pushUniqueFixturePurposeCandidate(candidates, seen, templateCandidate);
|
|
90
|
+
occupiedLines.add(templateCandidate.line);
|
|
91
|
+
}
|
|
92
|
+
const generatedCandidate = generatedFixtureCandidate(lines[index] ?? "", codeLine, lineNumber);
|
|
93
|
+
if (generatedCandidate) {
|
|
94
|
+
pushUniqueFixturePurposeCandidate(candidates, seen, generatedCandidate);
|
|
95
|
+
occupiedLines.add(generatedCandidate.line);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
for (const candidate of fixtureTestBlockCandidates(blocks, config, occupiedLines)) {
|
|
100
|
+
pushUniqueFixturePurposeCandidate(candidates, seen, candidate);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return candidates.filter((candidate) => candidate.line <= lines.length);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Whole-file preflight for the only shapes that can become fixture-purpose findings.
|
|
107
|
+
function hasFixturePurposeCandidateSignal(codeSource: string, blocks: FunctionBlock[]): boolean {
|
|
108
|
+
return blocks.some((block) => block.isTest) || /\b(?:analyseFixture|analyseProject|writeFileSync|Array\.from)\s*\(/.test(codeSource) || /\b(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:Fixture|FIXTURE)[A-Za-z0-9_$]*\b[^=\n]*=/.test(codeSource);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Composite key prevents two fixture candidates landing at the same line/symbol/kind tuple - a
|
|
112
|
+
// real case when one declaration triggers both a template-literal match and a generated-array match.
|
|
113
|
+
function pushUniqueFixturePurposeCandidate(candidates: FixturePurposeCandidate[], seen: Set<string>, candidate: FixturePurposeCandidate): void {
|
|
114
|
+
const key = `${candidate.line}\0${candidate.symbol}\0${candidate.targetKind}`;
|
|
115
|
+
if (seen.has(key)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
seen.add(key);
|
|
119
|
+
candidates.push(candidate);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Two-stage gate: detect the call-site trigger (`analyseFixture`, `analyseProject`, etc.), then
|
|
123
|
+
// extract the adjacent template literal and confirm it is large enough to need documentation.
|
|
124
|
+
function fixtureTemplateCandidate(source: string, lineOffsets: number[], codeLine: string, lineNumber: number): FixturePurposeCandidate | undefined {
|
|
125
|
+
const trigger = fixtureTemplateTrigger(codeLine);
|
|
126
|
+
if (!trigger) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const text = templateLiteralAtLine(source, lineOffsets, lineNumber);
|
|
130
|
+
if (!text || !isLargeSourceFixtureText(text)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
line: lineNumber,
|
|
135
|
+
symbol: trigger.symbol,
|
|
136
|
+
targetKind: trigger.targetKind,
|
|
137
|
+
lineCount: fixtureLineCount(text),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Four trigger forms in priority order: `analyseFixture(`, `analyseProject(`, `writeFileSync(`,
|
|
142
|
+
// or a `*Fixture` / `*FIXTURE` constant declaration. The `targetKind` differentiates them in metadata.
|
|
143
|
+
function fixtureTemplateTrigger(codeLine: string): { symbol: string; targetKind: string } | undefined {
|
|
144
|
+
if (/\banalyseFixture\s*\(/.test(codeLine)) {
|
|
145
|
+
return { symbol: "analyseFixture", targetKind: "inline-source" };
|
|
146
|
+
}
|
|
147
|
+
if (/\banalyseProject\s*\(/.test(codeLine)) {
|
|
148
|
+
return { symbol: "analyseProject", targetKind: "inline-project" };
|
|
149
|
+
}
|
|
150
|
+
if (/\bwriteFileSync\s*\(/.test(codeLine)) {
|
|
151
|
+
return { symbol: "writeFileSync", targetKind: "written-source" };
|
|
152
|
+
}
|
|
153
|
+
const fixtureName = fixtureConstantName(codeLine);
|
|
154
|
+
return fixtureName ? { symbol: fixtureName, targetKind: "fixture-constant" } : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Identifier names matching `*Fixture` or `*FIXTURE` are treated as opt-in markers - projects
|
|
158
|
+
// signal "this is fixture data" with that suffix, so it's the natural trigger for the rule.
|
|
159
|
+
function fixtureConstantName(codeLine: string): string | undefined {
|
|
160
|
+
return codeLine.match(/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*(?:Fixture|FIXTURE)[A-Za-z0-9_$]*)\b[^=\n]*=/)?.[1];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// `Array.from({ length: N })` style generated fixtures. Requires both a `*Fixture` constant name
|
|
164
|
+
// and N > FIXTURE_PURPOSE_MIN_LINES so trivial test arrays don't trip the rule.
|
|
165
|
+
function generatedFixtureCandidate(rawLine: string, codeLine: string, lineNumber: number): FixturePurposeCandidate | undefined {
|
|
166
|
+
const fixtureName = fixtureConstantName(codeLine) ?? (/\b(?:const|let|var)\b/.test(codeLine) ? fixtureConstantName(rawLine) : undefined);
|
|
167
|
+
const generatedLength = Number((codeLine.match(/\bArray\.from\s*\(\s*\{\s*length\s*:\s*(\d+)/) ?? rawLine.match(/\bArray\.from\s*\(\s*\{\s*length\s*:\s*(\d+)/))?.[1] ?? 0);
|
|
168
|
+
if (!fixtureName || generatedLength <= FIXTURE_PURPOSE_MIN_LINES) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
line: lineNumber,
|
|
173
|
+
symbol: fixtureName,
|
|
174
|
+
targetKind: "generated-fixture",
|
|
175
|
+
lineCount: generatedLength,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Test blocks with high setup-line counts AND a fixture-shape signal. Excludes blocks whose setup
|
|
180
|
+
// already produced a template-literal or generated-array candidate at the same line.
|
|
181
|
+
function fixtureTestBlockCandidates(blocks: FunctionBlock[], config: Config, occupiedLines: Set<number>): FixturePurposeCandidate[] {
|
|
182
|
+
const candidates: FixturePurposeCandidate[] = [];
|
|
183
|
+
for (const block of blocks) {
|
|
184
|
+
if (!block.isTest || fixtureLineInsideBlock(block, occupiedLines)) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const setupLines = setupLineCount(block.codeBody);
|
|
188
|
+
if (setupLines <= threshold(config, "test-quality.setup-bloat", 12) || !hasFixtureSetupSignal(block.codeBody)) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
candidates.push({
|
|
192
|
+
line: block.declarationLine,
|
|
193
|
+
symbol: block.name,
|
|
194
|
+
targetKind: "test-setup",
|
|
195
|
+
lineCount: setupLines,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return candidates;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Returns true if any occupied line falls inside the block's line range. Prevents test blocks
|
|
202
|
+
// from double-reporting on a fixture that was already detected as a template literal.
|
|
203
|
+
function fixtureLineInsideBlock(block: FunctionBlock, occupiedLines: Set<number>): boolean {
|
|
204
|
+
const endLine = block.startLine + block.lineCount - 1;
|
|
205
|
+
for (const line of occupiedLines) {
|
|
206
|
+
if (line >= block.startLine && line <= endLine) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Either the canonical fixture-generation calls (analyseFixture / writeFileSync / mkdtempSync /
|
|
214
|
+
// Array.from) or an identifier containing "fixture". The narrow allowlist is deliberate - broader
|
|
215
|
+
// matchers would catch ordinary production code that happens to construct test data.
|
|
216
|
+
function hasFixtureSetupSignal(source: string): boolean {
|
|
217
|
+
return /\b(?:analyseFixture|writeFileSync|mkdtempSync|Array\.from)\s*\(/.test(source) || hasFixtureIdentifier(source);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Substring (not word boundary) match because fixture identifiers in real projects include forms
|
|
221
|
+
// like `userFixtureA`, `manyFixtureRows`, etc. Lowercased so casing variants all match.
|
|
222
|
+
function hasFixtureIdentifier(source: string): boolean {
|
|
223
|
+
for (const match of source.matchAll(/\b[A-Za-z_$][A-Za-z0-9_$]*\b/g)) {
|
|
224
|
+
if ((match[0] ?? "").toLowerCase().includes("fixture")) {
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Two conjoint conditions: enough lines AND at least one declarative keyword. Short strings or
|
|
232
|
+
// pure prose templates don't need a purpose comment; only code-shaped fixtures do.
|
|
233
|
+
function isLargeSourceFixtureText(text: string): boolean {
|
|
234
|
+
return fixtureLineCount(text) > FIXTURE_PURPOSE_MIN_LINES && /\b(?:function|class|interface|type|enum|const|let|var|import|export|test|it)\b/.test(text);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Newline-count, not "nonblank-line count" - the rule cares about apparent fixture size as the
|
|
238
|
+
// maintainer sees it, including blank padding.
|
|
239
|
+
function fixtureLineCount(text: string): number {
|
|
240
|
+
return text.split(/\r?\n/).length;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Index → line lookup support. Used by the fixture-template detector to map a byte offset to a
|
|
244
|
+
// (line, column) coordinate without splitting the whole source on every query.
|
|
245
|
+
function sourceLineStartOffsets(source: string): number[] {
|
|
246
|
+
const offsets = [0];
|
|
247
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
248
|
+
if (source[index] === "\n") {
|
|
249
|
+
offsets.push(index + 1);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return offsets;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Walks forward from the line's start offset to find the opening backtick on that line, then
|
|
256
|
+
// searches for its match. Returns undefined when no template starts on the line.
|
|
257
|
+
function templateLiteralAtLine(source: string, lineOffsets: number[], lineNumber: number): string | undefined {
|
|
258
|
+
const start = lineOffsets[lineNumber - 1];
|
|
259
|
+
if (start === undefined) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
const nextLineStart = lineOffsets[lineNumber] ?? source.length + 1;
|
|
263
|
+
const firstBacktick = source.indexOf("`", start);
|
|
264
|
+
if (firstBacktick < 0 || firstBacktick >= nextLineStart) {
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
const end = closingTemplateLiteralIndex(source, firstBacktick + 1);
|
|
268
|
+
return end === undefined ? undefined : source.slice(firstBacktick + 1, end);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Linear walk respecting `\`` escapes. Does NOT handle `${…}` interpolation specially - fixture
|
|
272
|
+
// templates rarely contain nested backticks, and a stricter parse would not help the rule's signal.
|
|
273
|
+
function closingTemplateLiteralIndex(source: string, startIndex: number): number | undefined {
|
|
274
|
+
let isEscaped = false;
|
|
275
|
+
for (let index = startIndex; index < source.length; index += 1) {
|
|
276
|
+
const character = source[index] ?? "";
|
|
277
|
+
if (isEscaped) {
|
|
278
|
+
isEscaped = false;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (character === "\\") {
|
|
282
|
+
isEscaped = true;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (character === "`") {
|
|
286
|
+
return index;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Two acceptable positions: a comment on the candidate line itself, or one directly above with
|
|
293
|
+
// nothing but blank lines in between. Anything farther away cannot be claimed as documentation.
|
|
294
|
+
function hasFixturePurposeComment(lines: string[], comments: CommentRecord[], line: number): boolean {
|
|
295
|
+
const sameLine = comments.find((comment) => comment.line <= line && comment.endLine >= line);
|
|
296
|
+
if (sameLine && hasFixturePurposeMarker(sameLine.text)) {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
const leading = leadingFixturePurposeComment(lines, comments, line);
|
|
300
|
+
return Boolean(leading && hasFixturePurposeMarker(leading.text));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Like `leadingCommentForLine` but with the fixture-specific blank-gap predicate that allows for
|
|
304
|
+
// slightly more spacing than the documentation-rule version.
|
|
305
|
+
function leadingFixturePurposeComment(lines: string[], comments: CommentRecord[], line: number): CommentRecord | undefined {
|
|
306
|
+
for (let index = comments.length - 1; index >= 0; index -= 1) {
|
|
307
|
+
const comment = comments[index];
|
|
308
|
+
if (!comment || comment.endLine >= line) {
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (hasOnlyBlankFixturePurposeGap(lines, comment.endLine + 1, line - 1)) {
|
|
312
|
+
return comment;
|
|
313
|
+
}
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Identical to `hasOnlyBlankLines` but with an inclusive upper bound - fixtures may have one
|
|
320
|
+
// extra blank line of breathing room above them that ordinary declaration comments do not.
|
|
321
|
+
function hasOnlyBlankFixturePurposeGap(lines: string[], startLine: number, endLine: number): boolean {
|
|
322
|
+
for (let line = startLine; line <= endLine; line += 1) {
|
|
323
|
+
if ((lines[line - 1] ?? "").trim() !== "") {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Vocabulary list of words a meaningful fixture-purpose comment is expected to use (fixture,
|
|
331
|
+
// covers, regression, baseline, fingerprint, …). Project task references count as well.
|
|
332
|
+
function hasFixturePurposeMarker(text: string): boolean {
|
|
333
|
+
return /\b(?:fixture|covers|reproduces|regression|scanner|parse|baseline|fingerprint|noise|valid case|invalid case|because|M\d{1,3})\b/i.test(text) || /\.goat-flow\/tasks\//.test(text);
|
|
334
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Fixture data covers security rule-quality doctrine added by the M40/M42 scanner expansion.
|
|
2
|
+
export const SECURITY_EXPANSION_RISKY_RULE_IDS = [
|
|
3
|
+
"security.dynamic-regexp",
|
|
4
|
+
"security.github-actions-broad-permissions",
|
|
5
|
+
"security.github-actions-pull-request-target",
|
|
6
|
+
"security.github-actions-remote-shell",
|
|
7
|
+
"security.github-actions-secrets-in-pr",
|
|
8
|
+
"security.github-actions-unpinned-action",
|
|
9
|
+
"security.open-redirect-candidate",
|
|
10
|
+
"security.path-traversal-candidate",
|
|
11
|
+
"security.ssrf-candidate",
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
export const SECURITY_EXPANSION_RULE_QUALITY_DOCTRINE = [
|
|
15
|
+
{
|
|
16
|
+
ruleId: "security.dynamic-regexp",
|
|
17
|
+
signalSource: "same-line source-to-sink scan for external input inside RegExp constructors",
|
|
18
|
+
expectedPillar: "security",
|
|
19
|
+
expectedSeverity: "warning",
|
|
20
|
+
expectedConfidence: "medium",
|
|
21
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
22
|
+
invalidFixture: "process.argv or request input passed directly to new RegExp or RegExp",
|
|
23
|
+
noisyValidFixture: "fixed literal regular expressions plus prose strings mentioning risky examples",
|
|
24
|
+
missingInvalidFixture: "external regular expression construction remains reported when safe literal patterns are nearby",
|
|
25
|
+
falsePositiveEscapeHatch: "require an external-input token inside the same constructor call segment",
|
|
26
|
+
fingerprintStability: "anchor to the constructor line and keep raw pattern text out of metadata",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
ruleId: "security.github-actions-broad-permissions",
|
|
30
|
+
signalSource: "path-gated GitHub Actions workflow scan for permissions: write-all and selected write scopes",
|
|
31
|
+
expectedPillar: "security",
|
|
32
|
+
expectedSeverity: "warning",
|
|
33
|
+
expectedConfidence: "medium",
|
|
34
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
35
|
+
invalidFixture: "workflow permissions block granting write-all or repository write scopes",
|
|
36
|
+
noisyValidFixture: "read-only permissions, non-workflow YAML examples, and ordinary prose mentioning write access",
|
|
37
|
+
missingInvalidFixture: "broad workflow write permissions remain reported beside read-only workflow permissions",
|
|
38
|
+
falsePositiveEscapeHatch: "run only on .github/workflows YAML and require a permissions key or scoped write line",
|
|
39
|
+
fingerprintStability: "anchor to the permission line with the scope symbol",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
ruleId: "security.github-actions-pull-request-target",
|
|
43
|
+
signalSource: "path-gated GitHub Actions workflow scan for pull_request_target plus risky execution context",
|
|
44
|
+
expectedPillar: "security",
|
|
45
|
+
expectedSeverity: "warning",
|
|
46
|
+
expectedConfidence: "medium",
|
|
47
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
48
|
+
invalidFixture: "pull_request_target workflow paired with checkout, run, secrets, or write permissions",
|
|
49
|
+
noisyValidFixture: "pull_request_target event alone, pull_request workflows, and non-workflow YAML examples",
|
|
50
|
+
missingInvalidFixture: "risky pull_request_target context remains reported when safe event-only workflows are nearby",
|
|
51
|
+
falsePositiveEscapeHatch: "do not report the event unless a bounded risky context token is also present",
|
|
52
|
+
fingerprintStability: "anchor to the pull_request_target event line with sorted riskContext metadata",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
ruleId: "security.github-actions-remote-shell",
|
|
56
|
+
signalSource: "path-gated GitHub Actions workflow scan for run steps that pipe curl or wget to a shell",
|
|
57
|
+
expectedPillar: "security",
|
|
58
|
+
expectedSeverity: "warning",
|
|
59
|
+
expectedConfidence: "medium",
|
|
60
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
61
|
+
invalidFixture: "workflow run step containing curl or wget from HTTPS piped to sh, bash, or zsh",
|
|
62
|
+
noisyValidFixture: "download-only curl commands, package script examples, and prose snippets outside workflow paths",
|
|
63
|
+
missingInvalidFixture: "remote shell workflow command remains reported beside safe download-only commands",
|
|
64
|
+
falsePositiveEscapeHatch: "require workflow path plus run-step context before matching remote shell text",
|
|
65
|
+
fingerprintStability: "anchor to the run command line without including the full downloaded URL",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
ruleId: "security.github-actions-secrets-in-pr",
|
|
69
|
+
signalSource: "path-gated GitHub Actions workflow scan for pull request events with secrets references",
|
|
70
|
+
expectedPillar: "security",
|
|
71
|
+
expectedSeverity: "warning",
|
|
72
|
+
expectedConfidence: "medium",
|
|
73
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
74
|
+
invalidFixture: "pull_request or pull_request_target workflow referencing secrets.NAME",
|
|
75
|
+
noisyValidFixture: "push-only workflows with secrets and pull request workflows without secrets",
|
|
76
|
+
missingInvalidFixture: "pull request secret reference remains reported when push-only secret usage is nearby",
|
|
77
|
+
falsePositiveEscapeHatch: "require both a pull request style event and a concrete secrets.NAME reference",
|
|
78
|
+
fingerprintStability: "anchor to the secret reference line and use the secret name as symbol",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
ruleId: "security.github-actions-unpinned-action",
|
|
82
|
+
signalSource: "path-gated GitHub Actions workflow scan for third-party uses entries without 40-character SHA refs",
|
|
83
|
+
expectedPillar: "security",
|
|
84
|
+
expectedSeverity: "warning",
|
|
85
|
+
expectedConfidence: "medium",
|
|
86
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
87
|
+
invalidFixture: "third-party workflow action pinned to a tag or branch instead of a full commit SHA",
|
|
88
|
+
noisyValidFixture: "GitHub-owned actions, local reusable actions, and third-party actions pinned to a full SHA",
|
|
89
|
+
missingInvalidFixture: "third-party tag reference remains reported when official and SHA-pinned actions are nearby",
|
|
90
|
+
falsePositiveEscapeHatch: "exclude actions and github owners plus local action paths before checking the ref",
|
|
91
|
+
fingerprintStability: "anchor to the uses line with action owner and ref metadata",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
ruleId: "security.open-redirect-candidate",
|
|
95
|
+
signalSource: "same-line source-to-sink scan for external input inside redirect or navigation sinks",
|
|
96
|
+
expectedPillar: "security",
|
|
97
|
+
expectedSeverity: "warning",
|
|
98
|
+
expectedConfidence: "medium",
|
|
99
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
100
|
+
invalidFixture: "request query or body value passed directly to redirect or location navigation",
|
|
101
|
+
noisyValidFixture: "relative redirect literals, fixed navigation targets, and prose strings with redirect examples",
|
|
102
|
+
missingInvalidFixture: "external redirect target remains reported when safe relative redirects are nearby",
|
|
103
|
+
falsePositiveEscapeHatch: "require an external-input token inside the same redirect or navigation expression",
|
|
104
|
+
fingerprintStability: "anchor to the redirect expression line with source and sink kinds only in metadata",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
ruleId: "security.path-traversal-candidate",
|
|
108
|
+
signalSource: "same-line source-to-sink scan for external input inside filesystem path APIs",
|
|
109
|
+
expectedPillar: "security",
|
|
110
|
+
expectedSeverity: "warning",
|
|
111
|
+
expectedConfidence: "medium",
|
|
112
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
113
|
+
invalidFixture: "request, argv, or environment value passed directly to readFile or writeFile style path sink",
|
|
114
|
+
noisyValidFixture: "fixed local paths, predeclared safe path variables, and prose strings with filesystem examples",
|
|
115
|
+
missingInvalidFixture: "external filesystem path remains reported when fixed local path calls are nearby",
|
|
116
|
+
falsePositiveEscapeHatch: "require an external-input token inside the same filesystem call segment",
|
|
117
|
+
fingerprintStability: "anchor to the filesystem call line with source and sink kinds only in metadata",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
ruleId: "security.ssrf-candidate",
|
|
121
|
+
signalSource: "same-line source-to-sink scan for external input inside network request APIs",
|
|
122
|
+
expectedPillar: "security",
|
|
123
|
+
expectedSeverity: "warning",
|
|
124
|
+
expectedConfidence: "medium",
|
|
125
|
+
fixtureCategories: ["valid", "invalid", "noisy-valid", "missing-invalid"],
|
|
126
|
+
invalidFixture: "request query or body value passed directly to fetch, axios, http.request, or https.request",
|
|
127
|
+
noisyValidFixture: "fixed URL literals, safe health-check calls, and prose strings mentioning fetch examples",
|
|
128
|
+
missingInvalidFixture: "external network destination remains reported when fixed URL requests are nearby",
|
|
129
|
+
falsePositiveEscapeHatch: "require an external-input token inside the same network request call segment",
|
|
130
|
+
fingerprintStability: "anchor to the request call line with source and sink kinds only in metadata",
|
|
131
|
+
},
|
|
132
|
+
] as const;
|