@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTRIBUTING.md +87 -0
  3. package/LICENSE +21 -0
  4. package/README.md +303 -0
  5. package/SECURITY.md +45 -0
  6. package/bin/gruff-ts +25 -0
  7. package/docs/CONFIGURATION.md +220 -0
  8. package/docs/RELEASING.md +103 -0
  9. package/docs/REPORTS_AND_CI.md +156 -0
  10. package/fixtures/sample.ts +21 -0
  11. package/package.json +56 -0
  12. package/scripts/bump-version.sh +145 -0
  13. package/scripts/check.sh +4 -0
  14. package/scripts/npm-publish.sh +258 -0
  15. package/scripts/preflight-checks.sh +357 -0
  16. package/scripts/start-dev.sh +8 -0
  17. package/scripts/test-performance.sh +695 -0
  18. package/src/analyser.ts +461 -0
  19. package/src/baseline.ts +90 -0
  20. package/src/blocks.ts +687 -0
  21. package/src/class-rules.ts +326 -0
  22. package/src/cli-program.ts +326 -0
  23. package/src/cli.ts +19 -0
  24. package/src/comment-rules.ts +605 -0
  25. package/src/comment-scanner.ts +357 -0
  26. package/src/config.ts +622 -0
  27. package/src/constants.ts +4 -0
  28. package/src/context-doc-rules.ts +241 -0
  29. package/src/dashboard.ts +114 -0
  30. package/src/dead-code-rules.ts +183 -0
  31. package/src/discovery.ts +508 -0
  32. package/src/doc-rules.ts +368 -0
  33. package/src/findings-helpers.ts +108 -0
  34. package/src/findings.ts +45 -0
  35. package/src/fixture-purpose-rules.ts +334 -0
  36. package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
  37. package/src/github-actions-rules.ts +413 -0
  38. package/src/line-rules.ts +538 -0
  39. package/src/naming-pushers.ts +191 -0
  40. package/src/project-config-rules.ts +555 -0
  41. package/src/project-rules.ts +545 -0
  42. package/src/report-renderers.ts +691 -0
  43. package/src/rule-list.ts +179 -0
  44. package/src/rules.ts +135 -0
  45. package/src/safety-rules.ts +355 -0
  46. package/src/scoring.ts +74 -0
  47. package/src/security-flow-rules.ts +112 -0
  48. package/src/sensitive-data-rules.ts +288 -0
  49. package/src/source-text.ts +722 -0
  50. package/src/test-block-rules.ts +347 -0
  51. package/src/test-fixtures.ts +621 -0
  52. package/src/text-scans.ts +193 -0
  53. package/src/types.ts +113 -0
  54. package/tsconfig.json +15 -0
@@ -0,0 +1,413 @@
1
+ // GitHub Actions workflow security heuristics, path-gated to committed workflow files.
2
+ import type { SourceFile } from "./discovery.ts";
3
+ import { makeFinding } from "./findings.ts";
4
+ import type { Finding } from "./types.ts";
5
+
6
+ // Normalized workflow line used by indentation-aware YAML heuristics.
7
+ interface WorkflowLine {
8
+ raw: string;
9
+ text: string;
10
+ trimmed: string;
11
+ lineNumber: number;
12
+ indent: number;
13
+ }
14
+
15
+ // Common finding payload before the module applies shared security defaults.
16
+ interface WorkflowFindingInput {
17
+ ruleId: string;
18
+ message: string;
19
+ line: number;
20
+ symbol?: string;
21
+ remediation: string;
22
+ metadata: Record<string, unknown>;
23
+ }
24
+
25
+ // Shared indentation state for YAML block scans; deleting `indent` exits the current block.
26
+ interface IndentedBlockState {
27
+ indent?: number;
28
+ }
29
+
30
+ // Active `run: |` / `run: >` block scalar. The start line is the finding anchor, while `lines`
31
+ // holds the shell body after YAML indentation has been stripped by `workflowLines`.
32
+ interface RunBlockState {
33
+ indent?: number;
34
+ startLine?: WorkflowLine;
35
+ lines: string[];
36
+ }
37
+
38
+ // Shell command candidate extracted from a workflow `run` step with the line used for reporting.
39
+ interface WorkflowCommand {
40
+ command: string;
41
+ line: WorkflowLine;
42
+ }
43
+
44
+ const WRITE_PERMISSION_SCOPES = new Set([
45
+ "actions",
46
+ "checks",
47
+ "contents",
48
+ "deployments",
49
+ "issues",
50
+ "packages",
51
+ "pull-requests",
52
+ "repository-projects",
53
+ "security-events",
54
+ "statuses",
55
+ ]);
56
+
57
+ // Stable rule contract: workflow-only checks ignore non-workflow YAML so example docs avoid findings.
58
+ function analyseGithubActionsRules(file: SourceFile, source: string, findings: Finding[]): void {
59
+ if (!isGithubWorkflowPath(file.displayPath)) {
60
+ return;
61
+ }
62
+ const lines = workflowLines(source);
63
+ analysePullRequestTarget(file, lines, findings);
64
+ analyseBroadPermissions(file, lines, findings);
65
+ analyseUnpinnedActions(file, lines, findings);
66
+ analyseRemoteShell(file, lines, findings);
67
+ analyseSecretsInPullRequest(file, lines, findings);
68
+ }
69
+
70
+ // Matches committed GitHub Actions workflow locations and nothing under docs or examples.
71
+ function isGithubWorkflowPath(displayPath: string): boolean {
72
+ return /^\.github\/workflows\/[^/]+\.ya?ml$/i.test(displayPath);
73
+ }
74
+
75
+ // Normalizes raw YAML lines into a small record while preserving indentation for block-state scans.
76
+ function workflowLines(source: string): WorkflowLine[] {
77
+ return source.split(/\r?\n/).map((raw, index) => {
78
+ const text = stripInlineComment(raw);
79
+ const trimmed = text.trim();
80
+ return { raw, text, trimmed, lineNumber: index + 1, indent: leadingSpaceCount(raw) };
81
+ });
82
+ }
83
+
84
+ // Removes common YAML inline comments without attempting to parse quoted scalars.
85
+ function stripInlineComment(line: string): string {
86
+ const comment = line.indexOf(" #");
87
+ return comment === -1 ? line : line.slice(0, comment);
88
+ }
89
+
90
+ // Counts spaces only; workflow YAML should not rely on tabs for indentation.
91
+ function leadingSpaceCount(line: string): number {
92
+ const match = line.match(/^ */);
93
+ return match?.[0].length ?? 0;
94
+ }
95
+
96
+ // Stable finding contract: reports pull_request_target only when trusted-context risk is visible nearby.
97
+ function analysePullRequestTarget(file: SourceFile, lines: readonly WorkflowLine[], findings: Finding[]): void {
98
+ const eventLine = lines.find((line) => hasPullRequestTargetEvent(line));
99
+ if (!eventLine || !hasPullRequestTargetRiskContext(lines)) {
100
+ return;
101
+ }
102
+ findings.push(
103
+ workflowFinding(file, {
104
+ ruleId: "security.github-actions-pull-request-target",
105
+ message: "`pull_request_target` is paired with workflow behavior that can execute or expose trusted context.",
106
+ line: eventLine.lineNumber,
107
+ symbol: "pull_request_target",
108
+ remediation: "Use `pull_request` for untrusted code, or isolate checkout, secrets, and write permissions behind explicit trust checks.",
109
+ metadata: { event: "pull_request_target", riskContext: pullRequestTargetRiskContext(lines) },
110
+ }),
111
+ );
112
+ }
113
+
114
+ // Detects the event token in compact or expanded YAML event forms.
115
+ function hasPullRequestTargetEvent(line: WorkflowLine): boolean {
116
+ return !isCommentOrBlank(line) && /\bpull_request_target\b/.test(line.trimmed);
117
+ }
118
+
119
+ // Separates the boolean gate from metadata collection so the event-alone case stays quiet.
120
+ function hasPullRequestTargetRiskContext(lines: readonly WorkflowLine[]): boolean {
121
+ return pullRequestTargetRiskContext(lines).length > 0;
122
+ }
123
+
124
+ // Collects stable risk labels used in metadata and tests.
125
+ function pullRequestTargetRiskContext(lines: readonly WorkflowLine[]): string[] {
126
+ const contexts = new Set<string>();
127
+ for (const line of lines) {
128
+ if (isCommentOrBlank(line)) {
129
+ continue;
130
+ }
131
+ if (/^(?:-\s*)?uses:\s*["']?actions\/checkout@/i.test(line.trimmed)) {
132
+ contexts.add("checkout");
133
+ }
134
+ if (/^(?:-\s*)?run:\s*/i.test(line.trimmed)) {
135
+ contexts.add("run");
136
+ }
137
+ if (/\bsecrets\./.test(line.trimmed)) {
138
+ contexts.add("secrets");
139
+ }
140
+ if (broadPermissionSignal(line)) {
141
+ contexts.add("write-permissions");
142
+ }
143
+ }
144
+ return [...contexts].sort();
145
+ }
146
+
147
+ // Stable permissions contract: helpers keep YAML block state explicit because nested scopes affect anchors.
148
+ function analyseBroadPermissions(file: SourceFile, lines: readonly WorkflowLine[], findings: Finding[]): void {
149
+ const state: IndentedBlockState = {};
150
+ for (const line of lines) {
151
+ const scope = broadPermissionScope(line, state);
152
+ if (scope) {
153
+ pushBroadPermissionFinding(file, findings, line, scope);
154
+ }
155
+ }
156
+ }
157
+
158
+ // Reports one finding per broad permission scope; stable scope symbols keep remediation targeted.
159
+ function pushBroadPermissionFinding(file: SourceFile, findings: Finding[], line: WorkflowLine, scope: string): void {
160
+ findings.push(
161
+ workflowFinding(file, {
162
+ ruleId: "security.github-actions-broad-permissions",
163
+ message: `Workflow grants broad write permission \`${scope}\`.`,
164
+ line: line.lineNumber,
165
+ symbol: scope,
166
+ remediation: "Reduce workflow permissions to read-only by default and grant write scopes only to trusted jobs.",
167
+ metadata: { permission: scope },
168
+ }),
169
+ );
170
+ }
171
+
172
+ // Returns the broad permission scope for either inline permissions or an active permissions block.
173
+ function broadPermissionScope(line: WorkflowLine, state: IndentedBlockState): string | undefined {
174
+ if (isCommentOrBlank(line)) {
175
+ return undefined;
176
+ }
177
+ closeBlockWhenOutdented(state, line);
178
+ const inline = line.trimmed.match(/^permissions:\s*(write-all)\b/i);
179
+ if (inline?.[1]) {
180
+ return "write-all";
181
+ }
182
+ if (/^permissions:\s*$/i.test(line.trimmed)) {
183
+ state.indent = line.indent;
184
+ return undefined;
185
+ }
186
+ if (state.indent === undefined || line.indent <= state.indent) {
187
+ return undefined;
188
+ }
189
+ return scopedWritePermission(line);
190
+ }
191
+
192
+ // Clears YAML block state once the scanner reaches a sibling or parent indentation level.
193
+ function closeBlockWhenOutdented(state: IndentedBlockState, line: WorkflowLine): void {
194
+ if (state.indent !== undefined && line.indent <= state.indent) {
195
+ delete state.indent;
196
+ }
197
+ }
198
+
199
+ // Extracts selected write scopes that are broad enough to matter for workflow security.
200
+ function scopedWritePermission(line: WorkflowLine): string | undefined {
201
+ const scoped = line.trimmed.match(/^([a-z-]+):\s*write\b/i);
202
+ const scope = scoped?.[1] ?? "";
203
+ return WRITE_PERMISSION_SCOPES.has(scope) ? scope : undefined;
204
+ }
205
+
206
+ // Lightweight permission signal used by the pull_request_target risk-context gate.
207
+ function broadPermissionSignal(line: WorkflowLine): boolean {
208
+ if (/^permissions:\s*write-all\b/i.test(line.trimmed)) {
209
+ return true;
210
+ }
211
+ const scoped = line.trimmed.match(/^([a-z-]+):\s*write\b/i);
212
+ return WRITE_PERMISSION_SCOPES.has(scoped?.[1] ?? "");
213
+ }
214
+
215
+ // Stable supply-chain contract: reports third-party action refs unless pinned to a full commit SHA.
216
+ function analyseUnpinnedActions(file: SourceFile, lines: readonly WorkflowLine[], findings: Finding[]): void {
217
+ for (const line of lines) {
218
+ const action = thirdPartyActionUse(line);
219
+ if (!action || isPinnedToFullSha(action.ref)) {
220
+ continue;
221
+ }
222
+ findings.push(
223
+ workflowFinding(file, {
224
+ ruleId: "security.github-actions-unpinned-action",
225
+ message: `Third-party action \`${action.action}\` is not pinned to a full commit SHA.`,
226
+ line: line.lineNumber,
227
+ symbol: action.action,
228
+ remediation: "Pin third-party actions to a reviewed 40-character commit SHA and update them deliberately.",
229
+ metadata: { action: action.action, owner: action.owner, ref: action.ref },
230
+ }),
231
+ );
232
+ }
233
+ }
234
+
235
+ // Parses `uses:` entries while exempting GitHub-owned and local reusable actions.
236
+ function thirdPartyActionUse(line: WorkflowLine): { action: string; owner: string; ref: string } | undefined {
237
+ if (isCommentOrBlank(line)) {
238
+ return undefined;
239
+ }
240
+ const match = line.trimmed.match(/^(?:-\s*)?uses:\s*["']?([^@\s"'#]+)@([^ \t"'#]+)/i);
241
+ const action = match?.[1] ?? "";
242
+ const ref = match?.[2] ?? "";
243
+ if (!action.includes("/") || action.startsWith("./")) {
244
+ return undefined;
245
+ }
246
+ const owner = action.split("/")[0] ?? "";
247
+ if (owner === "actions" || owner === "github") {
248
+ return undefined;
249
+ }
250
+ return { action, owner, ref };
251
+ }
252
+
253
+ // Full 40-character SHAs are the stable pinning target for third-party actions.
254
+ function isPinnedToFullSha(ref: string): boolean {
255
+ return /^[0-9a-f]{40}$/i.test(ref);
256
+ }
257
+
258
+ // Stable run-step contract: helpers separate YAML block state from shell matching to preserve anchors.
259
+ function analyseRemoteShell(file: SourceFile, lines: readonly WorkflowLine[], findings: Finding[]): void {
260
+ for (const command of runCommands(lines)) {
261
+ if (isRemoteShellCommand(command.command)) {
262
+ pushRemoteShellFinding(file, findings, command.line);
263
+ }
264
+ }
265
+ }
266
+
267
+ // Reports workflow-specific remote-shell findings at the risky command line for stable anchors.
268
+ function pushRemoteShellFinding(file: SourceFile, findings: Finding[], line: WorkflowLine): void {
269
+ findings.push(
270
+ workflowFinding(file, {
271
+ ruleId: "security.github-actions-remote-shell",
272
+ message: "Workflow downloads remote content and pipes it to a shell.",
273
+ line: line.lineNumber,
274
+ symbol: "run",
275
+ remediation: "Vendor the installer, pin an audited action, or verify downloaded content before execution.",
276
+ metadata: { command: "remote-shell" },
277
+ }),
278
+ );
279
+ }
280
+
281
+ // Extracts both inline `run: command` values and multiline block-scalar bodies. The returned order
282
+ // follows workflow source order so multiple remote-shell findings remain deterministic.
283
+ function runCommands(lines: readonly WorkflowLine[]): WorkflowCommand[] {
284
+ const commands: WorkflowCommand[] = [];
285
+ const state: RunBlockState = { lines: [] };
286
+ for (const line of lines) {
287
+ flushClosedRunBlock(line, state, commands);
288
+ if (runBlockLine(line, state)) {
289
+ continue;
290
+ }
291
+ startOrPushRunCommand(line, state, commands);
292
+ }
293
+ flushRunBlock(state, commands);
294
+ return commands;
295
+ }
296
+
297
+ // Ends a block scalar when YAML indentation returns to the parent/sibling level. The current line
298
+ // is then processed again by the caller as a possible next command.
299
+ function flushClosedRunBlock(line: WorkflowLine, state: RunBlockState, commands: WorkflowCommand[]): void {
300
+ if (state.indent !== undefined && line.indent <= state.indent) {
301
+ flushRunBlock(state, commands);
302
+ }
303
+ }
304
+
305
+ // Captures a line belonging to the active run block. The normalized trimmed text is enough for the
306
+ // remote-shell heuristic because it only needs command tokens and pipe placement.
307
+ function runBlockLine(line: WorkflowLine, state: RunBlockState): boolean {
308
+ if (state.indent === undefined || line.indent <= state.indent) {
309
+ return false;
310
+ }
311
+ state.lines.push(line.trimmed);
312
+ return true;
313
+ }
314
+
315
+ // Starts a new `run` block or records an inline command. Blank and comment-only lines are ignored
316
+ // here because they cannot carry a workflow step key.
317
+ function startOrPushRunCommand(line: WorkflowLine, state: RunBlockState, commands: WorkflowCommand[]): void {
318
+ if (isCommentOrBlank(line)) {
319
+ return;
320
+ }
321
+ const run = line.trimmed.match(/^(?:-\s*)?run:\s*(.*)$/i);
322
+ if (run?.[1] === undefined) {
323
+ return;
324
+ }
325
+ if (isRunBlockScalar(run[1])) {
326
+ state.indent = line.indent;
327
+ state.startLine = line;
328
+ state.lines = [];
329
+ } else {
330
+ commands.push({ command: run[1], line });
331
+ }
332
+ }
333
+
334
+ // Emits the pending block-scalar command and clears all mutable state. Missing `startLine` is
335
+ // treated as empty state so partially initialized blocks cannot throw.
336
+ function flushRunBlock(state: RunBlockState, commands: WorkflowCommand[]): void {
337
+ if (state.indent !== undefined && state.startLine) {
338
+ commands.push({ command: state.lines.join("\n"), line: state.startLine });
339
+ }
340
+ delete state.indent;
341
+ delete state.startLine;
342
+ state.lines = [];
343
+ }
344
+
345
+ // Recognizes YAML block scalar headers including chomping/indent indicators such as `|-`, `>2`, or `|+4`.
346
+ function isRunBlockScalar(command: string): boolean {
347
+ return /^[|>](?:[+-]?\d*|\d*[+-]?)?\s*$/.test(command);
348
+ }
349
+
350
+ // Mirrors package script remote-installer semantics for curl/wget piped to a shell.
351
+ function isRemoteShellCommand(command: string): boolean {
352
+ return /\b(?:curl|wget)\b[^|]*https?:\/\/[^|]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i.test(command);
353
+ }
354
+
355
+ // Stable secret-exposure contract: reports secrets.NAME only when the workflow is pull-request triggered.
356
+ function analyseSecretsInPullRequest(file: SourceFile, lines: readonly WorkflowLine[], findings: Finding[]): void {
357
+ if (!hasPullRequestStyleEvent(lines)) {
358
+ return;
359
+ }
360
+ for (const line of lines) {
361
+ const secretName = secretReference(line);
362
+ if (!secretName) {
363
+ continue;
364
+ }
365
+ findings.push(
366
+ workflowFinding(file, {
367
+ ruleId: "security.github-actions-secrets-in-pr",
368
+ message: `Pull request workflow references secret \`${secretName}\`.`,
369
+ line: line.lineNumber,
370
+ symbol: secretName,
371
+ remediation: "Avoid exposing secrets to pull request workflows unless the code path is trusted and tightly gated.",
372
+ metadata: { event: "pull_request", secretName },
373
+ }),
374
+ );
375
+ }
376
+ }
377
+
378
+ // Treats pull_request and pull_request_target as PR-style contexts for secret exposure checks.
379
+ function hasPullRequestStyleEvent(lines: readonly WorkflowLine[]): boolean {
380
+ return lines.some((line) => !isCommentOrBlank(line) && /\bpull_request(?:_target)?\b/.test(line.trimmed));
381
+ }
382
+
383
+ // Extracts the secret symbol while keeping the raw expression out of finding metadata.
384
+ function secretReference(line: WorkflowLine): string | undefined {
385
+ if (isCommentOrBlank(line)) {
386
+ return undefined;
387
+ }
388
+ const match = line.trimmed.match(/\bsecrets\.([A-Za-z_][A-Za-z0-9_]*)\b/);
389
+ return match?.[1];
390
+ }
391
+
392
+ // Skips blank/comment-only YAML lines before applying simple text heuristics.
393
+ function isCommentOrBlank(line: WorkflowLine): boolean {
394
+ return line.trimmed.length === 0 || line.trimmed.startsWith("#");
395
+ }
396
+
397
+ // Centralizes workflow finding metadata so every rule emits the same pillar/severity contract.
398
+ function workflowFinding(file: SourceFile, input: WorkflowFindingInput): Finding {
399
+ return makeFinding({
400
+ ruleId: input.ruleId,
401
+ message: input.message,
402
+ filePath: file.displayPath,
403
+ line: input.line,
404
+ severity: "warning",
405
+ pillar: "security",
406
+ confidence: "medium",
407
+ ...(input.symbol ? { symbol: input.symbol } : {}),
408
+ remediation: input.remediation,
409
+ metadata: input.metadata,
410
+ });
411
+ }
412
+
413
+ export { analyseGithubActionsRules };