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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +274 -0
- package/action.yml +46 -0
- package/bin/awguard.js +8 -0
- package/docs/launch-plan.md +52 -0
- package/docs/market-analysis.md +162 -0
- package/examples/README.md +16 -0
- package/examples/awguard.config.example.json +14 -0
- package/examples/pull-request-target.yml +16 -0
- package/examples/safe-agent.yml +20 -0
- package/examples/suppressed-agent.yml +15 -0
- package/examples/unsafe-agent.yml +19 -0
- package/package.json +46 -0
- package/src/baseline.js +67 -0
- package/src/cli.js +172 -0
- package/src/config.js +166 -0
- package/src/fingerprints.js +13 -0
- package/src/graph.js +278 -0
- package/src/migration.js +258 -0
- package/src/presets.js +67 -0
- package/src/remediation.js +118 -0
- package/src/reporters.js +231 -0
- package/src/scanner.js +604 -0
package/src/graph.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
const sourcePattern = /github\.(?:event\.[\w.-]+\.)?(body|default_branch|email|head_ref|label|message|name|page_name|ref|title)|\${{\s*github\.(?:event\.[\w.-]+\.)?([\w.-]+)\s*}}/i;
|
|
2
|
+
|
|
3
|
+
const impactByRule = {
|
|
4
|
+
AWG001: 'Agent follows attacker-controlled instructions',
|
|
5
|
+
AWG002: 'Shell executes attacker-controlled workflow text',
|
|
6
|
+
AWG003: 'Untrusted pull request code runs with privileged context',
|
|
7
|
+
AWG004: 'Agent or script can write to repository resources',
|
|
8
|
+
AWG005: 'Model provider or repository secret may be exposed',
|
|
9
|
+
AWG006: 'Autonomous agent can act without review',
|
|
10
|
+
AWG007: 'Model output can become executable code',
|
|
11
|
+
AWG008: 'Default token permissions may be broader than intended',
|
|
12
|
+
AWG009: 'Untrusted artifacts can influence privileged jobs',
|
|
13
|
+
AWG010: 'Mutable third-party action can change behavior',
|
|
14
|
+
AWG011: 'Suppression policy can hide real risk'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function buildAttackGraphs(result) {
|
|
18
|
+
const findings = result.findings.filter((finding) => finding.ruleId !== 'AWG011');
|
|
19
|
+
const byFile = groupBy(findings, (finding) => finding.file);
|
|
20
|
+
|
|
21
|
+
const graphs = [...byFile.entries()].map(([file, fileFindings]) => {
|
|
22
|
+
const authorities = inferAuthorities(fileFindings);
|
|
23
|
+
return {
|
|
24
|
+
file,
|
|
25
|
+
findings: fileFindings,
|
|
26
|
+
authorities,
|
|
27
|
+
chains: fileFindings.map((finding) => buildChain(finding, authorities))
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
graphs,
|
|
33
|
+
summary: {
|
|
34
|
+
files: graphs.length,
|
|
35
|
+
chains: graphs.reduce((total, graph) => total + graph.chains.length, 0)
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderGraphMarkdown(result) {
|
|
41
|
+
const attackGraph = buildAttackGraphs(result);
|
|
42
|
+
const lines = [
|
|
43
|
+
'# Agentic Workflow Guard Attack Graph',
|
|
44
|
+
'',
|
|
45
|
+
`Scanned workflow files: **${result.scannedFiles.length}**`,
|
|
46
|
+
`Findings: **${result.summary.total}**`,
|
|
47
|
+
`Attack chains: **${attackGraph.summary.chains}**`,
|
|
48
|
+
''
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
if (attackGraph.summary.chains === 0) {
|
|
52
|
+
lines.push('No attack chains found.');
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const graph of attackGraph.graphs) {
|
|
57
|
+
lines.push(`## ${graph.file}`);
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('```mermaid');
|
|
60
|
+
lines.push(renderMermaidForGraph(graph));
|
|
61
|
+
lines.push('```');
|
|
62
|
+
lines.push('');
|
|
63
|
+
|
|
64
|
+
for (const chain of graph.chains) {
|
|
65
|
+
lines.push(`- **${chain.ruleId} ${chain.severity}** at \`${chain.location}\`: ${chain.impact}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function renderHtmlReport(result) {
|
|
74
|
+
const attackGraph = buildAttackGraphs(result);
|
|
75
|
+
const findingRows = result.findings
|
|
76
|
+
.map(
|
|
77
|
+
(finding) => `<tr>
|
|
78
|
+
<td><span class="severity ${escapeHtml(finding.severity)}">${escapeHtml(finding.severity)}</span></td>
|
|
79
|
+
<td>${escapeHtml(finding.ruleId)}</td>
|
|
80
|
+
<td><code>${escapeHtml(`${finding.file}:${finding.line}`)}</code></td>
|
|
81
|
+
<td>${escapeHtml(finding.title)}</td>
|
|
82
|
+
<td>${escapeHtml(finding.suggestion)}</td>
|
|
83
|
+
</tr>`
|
|
84
|
+
)
|
|
85
|
+
.join('\n');
|
|
86
|
+
|
|
87
|
+
const graphSections = attackGraph.graphs
|
|
88
|
+
.map(
|
|
89
|
+
(graph) => `<section class="graph">
|
|
90
|
+
<h2>${escapeHtml(graph.file)}</h2>
|
|
91
|
+
${graph.chains
|
|
92
|
+
.map(
|
|
93
|
+
(chain) => `<div class="chain">
|
|
94
|
+
<div class="step"><span>Source</span><strong>${escapeHtml(chain.source)}</strong></div>
|
|
95
|
+
<div class="arrow">→</div>
|
|
96
|
+
<div class="step"><span>Boundary</span><strong>${escapeHtml(chain.boundary)}</strong></div>
|
|
97
|
+
<div class="arrow">→</div>
|
|
98
|
+
<div class="step"><span>Capability</span><strong>${escapeHtml(chain.capability)}</strong></div>
|
|
99
|
+
<div class="arrow">→</div>
|
|
100
|
+
<div class="step"><span>Authority</span><strong>${escapeHtml(chain.authority)}</strong></div>
|
|
101
|
+
<div class="arrow">→</div>
|
|
102
|
+
<div class="step impact"><span>Impact</span><strong>${escapeHtml(chain.impact)}</strong></div>
|
|
103
|
+
</div>`
|
|
104
|
+
)
|
|
105
|
+
.join('\n')}
|
|
106
|
+
<details>
|
|
107
|
+
<summary>Mermaid source</summary>
|
|
108
|
+
<pre class="mermaid">${escapeHtml(renderMermaidForGraph(graph))}</pre>
|
|
109
|
+
</details>
|
|
110
|
+
<ul>
|
|
111
|
+
${graph.chains
|
|
112
|
+
.map(
|
|
113
|
+
(chain) =>
|
|
114
|
+
`<li><strong>${escapeHtml(chain.ruleId)}</strong> <code>${escapeHtml(
|
|
115
|
+
chain.location
|
|
116
|
+
)}</code>: ${escapeHtml(chain.impact)}</li>`
|
|
117
|
+
)
|
|
118
|
+
.join('\n')}
|
|
119
|
+
</ul>
|
|
120
|
+
</section>`
|
|
121
|
+
)
|
|
122
|
+
.join('\n');
|
|
123
|
+
|
|
124
|
+
return `<!doctype html>
|
|
125
|
+
<html lang="en">
|
|
126
|
+
<head>
|
|
127
|
+
<meta charset="utf-8">
|
|
128
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
129
|
+
<title>Agentic Workflow Guard Report</title>
|
|
130
|
+
<style>
|
|
131
|
+
:root { color-scheme: light; --ink: #172026; --muted: #53636f; --line: #d8e0e6; --panel: #f7fafc; --accent: #0f766e; }
|
|
132
|
+
body { margin: 0; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: var(--ink); background: #ffffff; }
|
|
133
|
+
header { padding: 40px 32px 28px; border-bottom: 1px solid var(--line); background: linear-gradient(180deg, #eef7f6, #ffffff); }
|
|
134
|
+
main { padding: 28px 32px 48px; max-width: 1180px; margin: 0 auto; }
|
|
135
|
+
h1 { margin: 0 0 8px; font-size: 34px; letter-spacing: 0; }
|
|
136
|
+
h2 { margin-top: 30px; font-size: 22px; }
|
|
137
|
+
.subtitle { margin: 0; color: var(--muted); max-width: 760px; }
|
|
138
|
+
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 24px 0; }
|
|
139
|
+
.metric { border: 1px solid var(--line); background: var(--panel); border-radius: 8px; padding: 16px; }
|
|
140
|
+
.metric strong { display: block; font-size: 28px; }
|
|
141
|
+
table { width: 100%; border-collapse: collapse; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
|
|
142
|
+
th, td { padding: 10px 12px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
|
143
|
+
th { background: var(--panel); }
|
|
144
|
+
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
145
|
+
pre { overflow: auto; background: #101820; color: #e7f4f2; padding: 16px; border-radius: 8px; }
|
|
146
|
+
.severity { display: inline-block; min-width: 64px; text-align: center; border-radius: 999px; color: #fff; font-size: 12px; padding: 2px 8px; text-transform: uppercase; }
|
|
147
|
+
.critical { background: #b42318; } .high { background: #c2410c; } .medium { background: #a16207; } .low { background: #2563eb; }
|
|
148
|
+
.graph { border: 1px solid var(--line); border-radius: 8px; padding: 18px; margin: 18px 0; }
|
|
149
|
+
.chain { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr auto 1fr; gap: 8px; align-items: stretch; margin: 14px 0; }
|
|
150
|
+
.step { border: 1px solid var(--line); border-top: 4px solid var(--accent); background: #fff; border-radius: 8px; padding: 10px; min-height: 82px; }
|
|
151
|
+
.step span { display: block; color: var(--muted); font-size: 12px; text-transform: uppercase; }
|
|
152
|
+
.step strong { display: block; margin-top: 5px; }
|
|
153
|
+
.step.impact { border-top-color: #b42318; }
|
|
154
|
+
.arrow { align-self: center; color: var(--accent); font-size: 24px; font-weight: 700; }
|
|
155
|
+
details { margin: 12px 0; }
|
|
156
|
+
@media (max-width: 980px) { .chain { grid-template-columns: 1fr; } .arrow { display: none; } }
|
|
157
|
+
</style>
|
|
158
|
+
</head>
|
|
159
|
+
<body>
|
|
160
|
+
<header>
|
|
161
|
+
<h1>Agentic Workflow Guard</h1>
|
|
162
|
+
<p class="subtitle">Attack graph report for AI-agent GitHub Actions workflows. It maps untrusted event text to prompts, agent capabilities, permissions, and possible impact.</p>
|
|
163
|
+
</header>
|
|
164
|
+
<main>
|
|
165
|
+
<section class="metrics">
|
|
166
|
+
<div class="metric"><span>Workflow files</span><strong>${result.scannedFiles.length}</strong></div>
|
|
167
|
+
<div class="metric"><span>Findings</span><strong>${result.summary.total}</strong></div>
|
|
168
|
+
<div class="metric"><span>Highest severity</span><strong>${escapeHtml(result.summary.highest)}</strong></div>
|
|
169
|
+
<div class="metric"><span>Attack chains</span><strong>${attackGraph.summary.chains}</strong></div>
|
|
170
|
+
</section>
|
|
171
|
+
<h2>Attack Graphs</h2>
|
|
172
|
+
${graphSections || '<p>No attack chains found.</p>'}
|
|
173
|
+
<h2>Findings</h2>
|
|
174
|
+
<table>
|
|
175
|
+
<thead><tr><th>Severity</th><th>Rule</th><th>Location</th><th>Finding</th><th>Suggested fix</th></tr></thead>
|
|
176
|
+
<tbody>${findingRows}</tbody>
|
|
177
|
+
</table>
|
|
178
|
+
</main>
|
|
179
|
+
</body>
|
|
180
|
+
</html>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildChain(finding, authorities) {
|
|
184
|
+
const source = inferSource(finding);
|
|
185
|
+
const boundary = inferBoundary(finding);
|
|
186
|
+
const capability = inferCapability(finding);
|
|
187
|
+
const authority = authorities.length > 0 ? authorities.join(' + ') : inferAuthority(finding);
|
|
188
|
+
const impact = impactByRule[finding.ruleId] || 'Workflow integrity risk';
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
ruleId: finding.ruleId,
|
|
192
|
+
severity: finding.severity,
|
|
193
|
+
location: `${finding.file}:${finding.line}`,
|
|
194
|
+
source,
|
|
195
|
+
boundary,
|
|
196
|
+
capability,
|
|
197
|
+
authority,
|
|
198
|
+
impact
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inferAuthorities(findings) {
|
|
203
|
+
const authorities = new Set();
|
|
204
|
+
if (findings.some((finding) => finding.ruleId === 'AWG004')) authorities.add('write-capable GITHUB_TOKEN');
|
|
205
|
+
if (findings.some((finding) => finding.ruleId === 'AWG005')) authorities.add('repository or provider secrets');
|
|
206
|
+
if (findings.some((finding) => finding.ruleId === 'AWG003')) authorities.add('pull_request_target privileges');
|
|
207
|
+
return [...authorities];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function inferSource(finding) {
|
|
211
|
+
const match = finding.evidence.match(sourcePattern);
|
|
212
|
+
if (match) return `GitHub event field: ${match[1] || match[2] || 'github context'}`;
|
|
213
|
+
if (finding.ruleId === 'AWG009') return 'workflow_run artifact';
|
|
214
|
+
if (finding.ruleId === 'AWG010') return 'third-party action ref';
|
|
215
|
+
return 'workflow configuration';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function inferBoundary(finding) {
|
|
219
|
+
if (finding.ruleId === 'AWG001') return 'AI agent prompt';
|
|
220
|
+
if (finding.ruleId === 'AWG002') return 'run script interpolation';
|
|
221
|
+
if (finding.ruleId === 'AWG003') return 'checkout of untrusted PR code';
|
|
222
|
+
if (finding.ruleId === 'AWG007') return 'command execution sink';
|
|
223
|
+
return 'workflow execution';
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function inferCapability(finding) {
|
|
227
|
+
if (finding.ruleId === 'AWG006') return 'autonomous agent tool use';
|
|
228
|
+
if (finding.ruleId === 'AWG007' || finding.ruleId === 'AWG002') return 'shell command execution';
|
|
229
|
+
if (finding.ruleId === 'AWG010') return 'third-party action execution';
|
|
230
|
+
return 'CI runner and agent tools';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function inferAuthority(finding) {
|
|
234
|
+
if (finding.ruleId === 'AWG004') return 'write-capable token';
|
|
235
|
+
if (finding.ruleId === 'AWG005') return 'secret environment values';
|
|
236
|
+
if (finding.ruleId === 'AWG008') return 'implicit token permissions';
|
|
237
|
+
return 'workflow permissions';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderMermaidForGraph(graph) {
|
|
241
|
+
const lines = ['flowchart LR'];
|
|
242
|
+
|
|
243
|
+
graph.chains.forEach((chain, index) => {
|
|
244
|
+
const prefix = `c${index}`;
|
|
245
|
+
lines.push(` ${prefix}s["${escapeMermaid(chain.source)}"]`);
|
|
246
|
+
lines.push(` ${prefix}b["${escapeMermaid(chain.boundary)}"]`);
|
|
247
|
+
lines.push(` ${prefix}c["${escapeMermaid(chain.capability)}"]`);
|
|
248
|
+
lines.push(` ${prefix}a["${escapeMermaid(chain.authority)}"]`);
|
|
249
|
+
lines.push(` ${prefix}i["${escapeMermaid(chain.impact)}"]`);
|
|
250
|
+
lines.push(` ${prefix}s --> ${prefix}b --> ${prefix}c --> ${prefix}a --> ${prefix}i`);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
return lines.join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function groupBy(values, keyFn) {
|
|
257
|
+
const groups = new Map();
|
|
258
|
+
for (const value of values) {
|
|
259
|
+
const key = keyFn(value);
|
|
260
|
+
const group = groups.get(key) || [];
|
|
261
|
+
group.push(value);
|
|
262
|
+
groups.set(key, group);
|
|
263
|
+
}
|
|
264
|
+
return groups;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function escapeMermaid(value) {
|
|
268
|
+
return String(value).replaceAll('"', '\\"');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function escapeHtml(value) {
|
|
272
|
+
return String(value)
|
|
273
|
+
.replaceAll('&', '&')
|
|
274
|
+
.replaceAll('<', '<')
|
|
275
|
+
.replaceAll('>', '>')
|
|
276
|
+
.replaceAll('"', '"')
|
|
277
|
+
.replaceAll("'", ''');
|
|
278
|
+
}
|
package/src/migration.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const ruleActions = {
|
|
2
|
+
AWG001: [
|
|
3
|
+
'Move untrusted GitHub event text into a data-only file or artifact.',
|
|
4
|
+
'Keep system and task instructions separate from issue, PR, comment, branch, and artifact text.',
|
|
5
|
+
'Ask the agent for a structured proposal instead of allowing direct repository writes.'
|
|
6
|
+
],
|
|
7
|
+
AWG002: [
|
|
8
|
+
'Stop interpolating GitHub expressions directly inside run scripts.',
|
|
9
|
+
'Pass untrusted values through env variables and quote them when writing data files.',
|
|
10
|
+
'Do not pipe untrusted or model-generated text into shell interpreters.'
|
|
11
|
+
],
|
|
12
|
+
AWG003: [
|
|
13
|
+
'Do not check out pull request head code from a pull_request_target workflow.',
|
|
14
|
+
'Split untrusted build/test work into pull_request and privileged metadata work into a separate job.',
|
|
15
|
+
'Require maintainer approval before any job with base-repository privileges writes back.'
|
|
16
|
+
],
|
|
17
|
+
AWG004: [
|
|
18
|
+
'Change the agent job to read-only permissions.',
|
|
19
|
+
'Move write scopes into the smallest possible apply job.',
|
|
20
|
+
'Gate write jobs behind workflow_dispatch, environment approval, or safe outputs validation.'
|
|
21
|
+
],
|
|
22
|
+
AWG005: [
|
|
23
|
+
'Remove repository, cloud, and model-provider secrets from untrusted agent jobs.',
|
|
24
|
+
'Use short-lived credentials only in a separate approved apply job.',
|
|
25
|
+
'Never expose secrets to jobs that read issue, PR, comment, branch, or artifact text.'
|
|
26
|
+
],
|
|
27
|
+
AWG006: [
|
|
28
|
+
'Disable full-auto, yolo, unsafe, no-confirm, and skip-permission flags in CI.',
|
|
29
|
+
'Run the agent in suggestion mode and save the proposed operation as an artifact.',
|
|
30
|
+
'Require a human or safe-output validator before tool use changes repository state.'
|
|
31
|
+
],
|
|
32
|
+
AWG007: [
|
|
33
|
+
'Treat model output as untrusted data.',
|
|
34
|
+
'Validate output against a strict JSON schema before applying anything.',
|
|
35
|
+
'Replace eval, bash -c, sh -c, pipe-to-shell, and dynamic command execution with typed handlers.'
|
|
36
|
+
],
|
|
37
|
+
AWG008: [
|
|
38
|
+
'Add an explicit top-level permissions block.',
|
|
39
|
+
'Start with contents: read for agent analysis jobs.',
|
|
40
|
+
'Add write permissions only to a separate apply job.'
|
|
41
|
+
],
|
|
42
|
+
AWG009: [
|
|
43
|
+
'Treat downloaded workflow_run artifacts as untrusted.',
|
|
44
|
+
'Verify artifact source, checksum, and expected schema before use.',
|
|
45
|
+
'Avoid executing artifact contents in privileged workflows.'
|
|
46
|
+
],
|
|
47
|
+
AWG010: [
|
|
48
|
+
'Pin third-party actions to full commit SHAs in agent workflows.',
|
|
49
|
+
'Review action updates before changing pins.',
|
|
50
|
+
'Prefer official or internally reviewed actions for privileged jobs.'
|
|
51
|
+
]
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const writeRules = new Set(['AWG003', 'AWG004', 'AWG005', 'AWG006', 'AWG007', 'AWG009']);
|
|
55
|
+
|
|
56
|
+
export function buildMigrationPlan(result) {
|
|
57
|
+
const actionableFindings = result.findings.filter((finding) => finding.ruleId !== 'AWG011');
|
|
58
|
+
const files = [...groupBy(actionableFindings, (finding) => finding.file).entries()].map(([file, findings]) => ({
|
|
59
|
+
file,
|
|
60
|
+
findings,
|
|
61
|
+
priority: priorityFor(findings),
|
|
62
|
+
riskShape: riskShapeFor(findings),
|
|
63
|
+
steps: migrationStepsFor(findings),
|
|
64
|
+
allowedOperations: allowedOperationsFor(findings)
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
summary: {
|
|
69
|
+
scannedFiles: result.scannedFiles.length,
|
|
70
|
+
findings: actionableFindings.length,
|
|
71
|
+
files: files.length,
|
|
72
|
+
highest: result.summary.highest
|
|
73
|
+
},
|
|
74
|
+
files
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderMigrationPlan(result) {
|
|
79
|
+
const plan = buildMigrationPlan(result);
|
|
80
|
+
const lines = [
|
|
81
|
+
'# Agentic Workflow Guard Migration Plan',
|
|
82
|
+
'',
|
|
83
|
+
`Scanned workflow files: **${plan.summary.scannedFiles}**`,
|
|
84
|
+
`Findings to migrate: **${plan.summary.findings}**`,
|
|
85
|
+
`Affected workflow files: **${plan.summary.files}**`,
|
|
86
|
+
`Highest severity: **${plan.summary.highest}**`,
|
|
87
|
+
'',
|
|
88
|
+
'Goal: move from agent jobs that can read untrusted GitHub text and directly act, to a two-stage pattern where the agent proposes structured output and a trusted layer validates what can happen next.',
|
|
89
|
+
'',
|
|
90
|
+
'Recommended target architecture:',
|
|
91
|
+
'',
|
|
92
|
+
'```text',
|
|
93
|
+
'untrusted GitHub event text',
|
|
94
|
+
' -> read-only agent job',
|
|
95
|
+
' -> structured proposal artifact',
|
|
96
|
+
' -> schema and policy validation',
|
|
97
|
+
' -> safe outputs or approved apply job',
|
|
98
|
+
'```',
|
|
99
|
+
''
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
if (plan.files.length === 0) {
|
|
103
|
+
lines.push('No migration needed. No unsafe agent workflow paths were found.');
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const filePlan of plan.files) {
|
|
108
|
+
lines.push(`## ${filePlan.file}`);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(`Priority: **${filePlan.priority}**`);
|
|
111
|
+
lines.push(`Risk shape: ${filePlan.riskShape}`);
|
|
112
|
+
lines.push('');
|
|
113
|
+
lines.push('| Rule | Line | Why it matters |');
|
|
114
|
+
lines.push('| --- | ---: | --- |');
|
|
115
|
+
|
|
116
|
+
for (const finding of filePlan.findings) {
|
|
117
|
+
lines.push(`| ${finding.ruleId} | ${finding.line} | ${escapeMarkdown(finding.title)} |`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lines.push('');
|
|
121
|
+
lines.push('Migration steps:');
|
|
122
|
+
|
|
123
|
+
filePlan.steps.forEach((step, index) => {
|
|
124
|
+
lines.push(`${index + 1}. ${step}`);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
lines.push('');
|
|
128
|
+
lines.push('Safe-output allowlist to aim for:');
|
|
129
|
+
lines.push('');
|
|
130
|
+
for (const operation of filePlan.allowedOperations) {
|
|
131
|
+
lines.push(`- ${operation}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push('');
|
|
135
|
+
lines.push('Reference pattern:');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('```yaml');
|
|
138
|
+
lines.push(renderReferencePattern(filePlan));
|
|
139
|
+
lines.push('```');
|
|
140
|
+
lines.push('');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
lines.push('Use this report as a migration checklist. It does not edit workflow files because safe output choices are product decisions: a triage bot, reviewer bot, and coding agent should each allow different write operations.');
|
|
144
|
+
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function priorityFor(findings) {
|
|
149
|
+
if (findings.some((finding) => finding.severity === 'critical')) return 'critical';
|
|
150
|
+
if (findings.some((finding) => finding.severity === 'high')) return 'high';
|
|
151
|
+
if (findings.some((finding) => finding.severity === 'medium')) return 'medium';
|
|
152
|
+
return 'low';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function riskShapeFor(findings) {
|
|
156
|
+
const rules = new Set(findings.map((finding) => finding.ruleId));
|
|
157
|
+
const pieces = [];
|
|
158
|
+
|
|
159
|
+
if (rules.has('AWG001')) pieces.push('untrusted text reaches an agent prompt');
|
|
160
|
+
if (rules.has('AWG002') || rules.has('AWG007')) pieces.push('shell or model output execution is possible');
|
|
161
|
+
if ([...rules].some((rule) => writeRules.has(rule))) pieces.push('privileged write path exists');
|
|
162
|
+
if (rules.has('AWG005')) pieces.push('secrets are in scope');
|
|
163
|
+
if (rules.has('AWG010')) pieces.push('agent workflow depends on mutable third-party code');
|
|
164
|
+
|
|
165
|
+
return pieces.length > 0 ? pieces.join('; ') : 'workflow hardening issue';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function migrationStepsFor(findings) {
|
|
169
|
+
const steps = new Set([
|
|
170
|
+
'Create a read-only agent job with explicit `permissions: contents: read`.',
|
|
171
|
+
'Write issue, PR, comment, branch, or artifact content to an `untrusted-input.txt` file.',
|
|
172
|
+
'Ask the agent to produce a structured JSON proposal with only allowed operation names and fields.',
|
|
173
|
+
'Validate the proposal with a schema before any GitHub write, shell execution, or artifact consumption.',
|
|
174
|
+
'Apply the proposal through GitHub Agentic Workflows safe outputs or a separate approved job with narrow write permissions.'
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
for (const finding of findings) {
|
|
178
|
+
for (const action of ruleActions[finding.ruleId] || []) {
|
|
179
|
+
steps.add(action);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return [...steps];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function allowedOperationsFor(findings) {
|
|
187
|
+
const rules = new Set(findings.map((finding) => finding.ruleId));
|
|
188
|
+
const operations = new Set(['add-comment with sanitized body', 'add-labels from an approved label allowlist']);
|
|
189
|
+
|
|
190
|
+
if (rules.has('AWG007') || rules.has('AWG006')) {
|
|
191
|
+
operations.add('create-pull-request only after patch size and path validation');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (rules.has('AWG009')) {
|
|
195
|
+
operations.add('upload-artifact only after provenance and checksum validation');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (rules.has('AWG003')) {
|
|
199
|
+
operations.add('metadata-only pull request updates after maintainer approval');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
operations.add('noop or missing-data report when validation fails');
|
|
203
|
+
return [...operations];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function renderReferencePattern(filePlan) {
|
|
207
|
+
const needsApproval = filePlan.findings.some((finding) => writeRules.has(finding.ruleId));
|
|
208
|
+
const applyGate = needsApproval ? "if: github.event_name == 'workflow_dispatch'" : 'if: always()';
|
|
209
|
+
|
|
210
|
+
return `permissions:
|
|
211
|
+
contents: read
|
|
212
|
+
|
|
213
|
+
jobs:
|
|
214
|
+
agent-proposal:
|
|
215
|
+
runs-on: ubuntu-latest
|
|
216
|
+
steps:
|
|
217
|
+
- name: Capture untrusted event text as data
|
|
218
|
+
env:
|
|
219
|
+
UNTRUSTED_TEXT: \${{ github.event.comment.body || github.event.issue.body || github.event.pull_request.body }}
|
|
220
|
+
run: |
|
|
221
|
+
printf '%s\\n' "$UNTRUSTED_TEXT" > untrusted-input.txt
|
|
222
|
+
- name: Run agent in suggestion mode
|
|
223
|
+
run: |
|
|
224
|
+
your-agent --input untrusted-input.txt --output proposal.json --mode suggest
|
|
225
|
+
- uses: actions/upload-artifact@v4
|
|
226
|
+
with:
|
|
227
|
+
name: agent-proposal
|
|
228
|
+
path: proposal.json
|
|
229
|
+
|
|
230
|
+
validate-and-apply:
|
|
231
|
+
needs: agent-proposal
|
|
232
|
+
runs-on: ubuntu-latest
|
|
233
|
+
${applyGate}
|
|
234
|
+
permissions:
|
|
235
|
+
contents: read
|
|
236
|
+
issues: write
|
|
237
|
+
pull-requests: write
|
|
238
|
+
steps:
|
|
239
|
+
- name: Validate structured proposal before applying
|
|
240
|
+
run: |
|
|
241
|
+
./scripts/validate-agent-proposal.js proposal.json
|
|
242
|
+
./scripts/apply-allowed-github-operation.js proposal.json`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function groupBy(values, keyFn) {
|
|
246
|
+
const groups = new Map();
|
|
247
|
+
for (const value of values) {
|
|
248
|
+
const key = keyFn(value);
|
|
249
|
+
const group = groups.get(key) || [];
|
|
250
|
+
group.push(value);
|
|
251
|
+
groups.set(key, group);
|
|
252
|
+
}
|
|
253
|
+
return groups;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function escapeMarkdown(value) {
|
|
257
|
+
return String(value).replaceAll('|', '\\|');
|
|
258
|
+
}
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const presetCatalog = {
|
|
2
|
+
strict: {
|
|
3
|
+
rules: {
|
|
4
|
+
AWG001: 'critical',
|
|
5
|
+
AWG002: 'critical',
|
|
6
|
+
AWG004: 'critical',
|
|
7
|
+
AWG005: 'critical',
|
|
8
|
+
AWG006: 'critical',
|
|
9
|
+
AWG008: 'high',
|
|
10
|
+
AWG010: 'medium'
|
|
11
|
+
},
|
|
12
|
+
suppressions: {
|
|
13
|
+
minimumReasonLength: 25
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
'claude-code': {
|
|
17
|
+
rules: {
|
|
18
|
+
AWG001: 'critical',
|
|
19
|
+
AWG006: 'critical'
|
|
20
|
+
},
|
|
21
|
+
suppressions: {
|
|
22
|
+
allowedRules: ['AWG001', 'AWG002', 'AWG008'],
|
|
23
|
+
minimumReasonLength: 20
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
codex: {
|
|
27
|
+
rules: {
|
|
28
|
+
AWG001: 'critical',
|
|
29
|
+
AWG002: 'critical',
|
|
30
|
+
AWG006: 'high'
|
|
31
|
+
},
|
|
32
|
+
suppressions: {
|
|
33
|
+
allowedRules: ['AWG001', 'AWG002', 'AWG008'],
|
|
34
|
+
minimumReasonLength: 20
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
aider: {
|
|
38
|
+
rules: {
|
|
39
|
+
AWG001: 'high',
|
|
40
|
+
AWG004: 'critical',
|
|
41
|
+
AWG005: 'critical'
|
|
42
|
+
},
|
|
43
|
+
suppressions: {
|
|
44
|
+
minimumReasonLength: 15
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
'triage-bot': {
|
|
48
|
+
rules: {
|
|
49
|
+
AWG001: 'critical',
|
|
50
|
+
AWG004: 'critical',
|
|
51
|
+
AWG005: 'critical',
|
|
52
|
+
AWG006: 'critical'
|
|
53
|
+
},
|
|
54
|
+
suppressions: {
|
|
55
|
+
allowedRules: ['AWG001', 'AWG002'],
|
|
56
|
+
minimumReasonLength: 30
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function getPreset(name) {
|
|
62
|
+
return presetCatalog[name];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function listPresetNames() {
|
|
66
|
+
return Object.keys(presetCatalog);
|
|
67
|
+
}
|