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.
- package/CHANGELOG.md +32 -0
- package/Dockerfile +8 -1
- package/README.md +176 -12
- package/action.yml +5 -1
- package/docs/comparison.md +161 -16
- package/docs/launch-plan.md +12 -2
- package/docs/marketplace-listing.md +19 -0
- package/docs/npm-publishing.md +68 -0
- package/docs/release-checklist.md +71 -0
- package/docs/report-gallery.md +166 -0
- package/docs/roadmap.md +32 -2
- package/docs/rule-authoring.md +99 -0
- package/docs/schemas.md +16 -0
- package/docs/setup-recipes.md +199 -0
- package/docs/site/index.html +29 -0
- package/examples/.vscode/tasks.json +17 -1
- package/examples/README.md +7 -0
- package/examples/awguard.config.example.json +8 -0
- package/examples/corpus/.cursor/rules/autonomy.mdc +3 -0
- package/examples/corpus/.github/prompts/auto-fix.prompt.md +3 -0
- package/examples/corpus/.github/workflows/agentic-pr-review.yml +20 -0
- package/examples/corpus/.github/workflows/pull-request-target-head.yml +13 -0
- package/examples/corpus/.mcp.json +15 -0
- package/examples/corpus/AGENTS.md +5 -0
- package/examples/corpus/README.md +23 -0
- package/examples/dashboard/README.md +55 -0
- package/examples/dashboard/index.html +313 -0
- package/examples/dashboard/sample-history.json +53 -0
- package/examples/lab/README.md +6 -0
- package/examples/pr-comment-bot.yml +43 -0
- package/examples/pull-request-target.yml +1 -1
- package/examples/safe-agent.yml +1 -1
- package/examples/unsafe-agent.yml +1 -1
- package/examples/vscode-extension/README.md +49 -0
- package/examples/vscode-extension/assets/problems-panel.svg +23 -0
- package/examples/vscode-extension/package.json +68 -0
- package/examples/vscode-extension/src/extension.js +116 -0
- package/package.json +2 -1
- package/schemas/awguard.badge.schema.json +25 -0
- package/schemas/awguard.baseline.schema.json +40 -0
- package/schemas/awguard.comparison.schema.json +146 -0
- package/schemas/awguard.config.schema.json +167 -0
- package/schemas/awguard.inventory.schema.json +124 -0
- package/schemas/awguard.report.schema.json +121 -0
- package/src/autofix.js +201 -0
- package/src/badges.js +63 -0
- package/src/baseline.js +77 -0
- package/src/cli.js +248 -6
- package/src/compare.js +60 -4
- package/src/config.js +31 -2
- package/src/demo.js +90 -0
- package/src/doctor.js +189 -0
- package/src/explain.js +147 -0
- package/src/init.js +4 -1
- package/src/policy-packs.js +99 -0
- package/src/policy-wizard.js +165 -0
- package/src/remediation.js +73 -1
- package/src/reporters.js +86 -3
- package/src/scanner.js +204 -5
- package/src/templates.js +132 -0
package/src/remediation.js
CHANGED
|
@@ -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:
|
|
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
|
|