awguard 1.6.0 → 1.7.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 (60) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/Dockerfile +8 -1
  3. package/README.md +176 -12
  4. package/action.yml +5 -1
  5. package/docs/comparison.md +161 -16
  6. package/docs/launch-plan.md +12 -2
  7. package/docs/marketplace-listing.md +19 -0
  8. package/docs/npm-publishing.md +68 -0
  9. package/docs/release-checklist.md +71 -0
  10. package/docs/report-gallery.md +166 -0
  11. package/docs/roadmap.md +32 -2
  12. package/docs/rule-authoring.md +99 -0
  13. package/docs/schemas.md +16 -0
  14. package/docs/setup-recipes.md +199 -0
  15. package/docs/site/index.html +29 -0
  16. package/examples/.vscode/tasks.json +17 -1
  17. package/examples/README.md +7 -0
  18. package/examples/awguard.config.example.json +8 -0
  19. package/examples/corpus/.cursor/rules/autonomy.mdc +3 -0
  20. package/examples/corpus/.github/prompts/auto-fix.prompt.md +3 -0
  21. package/examples/corpus/.github/workflows/agentic-pr-review.yml +20 -0
  22. package/examples/corpus/.github/workflows/pull-request-target-head.yml +13 -0
  23. package/examples/corpus/.mcp.json +15 -0
  24. package/examples/corpus/AGENTS.md +5 -0
  25. package/examples/corpus/README.md +23 -0
  26. package/examples/dashboard/README.md +55 -0
  27. package/examples/dashboard/index.html +313 -0
  28. package/examples/dashboard/sample-history.json +53 -0
  29. package/examples/lab/README.md +6 -0
  30. package/examples/pr-comment-bot.yml +43 -0
  31. package/examples/pull-request-target.yml +1 -1
  32. package/examples/safe-agent.yml +1 -1
  33. package/examples/unsafe-agent.yml +1 -1
  34. package/examples/vscode-extension/README.md +49 -0
  35. package/examples/vscode-extension/assets/problems-panel.svg +23 -0
  36. package/examples/vscode-extension/package.json +68 -0
  37. package/examples/vscode-extension/src/extension.js +116 -0
  38. package/package.json +2 -1
  39. package/schemas/awguard.badge.schema.json +25 -0
  40. package/schemas/awguard.baseline.schema.json +40 -0
  41. package/schemas/awguard.comparison.schema.json +146 -0
  42. package/schemas/awguard.config.schema.json +167 -0
  43. package/schemas/awguard.inventory.schema.json +124 -0
  44. package/schemas/awguard.report.schema.json +121 -0
  45. package/src/autofix.js +201 -0
  46. package/src/badges.js +63 -0
  47. package/src/baseline.js +77 -0
  48. package/src/cli.js +248 -6
  49. package/src/compare.js +60 -4
  50. package/src/config.js +31 -2
  51. package/src/demo.js +90 -0
  52. package/src/doctor.js +189 -0
  53. package/src/explain.js +147 -0
  54. package/src/init.js +4 -1
  55. package/src/policy-packs.js +99 -0
  56. package/src/policy-wizard.js +165 -0
  57. package/src/remediation.js +73 -1
  58. package/src/reporters.js +86 -3
  59. package/src/scanner.js +204 -5
  60. package/src/templates.js +132 -0
@@ -73,10 +73,30 @@ const fixCatalog = {
73
73
  'Review the new agentic surface before approving it in policy.',
74
74
  'Add reviewed files to policy.approvedFiles and reviewed MCP tools to the MCP policy allowlists.',
75
75
  'Remove or quarantine unapproved workflows, agent instructions, prompts, skills, and MCP configs.'
76
+ ],
77
+ AWG016: [
78
+ 'Set actions/checkout persist-credentials: false in agent jobs.',
79
+ 'Use read-only permissions for analysis jobs.',
80
+ 'Move writeback to a separate reviewed job with an explicit branch or pull request boundary.'
81
+ ],
82
+ AWG017: [
83
+ 'Push agent changes to a short-lived branch instead of main.',
84
+ 'Open a draft pull request for maintainer review.',
85
+ 'Use artifacts for generated patches when the workflow should not write to the repository.'
86
+ ],
87
+ AWG018: [
88
+ 'Move untrusted event text into a reviewed input file before MCP tool use.',
89
+ 'Sanitize issue, PR, comment, branch, and workflow_dispatch input text before passing it to MCP tools.',
90
+ 'Keep MCP tool calls read-only unless a maintainer approves the request.'
91
+ ],
92
+ AWG019: [
93
+ 'Review MCP package publisher, repository, release cadence, and install scripts before approval.',
94
+ 'Add trusted package scopes to policy.approvedMcpPackageScopes after review.',
95
+ 'Prefer pinned packages from reviewed organizations or local audited servers.'
76
96
  ]
77
97
  };
78
98
 
79
- export function renderFixDryRun(result) {
99
+ export function renderFixDryRun(result, { autofixPlan = null } = {}) {
80
100
  const lines = ['Agentic Workflow Guard Fix Dry Run', ''];
81
101
 
82
102
  if (result.findings.length === 0) {
@@ -105,6 +125,15 @@ export function renderFixDryRun(result) {
105
125
  lines.push('');
106
126
  }
107
127
 
128
+ if (autofixPlan?.changes > 0) {
129
+ lines.push('Autofix plan:');
130
+ for (const file of autofixPlan.files) {
131
+ lines.push(`- ${file.file}: ${file.changes.length} safe change(s) available`);
132
+ }
133
+ lines.push('Apply these narrow workflow hardening edits with `awguard --fix` and review git diff before committing.');
134
+ lines.push('');
135
+ }
136
+
108
137
  return lines.join('\n');
109
138
  }
110
139
 
@@ -194,11 +223,54 @@ run: |
194
223
  "approvedFiles": ["AGENTS.md", ".github/workflows/*", ".github/agents/*"],
195
224
  "approvedMcpServers": ["github"],
196
225
  "approvedMcpPackages": ["@modelcontextprotocol/server-github@1.2.3"],
226
+ "approvedMcpPackageScopes": ["@modelcontextprotocol/"],
197
227
  "approvedMcpCommands": ["npx", "node"]
198
228
  }
199
229
  }`
200
230
  };
201
231
  }
202
232
 
233
+ if (finding.ruleId === 'AWG016') {
234
+ return {
235
+ language: 'yaml',
236
+ text: `- uses: actions/checkout@v6
237
+ with:
238
+ persist-credentials: false`
239
+ };
240
+ }
241
+
242
+ if (finding.ruleId === 'AWG017') {
243
+ return {
244
+ language: 'yaml',
245
+ text: `run: |
246
+ git switch -c awguard/agent-output
247
+ git commit -am "Propose agent changes"
248
+ git push origin HEAD:awguard/agent-output
249
+ gh pr create --draft --fill`
250
+ };
251
+ }
252
+
253
+ if (finding.ruleId === 'AWG018') {
254
+ return {
255
+ language: 'yaml',
256
+ text: `env:
257
+ UNTRUSTED_TEXT: \${{ github.event.comment.body }}
258
+ run: |
259
+ printf '%s\\n' "$UNTRUSTED_TEXT" > untrusted-input.txt
260
+ codex mcp run github --input reviewed-request.json`
261
+ };
262
+ }
263
+
264
+ if (finding.ruleId === 'AWG019') {
265
+ return {
266
+ language: 'json',
267
+ text: `{
268
+ "policy": {
269
+ "approvedMcpPackageScopes": ["@modelcontextprotocol/"]
270
+ }
271
+ }`
272
+ };
273
+ }
274
+
203
275
  return null;
204
276
  }
package/src/reporters.js CHANGED
@@ -61,8 +61,10 @@ export function renderJson(result) {
61
61
  severity: finding.severity,
62
62
  file: finding.file,
63
63
  line: finding.line,
64
+ column: finding.column,
64
65
  message: finding.message,
65
66
  evidence: finding.evidence,
67
+ remediationCode: finding.remediationCode,
66
68
  suggestion: finding.suggestion,
67
69
  fingerprint: finding.fingerprint,
68
70
  baselineState: finding.baselineState
@@ -88,6 +90,7 @@ export function renderSarif(result) {
88
90
  rules: Object.entries(ruleCatalog).map(([id, rule]) => ({
89
91
  id,
90
92
  name: id,
93
+ helpUri: 'https://github.com/Mughal-Baig/agentic-workflow-guard#rule-reference',
91
94
  shortDescription: {
92
95
  text: rule.title
93
96
  },
@@ -101,8 +104,10 @@ export function renderSarif(result) {
101
104
  level: sarifSeverity[rule.severity].level
102
105
  },
103
106
  properties: {
104
- tags: ['security', 'github-actions', 'ai-agent', 'prompt-injection', 'mcp'],
107
+ tags: ruleTags(id),
105
108
  precision: 'medium',
109
+ 'awguard.ruleId': id,
110
+ 'awguard.category': ruleCategory(id),
106
111
  'problem.severity': sarifSeverity[rule.severity].level,
107
112
  'security-severity': sarifSeverity[rule.severity].score
108
113
  }
@@ -122,17 +127,28 @@ export function renderSarif(result) {
122
127
  uri: toSarifUri(finding.file)
123
128
  },
124
129
  region: {
125
- startLine: finding.line
130
+ startLine: finding.line,
131
+ startColumn: finding.column || 1,
132
+ snippet: finding.evidence
133
+ ? {
134
+ text: finding.evidence
135
+ }
136
+ : undefined
126
137
  }
127
138
  }
128
139
  }
129
140
  ],
130
141
  partialFingerprints: {
131
- primaryLocationLineHash: finding.fingerprint || findingFingerprint(finding)
142
+ primaryLocationLineHash: finding.fingerprint || findingFingerprint(finding),
143
+ awguardStableFindingId: finding.fingerprint || findingFingerprint(finding)
144
+ },
145
+ fingerprints: {
146
+ 'awguard/v1': finding.fingerprint || findingFingerprint(finding)
132
147
  },
133
148
  properties: {
134
149
  severity: finding.severity,
135
150
  baselineState: finding.baselineState,
151
+ remediationCode: finding.remediationCode,
136
152
  evidence: finding.evidence
137
153
  }
138
154
  }))
@@ -222,6 +238,73 @@ export function renderGithubAnnotations(result) {
222
238
  return result.findings.map((finding) => formatAnnotation(finding)).join('\n');
223
239
  }
224
240
 
241
+ export function renderGithubStepSummary(result, { format = 'github', failOn = 'high', outputFile = '' } = {}) {
242
+ const lines = [
243
+ '## Agentic Workflow Guard',
244
+ '',
245
+ '| Metric | Value |',
246
+ '| --- | --- |',
247
+ `| Scanned files | ${result.scannedFiles.length} |`,
248
+ `| Findings | ${result.summary.total} |`,
249
+ `| Highest severity | ${escapeMarkdown(result.summary.highest)} |`,
250
+ `| Output format | ${escapeMarkdown(format)} |`,
251
+ `| Fail threshold | ${escapeMarkdown(failOn)} |`
252
+ ];
253
+
254
+ if (result.summary.baseline) {
255
+ lines.push(`| Baseline | ${result.summary.baseline.new} new, ${result.summary.baseline.known} known |`);
256
+ }
257
+
258
+ if (outputFile) {
259
+ lines.push(`| Report file | \`${escapeMarkdown(outputFile)}\` |`);
260
+ }
261
+
262
+ lines.push('');
263
+
264
+ if (result.scannedFiles.length === 0) {
265
+ lines.push('No GitHub Actions workflow, agent instruction, or MCP config files were found.');
266
+ } else if (result.findings.length === 0) {
267
+ lines.push('No findings. The scanned agentic surfaces are clean for the enabled rules.');
268
+ } else {
269
+ lines.push('### Top Findings', '');
270
+ lines.push('| Severity | Rule | Location | Finding |');
271
+ lines.push('| --- | --- | --- | --- |');
272
+ for (const finding of result.findings.slice(0, 10)) {
273
+ lines.push(
274
+ `| ${escapeMarkdown(finding.severity)} | ${escapeMarkdown(finding.ruleId)} | \`${escapeMarkdown(
275
+ `${finding.file}:${finding.line}`
276
+ )}\` | ${escapeMarkdown(finding.title)} |`
277
+ );
278
+ }
279
+ if (result.findings.length > 10) {
280
+ lines.push('', `Showing 10 of ${result.findings.length} findings.`);
281
+ }
282
+ }
283
+
284
+ lines.push(
285
+ '',
286
+ '### Useful Follow-Ups',
287
+ '',
288
+ '- Run `npx awguard@latest . --format inventory` to map agentic surfaces.',
289
+ '- Run `npx awguard@latest . --format score` to generate an AWI scorecard.',
290
+ '- Run `npx awguard@latest . --fix-dry-run` for remediation guidance.'
291
+ );
292
+
293
+ return lines.join('\n');
294
+ }
295
+
296
+ function ruleCategory(ruleId) {
297
+ if (['AWG001', 'AWG002', 'AWG018'].includes(ruleId)) return 'prompt-injection';
298
+ if (['AWG003', 'AWG004', 'AWG005', 'AWG008', 'AWG016', 'AWG017'].includes(ruleId)) return 'github-actions-permissions';
299
+ if (['AWG013', 'AWG014', 'AWG015', 'AWG019'].includes(ruleId)) return 'mcp-governance';
300
+ if (ruleId === 'AWG012') return 'agent-instructions';
301
+ return 'workflow-hardening';
302
+ }
303
+
304
+ function ruleTags(ruleId) {
305
+ return ['security', 'github-actions', 'ai-agent', ruleCategory(ruleId)];
306
+ }
307
+
225
308
  function formatAnnotation(finding) {
226
309
  const command = finding.severity === 'low' || finding.severity === 'medium' ? 'warning' : 'error';
227
310
  const title = `${finding.ruleId} ${finding.title}`;
package/src/scanner.js CHANGED
@@ -40,7 +40,7 @@ const mcpConfigFiles = new Set([
40
40
  ]);
41
41
 
42
42
  const untrustedFieldPattern =
43
- /\${{\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;
43
+ /\${{\s*(?:github\.(?:event\.[\w.-]+\.)?(?:body|default_branch|email|head_ref|label|message|name|page_name|ref|title)|github\.event\.inputs\.[\w-]+|inputs\.[\w-]+)\s*}}|(?:github\.(?:event\.[\w.-]+\.)?(?:body|default_branch|email|head_ref|label|message|name|page_name|ref|title)|github\.event\.inputs\.[\w-]+|inputs\.[\w-]+)/i;
44
44
 
45
45
  const agentPattern =
46
46
  /\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;
@@ -57,6 +57,18 @@ const commandSinkPattern =
57
57
  const modelOutputPattern =
58
58
  /\b(agent|ai|completion|llm|model|output|patch|plan|response|result)\b/i;
59
59
 
60
+ const mcpWorkflowPattern =
61
+ /\b(?:mcp|modelcontextprotocol|mcpServers|MCP_[A-Z0-9_]+|claude\s+mcp|codex\s+mcp|cursor\s+mcp)\b|@modelcontextprotocol\//i;
62
+
63
+ const mcpInputKeyPattern =
64
+ /^\s*(?:MCP_[A-Z0-9_]+|(?:tool|mcp|server|args?|payload|request)[\w-]*)\s*[:=]/i;
65
+
66
+ const repositoryWritePattern =
67
+ /\b(?:git\s+(?:commit|push|tag)|gh\s+(?:api|release|repo|workflow|pr\s+merge|issue\s+(?:close|edit)|label\s+)|npm\s+publish)\b/i;
68
+
69
+ const safeWriteBoundaryPattern =
70
+ /\b(?:create-pull-request|gh\s+pr\s+create|pull-request|actions\/upload-artifact|upload-artifact|artifact-only)\b/i;
71
+
60
72
  const suppressionPattern = /#\s*awguard-disable-(next-line|line)\b(.*)$/i;
61
73
 
62
74
  const riskyAgentInstructionPattern =
@@ -95,99 +107,142 @@ export const ruleCatalog = {
95
107
  AWG001: {
96
108
  title: 'Untrusted text reaches an AI agent prompt',
97
109
  severity: 'high',
110
+ remediationCode: 'prompt.isolate-untrusted-text',
98
111
  suggestion:
99
112
  '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.'
100
113
  },
101
114
  AWG002: {
102
115
  title: 'Untrusted GitHub context is interpolated in a shell script',
103
116
  severity: 'high',
117
+ remediationCode: 'shell.quote-github-context',
104
118
  suggestion:
105
119
  '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.'
106
120
  },
107
121
  AWG003: {
108
122
  title: 'pull_request_target checks out untrusted pull request code',
109
123
  severity: 'critical',
124
+ remediationCode: 'checkout.avoid-pr-head',
110
125
  suggestion:
111
126
  'Use pull_request for untrusted builds, or keep pull_request_target limited to base-repository metadata work without checking out head SHA/ref.'
112
127
  },
113
128
  AWG004: {
114
129
  title: 'AI agent workflow has broad token permissions',
115
130
  severity: 'high',
131
+ remediationCode: 'permissions.tighten-token',
116
132
  suggestion:
117
133
  'Set permissions to read-all or the smallest write scope required. Add manual approval before any agent can write code, comments, labels, or releases.'
118
134
  },
119
135
  AWG005: {
120
136
  title: 'Secrets are exposed in an untrusted agent workflow',
121
137
  severity: 'high',
138
+ remediationCode: 'secrets.split-privileged-workflow',
122
139
  suggestion:
123
140
  '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.'
124
141
  },
125
142
  AWG006: {
126
143
  title: 'Autonomous agent runs with unsafe approval flags',
127
144
  severity: 'high',
145
+ remediationCode: 'agent.require-approval',
128
146
  suggestion:
129
147
  'Remove full-auto or skip-permission flags in CI. Require a human approval gate before tool use, file writes, command execution, or repository changes.'
130
148
  },
131
149
  AWG007: {
132
150
  title: 'Model or agent output may be executed by a script',
133
151
  severity: 'high',
152
+ remediationCode: 'output.validate-before-exec',
134
153
  suggestion:
135
154
  '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.'
136
155
  },
137
156
  AWG008: {
138
157
  title: 'Agent workflow does not declare permissions',
139
158
  severity: 'medium',
159
+ remediationCode: 'permissions.declare-readonly',
140
160
  suggestion:
141
161
  'Declare explicit permissions, usually contents: read for analysis workflows. Escalate write scopes only in a separate, reviewed job.'
142
162
  },
143
163
  AWG009: {
144
164
  title: 'workflow_run consumes artifacts before script execution',
145
165
  severity: 'medium',
166
+ remediationCode: 'artifact.verify-provenance',
146
167
  suggestion:
147
168
  'Treat artifacts from earlier workflows as untrusted. Verify provenance and contents before using them in privileged workflow_run jobs.'
148
169
  },
149
170
  AWG010: {
150
171
  title: 'Third-party action is not pinned to a commit SHA',
151
172
  severity: 'low',
173
+ remediationCode: 'actions.pin-sha',
152
174
  suggestion:
153
175
  'Pin third-party actions to a full commit SHA in security-sensitive agent workflows, and review the action before updating the pin.'
154
176
  },
155
177
  AWG011: {
156
178
  title: 'Invalid suppression comment',
157
179
  severity: 'medium',
180
+ remediationCode: 'suppression.add-justification',
158
181
  suggestion:
159
182
  '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.'
160
183
  },
161
184
  AWG012: {
162
185
  title: 'Agent instruction file weakens review or permission boundaries',
163
186
  severity: 'high',
187
+ remediationCode: 'instructions.harden-agent-boundary',
164
188
  suggestion:
165
189
  'Keep AGENTS.md, CLAUDE.md, GEMINI.md, Copilot instructions, and other persistent agent instruction files conservative. Do not tell agents to bypass approvals, follow untrusted issue/PR text as commands, or expose secrets.'
166
190
  },
167
191
  AWG013: {
168
192
  title: 'MCP config starts mutable or shell-based tool servers',
169
193
  severity: 'high',
194
+ remediationCode: 'mcp.pin-server',
170
195
  suggestion:
171
196
  'Pin MCP server packages to exact versions or container digests, avoid shell wrappers, and review project-scoped MCP servers before agents can use them.'
172
197
  },
173
198
  AWG014: {
174
199
  title: 'MCP config hardcodes secrets or auth material',
175
200
  severity: 'critical',
201
+ remediationCode: 'mcp.prompt-secrets',
176
202
  suggestion:
177
203
  'Move MCP credentials into input prompts, environment variables, or a secret manager. Do not commit bearer tokens, API keys, passwords, or auth headers in MCP config files.'
178
204
  },
179
205
  AWG015: {
180
206
  title: 'Agentic surface is not approved by policy',
181
207
  severity: 'medium',
208
+ remediationCode: 'policy.allowlist-reviewed-surface',
182
209
  suggestion:
183
210
  'Add the workflow, agent context file, MCP config, MCP server, package, or command to the policy allowlist only after review. Otherwise remove or harden it.'
211
+ },
212
+ AWG016: {
213
+ title: 'Checkout credentials persist into an agent workflow',
214
+ severity: 'high',
215
+ remediationCode: 'checkout.disable-persisted-credentials',
216
+ suggestion:
217
+ 'Set actions/checkout persist-credentials: false in agent jobs with write tokens, or split checkout and writeback into separate reviewed jobs.'
218
+ },
219
+ AWG017: {
220
+ title: 'Agent writeback lacks branch or PR containment',
221
+ severity: 'critical',
222
+ remediationCode: 'writeback.use-pr-branch',
223
+ suggestion:
224
+ 'Write agent changes to an isolated branch, draft pull request, or artifact. Do not let autonomous agent jobs push directly to protected branches.'
225
+ },
226
+ AWG018: {
227
+ title: 'Untrusted event text reaches MCP tool inputs or environment',
228
+ severity: 'high',
229
+ remediationCode: 'mcp.sanitize-untrusted-input',
230
+ suggestion:
231
+ 'Keep issue, PR, branch, comment, and workflow_dispatch input text out of MCP tool arguments and environment variables unless it is reviewed and sanitized.'
232
+ },
233
+ AWG019: {
234
+ title: 'MCP package is outside trusted package scopes',
235
+ severity: 'medium',
236
+ remediationCode: 'mcp.review-package-reputation',
237
+ suggestion:
238
+ 'Review MCP package publisher, source repository, release cadence, and install scripts before approving it. Add reviewed scopes to policy.approvedMcpPackageScopes.'
184
239
  }
185
240
  };
186
241
 
187
242
  export function scanWorkflows({ root = process.cwd(), config = {} } = {}) {
188
243
  const absoluteRoot = path.resolve(root);
189
244
  const relativeBase = fs.statSync(absoluteRoot).isFile() ? path.dirname(absoluteRoot) : absoluteRoot;
190
- const files = discoverScanFiles(absoluteRoot);
245
+ const files = enforceScanLimits(filterScanFiles(discoverScanFiles(absoluteRoot), relativeBase, config.scan || {}), config.scan || {});
191
246
  const findings = files.flatMap((file) => scanFile(file, relativeBase, config));
192
247
 
193
248
  findings.sort((a, b) => {
@@ -245,6 +300,9 @@ export function scanWorkflowText(text, file = 'workflow.yml', root = process.cwd
245
300
  detectSecretsInAgentWorkflow(context);
246
301
  detectUnsafeAgentFlags(context);
247
302
  detectModelOutputSinks(context);
303
+ detectCheckoutCredentialPersistence(context);
304
+ detectUnsafeAgentWriteback(context);
305
+ detectUntrustedMcpInputs(context);
248
306
  detectMissingPermissions(context);
249
307
  detectWorkflowRunArtifacts(context);
250
308
  detectUnpinnedActions(context);
@@ -320,6 +378,12 @@ export function classifyScanFile(file, root = process.cwd()) {
320
378
  }
321
379
 
322
380
  function scanFile(file, root, config) {
381
+ const maxFileBytes = config.scan?.maxFileBytes;
382
+ if (maxFileBytes && fs.statSync(file).size > maxFileBytes) {
383
+ const relativeFile = path.relative(root, file).split(path.sep).join('/') || path.basename(file);
384
+ throw new Error(`${relativeFile} is larger than scan.maxFileBytes (${maxFileBytes}). Exclude it or raise the limit.`);
385
+ }
386
+
323
387
  const text = fs.readFileSync(file, 'utf8');
324
388
  let findings;
325
389
  if (isAgentInstructionFile(file, root)) {
@@ -359,6 +423,28 @@ function discoverScanFiles(root) {
359
423
  return [...new Set(files)].sort();
360
424
  }
361
425
 
426
+ function filterScanFiles(files, root, scan) {
427
+ const include = scan.include || [];
428
+ const exclude = scan.exclude || [];
429
+
430
+ return files.filter((file) => {
431
+ const relativeFile = path.relative(root, file).split(path.sep).join('/') || path.basename(file);
432
+ const included = include.length === 0 || matchesAnyWildcardPattern(relativeFile, include);
433
+ const excluded = exclude.length > 0 && matchesAnyWildcardPattern(relativeFile, exclude);
434
+ return included && !excluded;
435
+ });
436
+ }
437
+
438
+ function enforceScanLimits(files, scan) {
439
+ if (scan.maxFiles && files.length > scan.maxFiles) {
440
+ throw new Error(
441
+ `scan matched ${files.length} files, above scan.maxFiles (${scan.maxFiles}). Narrow scan.include/scan.exclude or raise the limit.`
442
+ );
443
+ }
444
+
445
+ return files;
446
+ }
447
+
362
448
  function walk(dir) {
363
449
  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
364
450
  const fullPath = path.join(dir, entry.name);
@@ -389,7 +475,7 @@ function detectPromptToAgent(context) {
389
475
  function detectScriptInjection(context) {
390
476
  context.lines.forEach((line, index) => {
391
477
  if (!untrustedFieldPattern.test(line)) return;
392
- if (!context.runBlocks.has(index) && !/^\s*run\s*:/.test(line)) return;
478
+ if (!context.runBlocks.has(index) && !/^\s*(?:-\s*)?run\s*:/.test(line)) return;
393
479
 
394
480
  addFinding(context, 'AWG002', index + 1, {
395
481
  evidence: line.trim(),
@@ -472,6 +558,66 @@ function detectModelOutputSinks(context) {
472
558
  });
473
559
  }
474
560
 
561
+ function detectCheckoutCredentialPersistence(context) {
562
+ if (!context.hasAgent || (!context.hasBroadPermission && !context.triggers.has('pull_request_target'))) return;
563
+
564
+ context.lines.forEach((line, index) => {
565
+ if (!/uses\s*:\s*actions\/checkout@/i.test(line)) return;
566
+
567
+ const checkoutBlock = windowAfter(context.lines, index, 10);
568
+ const persistIndex = checkoutBlock.findIndex((candidate) => /^\s*persist-credentials\s*:/i.test(candidate));
569
+ const hasPersistFalse =
570
+ persistIndex !== -1 && /^\s*persist-credentials\s*:\s*false\s*(?:#.*)?$/i.test(checkoutBlock[persistIndex]);
571
+
572
+ if (hasPersistFalse) return;
573
+
574
+ const lineOffset = persistIndex === -1 ? 0 : persistIndex;
575
+ const evidence = persistIndex === -1 ? line.trim() : checkoutBlock[persistIndex].trim();
576
+ const message =
577
+ persistIndex === -1
578
+ ? 'actions/checkout persists the GitHub token by default in an agent workflow with elevated authority.'
579
+ : 'actions/checkout explicitly persists credentials in an agent workflow with elevated authority.';
580
+
581
+ addFinding(context, 'AWG016', index + lineOffset + 1, {
582
+ evidence,
583
+ message
584
+ });
585
+ });
586
+ }
587
+
588
+ function detectUnsafeAgentWriteback(context) {
589
+ if (!context.hasAgent || !context.hasBroadPermission) return;
590
+
591
+ context.lines.forEach((line, index) => {
592
+ if (!context.runBlocks.has(index) && !/^\s*(?:-\s*)?run\s*:/.test(line)) return;
593
+ if (!repositoryWritePattern.test(line)) return;
594
+
595
+ const windowText = windowAround(context.lines, index, 8).join('\n');
596
+ if (safeWriteBoundaryPattern.test(windowText) && !isProtectedBranchPush(line)) return;
597
+
598
+ addFinding(context, 'AWG017', index + 1, {
599
+ evidence: line.trim(),
600
+ message: 'An agent job with write-capable permissions appears to write back without a branch, PR, or artifact boundary.'
601
+ });
602
+ });
603
+ }
604
+
605
+ function detectUntrustedMcpInputs(context) {
606
+ context.lines.forEach((line, index) => {
607
+ if (!untrustedFieldPattern.test(line)) return;
608
+
609
+ const windowText = windowAround(context.lines, index, 8).join('\n');
610
+ if (!mcpWorkflowPattern.test(windowText) && !mcpInputKeyPattern.test(line)) return;
611
+
612
+ const severity = context.hasBroadPermission || context.hasSecret ? 'critical' : ruleCatalog.AWG018.severity;
613
+ addFinding(context, 'AWG018', index + 1, {
614
+ severity,
615
+ evidence: line.trim(),
616
+ message: 'User-controlled GitHub event text appears to reach MCP tool arguments or environment variables.'
617
+ });
618
+ });
619
+ }
620
+
475
621
  function detectMissingPermissions(context) {
476
622
  if (!context.hasAgent || context.hasPermissionBlock) return;
477
623
 
@@ -488,7 +634,7 @@ function detectWorkflowRunArtifacts(context) {
488
634
  if (!context.triggers.has('workflow_run')) return;
489
635
  if (!/actions\/download-artifact@|download-artifact/i.test(context.lines.join('\n'))) return;
490
636
 
491
- const firstRunLine = context.lines.findIndex((line) => /^\s*run\s*:/.test(line));
637
+ const firstRunLine = context.lines.findIndex((line) => /^\s*(?:-\s*)?run\s*:/.test(line));
492
638
  if (firstRunLine === -1) return;
493
639
 
494
640
  addFinding(context, 'AWG009', firstRunLine + 1, {
@@ -645,6 +791,18 @@ function detectMcpPolicy(context, server) {
645
791
  message: `MCP server "${server.name}" uses a package not listed in policy.approvedMcpPackages.`
646
792
  });
647
793
  }
794
+
795
+ const packageName = packageNameFromSpec(packageSpec);
796
+ if (
797
+ policy.approvedMcpPackageScopes?.length > 0 &&
798
+ packageName &&
799
+ !matchesTrustedPackageScope(packageName, policy.approvedMcpPackageScopes)
800
+ ) {
801
+ addFinding(context, 'AWG019', locateMcpLine(context, server, packageSpec), {
802
+ evidence: `MCP server "${server.name}" package: ${packageSpec}`,
803
+ message: `MCP package "${packageName}" is not in policy.approvedMcpPackageScopes.`
804
+ });
805
+ }
648
806
  }
649
807
 
650
808
  function detectFilePolicy(file, root, config) {
@@ -700,8 +858,10 @@ function addFinding(context, ruleId, line, overrides = {}) {
700
858
  file: context.relativeFile,
701
859
  absoluteFile: context.file,
702
860
  line,
861
+ column: overrides.column || findEvidenceColumn(context.lines[line - 1], overrides.evidence),
703
862
  message: overrides.message || docs.title,
704
863
  evidence: overrides.evidence || '',
864
+ remediationCode: docs.remediationCode,
705
865
  suggestion: overrides.suggestion || docs.suggestion
706
866
  };
707
867
 
@@ -721,6 +881,12 @@ function addFinding(context, ruleId, line, overrides = {}) {
721
881
  });
722
882
  }
723
883
 
884
+ function findEvidenceColumn(sourceLine = '', evidence = '') {
885
+ if (!evidence) return 1;
886
+ const index = sourceLine.indexOf(evidence);
887
+ return index === -1 ? 1 : index + 1;
888
+ }
889
+
724
890
  function collectSuppressions(lines, rawSuppressionConfig = {}) {
725
891
  const suppressionConfig = {
726
892
  allow: rawSuppressionConfig.allow !== false,
@@ -870,7 +1036,7 @@ function markRunBlocks(lines) {
870
1036
 
871
1037
  lines.forEach((line, index) => {
872
1038
  const indent = leadingSpaces(line);
873
- const startsRun = /^\s*run\s*:/.test(line);
1039
+ const startsRun = /^\s*(?:-\s*)?run\s*:/.test(line);
874
1040
 
875
1041
  if (startsRun) {
876
1042
  runBlocks.add(index);
@@ -899,6 +1065,12 @@ function isBroadPermissionLine(line) {
899
1065
  );
900
1066
  }
901
1067
 
1068
+ function isProtectedBranchPush(line) {
1069
+ if (!/\bgit\s+push\b/i.test(line)) return false;
1070
+ if (/\b(?:main|master|production|release)\b/i.test(line)) return true;
1071
+ return !/\bHEAD:(?!main\b|master\b|production\b|release\b)[\w./-]+\b/i.test(line);
1072
+ }
1073
+
902
1074
  function discoverAgentInstructionFiles(root) {
903
1075
  const files = [];
904
1076
 
@@ -1132,6 +1304,29 @@ function findMcpPackageSpec(baseCommand, args) {
1132
1304
  return '';
1133
1305
  }
1134
1306
 
1307
+ function packageNameFromSpec(packageSpec = '') {
1308
+ if (!packageSpec || packageSpec.startsWith('.') || packageSpec.startsWith('/') || packageSpec.startsWith('${')) return '';
1309
+ const withoutAlias = packageSpec.startsWith('npm:') ? packageSpec.slice('npm:'.length) : packageSpec;
1310
+ if (/^(?:https?|git\+?ssh|git\+?https?):/i.test(withoutAlias)) return withoutAlias;
1311
+
1312
+ if (withoutAlias.startsWith('@')) {
1313
+ const match = withoutAlias.match(/^(@[^/]+\/[^@/]+)/);
1314
+ return match ? match[1] : withoutAlias;
1315
+ }
1316
+
1317
+ return withoutAlias.split('@')[0];
1318
+ }
1319
+
1320
+ function matchesTrustedPackageScope(packageName, scopes) {
1321
+ return scopes.some((scope) => {
1322
+ const normalized = String(scope).trim();
1323
+ if (!normalized) return false;
1324
+ if (normalized.endsWith('/')) return packageName.startsWith(normalized);
1325
+ if (normalized.startsWith('@') && !normalized.includes('/')) return packageName.startsWith(`${normalized}/`);
1326
+ return packageName === normalized || packageName.startsWith(normalized);
1327
+ });
1328
+ }
1329
+
1135
1330
  function isMutablePackageSpec(spec) {
1136
1331
  if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('${')) return false;
1137
1332
  if (/^(?:https?|git\+?ssh|git\+?https?):/i.test(spec)) return true;
@@ -1282,6 +1477,10 @@ function isPlainObject(value) {
1282
1477
  }
1283
1478
 
1284
1479
  function matchesAnyPolicyPattern(value, patterns) {
1480
+ return matchesAnyWildcardPattern(value, patterns);
1481
+ }
1482
+
1483
+ function matchesAnyWildcardPattern(value, patterns) {
1285
1484
  return patterns.some((pattern) => wildcardToRegExp(pattern).test(value));
1286
1485
  }
1287
1486