awguard 1.1.1

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/src/scanner.js ADDED
@@ -0,0 +1,604 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { findingFingerprint } from './fingerprints.js';
4
+
5
+ export const severityRank = {
6
+ none: 0,
7
+ low: 1,
8
+ medium: 2,
9
+ high: 3,
10
+ critical: 4
11
+ };
12
+
13
+ const severityWeight = new Map(Object.entries(severityRank));
14
+
15
+ const workflowExtensions = new Set(['.yml', '.yaml']);
16
+
17
+ const untrustedFieldPattern =
18
+ /\${{\s*github\.(?:event\.[\w.-]+\.)?(?:body|default_branch|email|head_ref|label|message|name|page_name|ref|title)\s*}}|github\.(?:event\.[\w.-]+\.)?(?:body|default_branch|email|head_ref|label|message|name|page_name|ref|title)/i;
19
+
20
+ const agentPattern =
21
+ /\b(aider|anthropic|claude|codex|copilot|cursor|gemini|langchain|litellm|llm|mistral|ollama|openai|openrouter)\b|AI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|OPENAI_API_KEY/i;
22
+
23
+ const promptBoundaryPattern =
24
+ /\b(prompt|system_prompt|user_prompt|instructions|message|messages|input|query|task)\b\s*[:=]/i;
25
+
26
+ const dangerousFlagPattern =
27
+ /--dangerously-skip-permissions|--yolo|--allow-all|--unsafe|--auto-approve|--no-confirm|approval-mode\s+full-auto/i;
28
+
29
+ const commandSinkPattern =
30
+ /\b(eval|bash\s+-c|sh\s+-c|python\s+-c|node\s+-e)\b|\|\s*(?:bash|sh)\b/i;
31
+
32
+ const modelOutputPattern =
33
+ /\b(agent|ai|completion|llm|model|output|patch|plan|response|result)\b/i;
34
+
35
+ const suppressionPattern = /#\s*awguard-disable-(next-line|line)\b(.*)$/i;
36
+
37
+ const untrustedTriggers = new Set([
38
+ 'discussion_comment',
39
+ 'issue_comment',
40
+ 'issues',
41
+ 'pull_request',
42
+ 'pull_request_review',
43
+ 'pull_request_target',
44
+ 'repository_dispatch',
45
+ 'workflow_run'
46
+ ]);
47
+
48
+ export const ruleCatalog = {
49
+ AWG001: {
50
+ title: 'Untrusted text reaches an AI agent prompt',
51
+ severity: 'high',
52
+ suggestion:
53
+ 'Keep issue, PR, comment, and branch text out of privileged agent prompts unless it is reviewed, delimited, and sanitized. Run the agent with read-only permissions by default.'
54
+ },
55
+ AWG002: {
56
+ title: 'Untrusted GitHub context is interpolated in a shell script',
57
+ severity: 'high',
58
+ suggestion:
59
+ 'Move the expression into an env variable and reference the shell variable with quotes, or pass the value to a JavaScript action as an argument.'
60
+ },
61
+ AWG003: {
62
+ title: 'pull_request_target checks out untrusted pull request code',
63
+ severity: 'critical',
64
+ suggestion:
65
+ 'Use pull_request for untrusted builds, or keep pull_request_target limited to base-repository metadata work without checking out head SHA/ref.'
66
+ },
67
+ AWG004: {
68
+ title: 'AI agent workflow has broad token permissions',
69
+ severity: 'high',
70
+ suggestion:
71
+ 'Set permissions to read-all or the smallest write scope required. Add manual approval before any agent can write code, comments, labels, or releases.'
72
+ },
73
+ AWG005: {
74
+ title: 'Secrets are exposed in an untrusted agent workflow',
75
+ severity: 'high',
76
+ suggestion:
77
+ 'Do not provide repository, cloud, or model-provider secrets to workflows driven by untrusted issue/PR/comment text. Split privileged work into a separate approved workflow.'
78
+ },
79
+ AWG006: {
80
+ title: 'Autonomous agent runs with unsafe approval flags',
81
+ severity: 'high',
82
+ suggestion:
83
+ 'Remove full-auto or skip-permission flags in CI. Require a human approval gate before tool use, file writes, command execution, or repository changes.'
84
+ },
85
+ AWG007: {
86
+ title: 'Model or agent output may be executed by a script',
87
+ severity: 'high',
88
+ suggestion:
89
+ 'Treat model output as data. Write it to a file, validate it, and apply narrow parsers instead of eval, bash -c, sh -c, or pipe-to-shell patterns.'
90
+ },
91
+ AWG008: {
92
+ title: 'Agent workflow does not declare permissions',
93
+ severity: 'medium',
94
+ suggestion:
95
+ 'Declare explicit permissions, usually contents: read for analysis workflows. Escalate write scopes only in a separate, reviewed job.'
96
+ },
97
+ AWG009: {
98
+ title: 'workflow_run consumes artifacts before script execution',
99
+ severity: 'medium',
100
+ suggestion:
101
+ 'Treat artifacts from earlier workflows as untrusted. Verify provenance and contents before using them in privileged workflow_run jobs.'
102
+ },
103
+ AWG010: {
104
+ title: 'Third-party action is not pinned to a commit SHA',
105
+ severity: 'low',
106
+ suggestion:
107
+ 'Pin third-party actions to a full commit SHA in security-sensitive agent workflows, and review the action before updating the pin.'
108
+ },
109
+ AWG011: {
110
+ title: 'Invalid suppression comment',
111
+ severity: 'medium',
112
+ suggestion:
113
+ 'Use awguard-disable-next-line or awguard-disable-line with rule ids and a clear reason after --, for example: # awguard-disable-next-line AWG001 -- reviewed false positive.'
114
+ }
115
+ };
116
+
117
+ export function scanWorkflows({ root = process.cwd(), config = {} } = {}) {
118
+ const absoluteRoot = path.resolve(root);
119
+ const relativeBase = fs.statSync(absoluteRoot).isFile() ? path.dirname(absoluteRoot) : absoluteRoot;
120
+ const files = discoverWorkflowFiles(absoluteRoot);
121
+ const findings = files.flatMap((file) => scanWorkflowFile(file, relativeBase, config));
122
+
123
+ findings.sort((a, b) => {
124
+ const severityDelta = severityWeight.get(b.severity) - severityWeight.get(a.severity);
125
+ if (severityDelta !== 0) return severityDelta;
126
+ if (a.file !== b.file) return a.file.localeCompare(b.file);
127
+ return a.line - b.line;
128
+ });
129
+
130
+ return {
131
+ root: relativeBase,
132
+ scannedFiles: files,
133
+ findings,
134
+ summary: summarize(findings)
135
+ };
136
+ }
137
+
138
+ export function scanWorkflowText(text, file = 'workflow.yml', root = process.cwd(), config = {}) {
139
+ const lines = text.split(/\r?\n/);
140
+ const runBlocks = markRunBlocks(lines);
141
+ const triggers = detectTriggers(text, lines);
142
+ const hasUntrustedTrigger = [...triggers].some((trigger) => untrustedTriggers.has(trigger));
143
+ const hasAgent = lines.some((line) => agentPattern.test(line));
144
+ const hasPromptBoundary = lines.some((line) => promptBoundaryPattern.test(line));
145
+ const hasPermissionBlock = /^\s*permissions\s*:/im.test(text);
146
+ const hasBroadPermission = lines.some((line) => isBroadPermissionLine(line));
147
+ const hasSecret = lines.some((line) => /\bsecrets\.[A-Z0-9_]+\b/i.test(line));
148
+ const { suppressions, invalidSuppressions } = collectSuppressions(lines, config.suppressions || {});
149
+
150
+ const context = {
151
+ file,
152
+ relativeFile: path.isAbsolute(file) ? path.relative(root, file) || path.basename(file) : file,
153
+ lines,
154
+ runBlocks,
155
+ triggers,
156
+ hasAgent,
157
+ hasPromptBoundary,
158
+ hasUntrustedTrigger,
159
+ hasPermissionBlock,
160
+ hasBroadPermission,
161
+ hasSecret,
162
+ config,
163
+ suppressions,
164
+ invalidSuppressions,
165
+ suppressedFindings: [],
166
+ findings: [],
167
+ seen: new Set()
168
+ };
169
+
170
+ detectInvalidSuppressions(context);
171
+ detectPromptToAgent(context);
172
+ detectScriptInjection(context);
173
+ detectPullRequestTargetCheckout(context);
174
+ detectBroadPermissions(context);
175
+ detectSecretsInAgentWorkflow(context);
176
+ detectUnsafeAgentFlags(context);
177
+ detectModelOutputSinks(context);
178
+ detectMissingPermissions(context);
179
+ detectWorkflowRunArtifacts(context);
180
+ detectUnpinnedActions(context);
181
+
182
+ return context.findings;
183
+ }
184
+
185
+ function scanWorkflowFile(file, root, config) {
186
+ const text = fs.readFileSync(file, 'utf8');
187
+ return scanWorkflowText(text, file, root, config);
188
+ }
189
+
190
+ function discoverWorkflowFiles(root) {
191
+ if (!fs.existsSync(root)) {
192
+ throw new Error(`path does not exist: ${root}`);
193
+ }
194
+
195
+ const stat = fs.statSync(root);
196
+ if (stat.isFile()) {
197
+ return workflowExtensions.has(path.extname(root)) ? [root] : [];
198
+ }
199
+
200
+ const workflowDir = path.join(root, '.github', 'workflows');
201
+ if (!fs.existsSync(workflowDir)) {
202
+ return [];
203
+ }
204
+
205
+ return walk(workflowDir).filter((file) => workflowExtensions.has(path.extname(file)));
206
+ }
207
+
208
+ function walk(dir) {
209
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
210
+ const fullPath = path.join(dir, entry.name);
211
+ if (entry.isDirectory()) return walk(fullPath);
212
+ if (entry.isFile()) return [fullPath];
213
+ return [];
214
+ });
215
+ }
216
+
217
+ function detectPromptToAgent(context) {
218
+ if (!context.hasAgent && !context.hasPromptBoundary) return;
219
+
220
+ context.lines.forEach((line, index) => {
221
+ if (!untrustedFieldPattern.test(line)) return;
222
+
223
+ const windowText = windowAround(context.lines, index, 8).join('\n');
224
+ if (!agentPattern.test(windowText) && !promptBoundaryPattern.test(windowText)) return;
225
+
226
+ const severity = context.hasBroadPermission || context.hasSecret ? 'critical' : ruleCatalog.AWG001.severity;
227
+ addFinding(context, 'AWG001', index + 1, {
228
+ severity,
229
+ evidence: line.trim(),
230
+ message: 'User-controlled GitHub event text appears to be used as prompt/input for an AI agent.'
231
+ });
232
+ });
233
+ }
234
+
235
+ function detectScriptInjection(context) {
236
+ context.lines.forEach((line, index) => {
237
+ if (!untrustedFieldPattern.test(line)) return;
238
+ if (!context.runBlocks.has(index) && !/^\s*run\s*:/.test(line)) return;
239
+
240
+ addFinding(context, 'AWG002', index + 1, {
241
+ evidence: line.trim(),
242
+ message: 'A user-controlled GitHub expression is interpolated directly inside a run script.'
243
+ });
244
+ });
245
+ }
246
+
247
+ function detectPullRequestTargetCheckout(context) {
248
+ if (!context.triggers.has('pull_request_target')) return;
249
+
250
+ context.lines.forEach((line, index) => {
251
+ if (!/uses\s*:\s*actions\/checkout@/i.test(line)) return;
252
+
253
+ const checkoutBlock = windowAfter(context.lines, index, 8);
254
+ const refIndex = checkoutBlock.findIndex((candidate) =>
255
+ /github\.event\.pull_request\.head|github\.head_ref|head\.sha|head\.ref/i.test(candidate)
256
+ );
257
+
258
+ if (refIndex === -1) return;
259
+
260
+ addFinding(context, 'AWG003', index + refIndex + 1, {
261
+ evidence: checkoutBlock[refIndex].trim(),
262
+ message: 'pull_request_target is checking out pull request head code in a privileged workflow.'
263
+ });
264
+ });
265
+ }
266
+
267
+ function detectBroadPermissions(context) {
268
+ if (!context.hasAgent) return;
269
+
270
+ context.lines.forEach((line, index) => {
271
+ if (!isBroadPermissionLine(line)) return;
272
+
273
+ addFinding(context, 'AWG004', index + 1, {
274
+ evidence: line.trim(),
275
+ message: 'An AI-agent workflow grants write-capable token permissions.'
276
+ });
277
+ });
278
+ }
279
+
280
+ function detectSecretsInAgentWorkflow(context) {
281
+ if (!context.hasAgent || !context.hasUntrustedTrigger) return;
282
+
283
+ context.lines.forEach((line, index) => {
284
+ if (!/\bsecrets\.[A-Z0-9_]+\b/i.test(line)) return;
285
+
286
+ addFinding(context, 'AWG005', index + 1, {
287
+ evidence: line.trim(),
288
+ message: 'A secret is available in a workflow triggered by untrusted event content and using an AI agent.'
289
+ });
290
+ });
291
+ }
292
+
293
+ function detectUnsafeAgentFlags(context) {
294
+ context.lines.forEach((line, index) => {
295
+ if (!dangerousFlagPattern.test(line)) return;
296
+
297
+ const windowText = windowAround(context.lines, index, 6).join('\n');
298
+ const severity = agentPattern.test(windowText) ? ruleCatalog.AWG006.severity : 'medium';
299
+
300
+ addFinding(context, 'AWG006', index + 1, {
301
+ severity,
302
+ evidence: line.trim(),
303
+ message: 'The workflow appears to run an agent with permission checks or confirmations disabled.'
304
+ });
305
+ });
306
+ }
307
+
308
+ function detectModelOutputSinks(context) {
309
+ if (!context.hasAgent) return;
310
+
311
+ context.lines.forEach((line, index) => {
312
+ if (!commandSinkPattern.test(line) || !modelOutputPattern.test(line)) return;
313
+
314
+ addFinding(context, 'AWG007', index + 1, {
315
+ evidence: line.trim(),
316
+ message: 'A command sink appears to execute data named like model or agent output.'
317
+ });
318
+ });
319
+ }
320
+
321
+ function detectMissingPermissions(context) {
322
+ if (!context.hasAgent || context.hasPermissionBlock) return;
323
+
324
+ const firstAgentLine = context.lines.findIndex((line) => agentPattern.test(line));
325
+ if (firstAgentLine === -1) return;
326
+
327
+ addFinding(context, 'AWG008', firstAgentLine + 1, {
328
+ evidence: context.lines[firstAgentLine].trim(),
329
+ message: 'This agent workflow does not declare an explicit GitHub token permission block.'
330
+ });
331
+ }
332
+
333
+ function detectWorkflowRunArtifacts(context) {
334
+ if (!context.triggers.has('workflow_run')) return;
335
+ if (!/actions\/download-artifact@|download-artifact/i.test(context.lines.join('\n'))) return;
336
+
337
+ const firstRunLine = context.lines.findIndex((line) => /^\s*run\s*:/.test(line));
338
+ if (firstRunLine === -1) return;
339
+
340
+ addFinding(context, 'AWG009', firstRunLine + 1, {
341
+ evidence: context.lines[firstRunLine].trim(),
342
+ message: 'A privileged workflow_run job downloads artifacts before executing script steps.'
343
+ });
344
+ }
345
+
346
+ function detectUnpinnedActions(context) {
347
+ if (!context.hasAgent) return;
348
+
349
+ context.lines.forEach((line, index) => {
350
+ const match = line.match(/\buses\s*:\s*([^@\s#]+)@([^\s#]+)/i);
351
+ if (!match) return;
352
+
353
+ const actionName = match[1];
354
+ const ref = match[2].replace(/^['"]|['"]$/g, '');
355
+ if (/^[a-f0-9]{40}$/i.test(ref)) return;
356
+ if (/^(actions|github)\//i.test(actionName)) return;
357
+
358
+ addFinding(context, 'AWG010', index + 1, {
359
+ evidence: line.trim(),
360
+ message: 'A third-party action in an agent workflow is referenced by a mutable tag or branch.'
361
+ });
362
+ });
363
+ }
364
+
365
+ function detectInvalidSuppressions(context) {
366
+ context.invalidSuppressions.forEach((suppression) => {
367
+ addFinding(context, 'AWG011', suppression.line, {
368
+ evidence: suppression.evidence,
369
+ message: suppression.message
370
+ });
371
+ });
372
+ }
373
+
374
+ function addFinding(context, ruleId, line, overrides = {}) {
375
+ const docs = ruleCatalog[ruleId];
376
+ const ruleConfig = context.config.rules?.[ruleId];
377
+ if (ruleConfig?.enabled === false) return;
378
+
379
+ const key = `${ruleId}:${line}:${overrides.evidence || ''}`;
380
+ if (context.seen.has(key)) return;
381
+ context.seen.add(key);
382
+
383
+ const finding = {
384
+ ruleId,
385
+ title: docs.title,
386
+ severity: ruleConfig?.severity || overrides.severity || docs.severity,
387
+ file: context.relativeFile,
388
+ absoluteFile: context.file,
389
+ line,
390
+ message: overrides.message || docs.title,
391
+ evidence: overrides.evidence || '',
392
+ suggestion: overrides.suggestion || docs.suggestion
393
+ };
394
+
395
+ const suppression = ruleId === 'AWG011' ? null : findSuppression(context, ruleId, line);
396
+ if (suppression) {
397
+ context.suppressedFindings.push({
398
+ ...finding,
399
+ suppressionReason: suppression.reason
400
+ });
401
+ return;
402
+ }
403
+
404
+ context.findings.push({
405
+ ...finding,
406
+ fingerprint: findingFingerprint(finding),
407
+ baselineState: 'new'
408
+ });
409
+ }
410
+
411
+ function collectSuppressions(lines, rawSuppressionConfig = {}) {
412
+ const suppressionConfig = {
413
+ allow: rawSuppressionConfig.allow !== false,
414
+ allowedRules: rawSuppressionConfig.allowedRules || [],
415
+ minimumReasonLength: rawSuppressionConfig.minimumReasonLength || 10
416
+ };
417
+ const suppressions = new Map();
418
+ const invalidSuppressions = [];
419
+
420
+ lines.forEach((line, index) => {
421
+ const match = line.match(suppressionPattern);
422
+ if (!match) return;
423
+
424
+ const parsed = parseSuppression(match, line, index + 1, suppressionConfig);
425
+ if (!parsed.valid) {
426
+ invalidSuppressions.push(parsed);
427
+ return;
428
+ }
429
+
430
+ const existing = suppressions.get(parsed.targetLine) || [];
431
+ existing.push(parsed);
432
+ suppressions.set(parsed.targetLine, existing);
433
+ });
434
+
435
+ return { suppressions, invalidSuppressions };
436
+ }
437
+
438
+ function parseSuppression(match, line, lineNumber, suppressionConfig) {
439
+ const mode = match[1].toLowerCase();
440
+ const rest = match[2].trim();
441
+ const separatorIndex = rest.indexOf('--');
442
+ const ruleText = separatorIndex === -1 ? rest : rest.slice(0, separatorIndex).trim();
443
+ const reason = separatorIndex === -1 ? '' : rest.slice(separatorIndex + 2).trim();
444
+ const rules = ruleText ? ruleText.split(/[,\s]+/).filter(Boolean).map((rule) => rule.toUpperCase()) : ['*'];
445
+ const targetLine = mode === 'next-line' ? lineNumber + 1 : lineNumber;
446
+ const evidence = line.trim();
447
+
448
+ if (suppressionConfig.allow === false) {
449
+ return {
450
+ valid: false,
451
+ line: lineNumber,
452
+ evidence,
453
+ message: 'Suppression comments are disabled by configuration.'
454
+ };
455
+ }
456
+
457
+ if (!reason || reason.length < suppressionConfig.minimumReasonLength) {
458
+ return {
459
+ valid: false,
460
+ line: lineNumber,
461
+ evidence,
462
+ message: 'Suppression comments must include a clear justification after --.'
463
+ };
464
+ }
465
+
466
+ const invalidRule = rules.find((rule) => rule !== '*' && !ruleCatalog[rule]);
467
+ if (invalidRule) {
468
+ return {
469
+ valid: false,
470
+ line: lineNumber,
471
+ evidence,
472
+ message: `Suppression references unknown rule id: ${invalidRule}.`
473
+ };
474
+ }
475
+
476
+ if (suppressionConfig.allowedRules?.length > 0) {
477
+ if (rules.includes('*')) {
478
+ return {
479
+ valid: false,
480
+ line: lineNumber,
481
+ evidence,
482
+ message: 'Wildcard suppression is not allowed by configuration.'
483
+ };
484
+ }
485
+
486
+ const disallowedRule = rules.find((rule) => !suppressionConfig.allowedRules.includes(rule));
487
+ if (disallowedRule) {
488
+ return {
489
+ valid: false,
490
+ line: lineNumber,
491
+ evidence,
492
+ message: `Suppression for ${disallowedRule} is not allowed by configuration.`
493
+ };
494
+ }
495
+ }
496
+
497
+ return {
498
+ valid: true,
499
+ line: lineNumber,
500
+ targetLine,
501
+ rules,
502
+ reason,
503
+ evidence
504
+ };
505
+ }
506
+
507
+ function findSuppression(context, ruleId, line) {
508
+ const candidates = context.suppressions.get(line) || [];
509
+ return candidates.find((suppression) => suppression.rules.includes('*') || suppression.rules.includes(ruleId));
510
+ }
511
+
512
+ function summarize(findings) {
513
+ const bySeverity = Object.fromEntries(Object.keys(severityRank).map((severity) => [severity, 0]));
514
+ for (const finding of findings) {
515
+ bySeverity[finding.severity] += 1;
516
+ }
517
+
518
+ const highest = findings.reduce((current, finding) => {
519
+ return severityRank[finding.severity] > severityRank[current] ? finding.severity : current;
520
+ }, 'none');
521
+
522
+ return {
523
+ total: findings.length,
524
+ highest,
525
+ bySeverity
526
+ };
527
+ }
528
+
529
+ function detectTriggers(text, lines) {
530
+ const triggers = new Set();
531
+ const triggerNames = [...untrustedTriggers, 'push', 'schedule', 'workflow_dispatch'];
532
+
533
+ for (const trigger of triggerNames) {
534
+ const keyPattern = new RegExp(`^\\s*${escapeRegex(trigger)}\\s*:`, 'im');
535
+ const arrayPattern = new RegExp(`\\bon\\s*:\\s*\\[[^\\]]*\\b${escapeRegex(trigger)}\\b`, 'i');
536
+ const scalarPattern = new RegExp(`\\bon\\s*:\\s*${escapeRegex(trigger)}\\b`, 'i');
537
+
538
+ if (keyPattern.test(text) || arrayPattern.test(text) || scalarPattern.test(text)) {
539
+ triggers.add(trigger);
540
+ }
541
+ }
542
+
543
+ lines.forEach((line) => {
544
+ const trimmed = line.trim();
545
+ const match = trimmed.match(/^-\s*([\w-]+)\s*$/);
546
+ if (match && triggerNames.includes(match[1])) {
547
+ triggers.add(match[1]);
548
+ }
549
+ });
550
+
551
+ return triggers;
552
+ }
553
+
554
+ function markRunBlocks(lines) {
555
+ const runBlocks = new Set();
556
+ let activeIndent = null;
557
+
558
+ lines.forEach((line, index) => {
559
+ const indent = leadingSpaces(line);
560
+ const startsRun = /^\s*run\s*:/.test(line);
561
+
562
+ if (startsRun) {
563
+ runBlocks.add(index);
564
+ activeIndent = /run\s*:\s*[|>]\s*$/.test(line) ? indent : null;
565
+ return;
566
+ }
567
+
568
+ if (activeIndent !== null) {
569
+ if (line.trim() === '' || indent > activeIndent) {
570
+ runBlocks.add(index);
571
+ return;
572
+ }
573
+ activeIndent = null;
574
+ }
575
+ });
576
+
577
+ return runBlocks;
578
+ }
579
+
580
+ function isBroadPermissionLine(line) {
581
+ return (
582
+ /^\s*permissions\s*:\s*write-all\s*(?:#.*)?$/i.test(line) ||
583
+ /^\s*(actions|checks|contents|deployments|discussions|id-token|issues|packages|pages|pull-requests|repository-projects|security-events|statuses)\s*:\s*write\s*(?:#.*)?$/i.test(
584
+ line
585
+ )
586
+ );
587
+ }
588
+
589
+ function windowAround(lines, index, radius) {
590
+ return lines.slice(Math.max(0, index - radius), Math.min(lines.length, index + radius + 1));
591
+ }
592
+
593
+ function windowAfter(lines, index, count) {
594
+ return lines.slice(index, Math.min(lines.length, index + count + 1));
595
+ }
596
+
597
+ function leadingSpaces(line) {
598
+ const match = line.match(/^\s*/);
599
+ return match ? match[0].length : 0;
600
+ }
601
+
602
+ function escapeRegex(value) {
603
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
604
+ }