agentci-guard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/SECURITY.md +23 -0
- package/action.yml +42 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +718 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +135 -0
- package/dist/index.js +638 -0
- package/dist/index.js.map +1 -0
- package/docs/demo.svg +47 -0
- package/docs/demo.tape +34 -0
- package/docs/real-world-findings.md +79 -0
- package/docs/rules.md +33 -0
- package/docs/threat-model.md +32 -0
- package/examples/hardened/.github/workflows/ai-agent.yml +18 -0
- package/examples/vulnerable/.github/workflows/ai-agent.yml +29 -0
- package/package.json +71 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
var EMPTY = { ignore: [], ignorePaths: [] };
|
|
5
|
+
var CONFIG_FILENAMES = ["agentci.config.json", ".agentcirc.json"];
|
|
6
|
+
async function loadConfig(root, explicitPath) {
|
|
7
|
+
const candidates = explicitPath ? [path.resolve(explicitPath)] : CONFIG_FILENAMES.map((name) => path.join(root, name));
|
|
8
|
+
for (const file of candidates) {
|
|
9
|
+
let raw;
|
|
10
|
+
try {
|
|
11
|
+
raw = await fs.readFile(file, "utf8");
|
|
12
|
+
} catch {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const parsed = JSON.parse(raw);
|
|
16
|
+
return {
|
|
17
|
+
ignore: toStringArray(parsed.ignore),
|
|
18
|
+
ignorePaths: toStringArray(parsed.ignorePaths)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return EMPTY;
|
|
22
|
+
}
|
|
23
|
+
function toStringArray(value) {
|
|
24
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
25
|
+
}
|
|
26
|
+
function parseInlineIgnores(raw) {
|
|
27
|
+
const rules = /* @__PURE__ */ new Set();
|
|
28
|
+
let all = false;
|
|
29
|
+
for (const line of raw.split("\n")) {
|
|
30
|
+
if (/#\s*agentci-ignore-all\b/i.test(line)) {
|
|
31
|
+
all = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const match = /#\s*agentci-ignore\s+([^\n]+)/i.exec(line);
|
|
35
|
+
if (!match) continue;
|
|
36
|
+
const spec = match[1].split("--")[0];
|
|
37
|
+
for (const id of spec.split(/[\s,]+/)) {
|
|
38
|
+
if (id) rules.add(id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { all, rules };
|
|
42
|
+
}
|
|
43
|
+
function matchesPath(glob, target) {
|
|
44
|
+
const pattern = glob.split("**").map(
|
|
45
|
+
(part) => part.split("*").map((segment) => segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*")
|
|
46
|
+
).join(".*");
|
|
47
|
+
return new RegExp(`^${pattern}$`).test(target);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/detect.ts
|
|
51
|
+
var AI_AGENT_PATTERNS = [
|
|
52
|
+
// Known AI coding-agent GitHub Actions (matched in `uses:`)
|
|
53
|
+
/anthropics\/claude-code(?:-base)?-action/i,
|
|
54
|
+
/\banthropics\/[\w.-]*claude/i,
|
|
55
|
+
/\baider-ai\/aider\b/i,
|
|
56
|
+
/\bsweepai\//i,
|
|
57
|
+
/(?:all-hands-ai|opendevin)\/(?:openhands|opendevin)/i,
|
|
58
|
+
/\bcontinuedev\//i,
|
|
59
|
+
/\bblock\/goose\b|\bgoose-ai\//i,
|
|
60
|
+
/\bgithub\/copilot[\w-]*agent/i,
|
|
61
|
+
/\bopenai\/codex[\w-]*/i,
|
|
62
|
+
// Agent CLIs / tools (product names specific enough to be low false-positive)
|
|
63
|
+
/\bclaude-code\b/i,
|
|
64
|
+
/@anthropic-ai\/claude-code\b/i,
|
|
65
|
+
/\bclaude\b/i,
|
|
66
|
+
// CLI binary + product name; rare as a literal token in non-AI CI
|
|
67
|
+
/\baider\b/i,
|
|
68
|
+
/\bchatgpt\b/i,
|
|
69
|
+
/\bcodex\s+(?:exec|run|--)/i,
|
|
70
|
+
// openai codex CLI (bare "codex" is too generic)
|
|
71
|
+
/\bollama\s+run\b/i,
|
|
72
|
+
/\bcursor-agent\b/i,
|
|
73
|
+
/\bllm\s+(?:-m|--model)\b/i,
|
|
74
|
+
// simonw llm CLI (bare "llm" is too generic)
|
|
75
|
+
// Provider credentials / endpoints / SDKs / model identifiers
|
|
76
|
+
/\bANTHROPIC_API_KEY\b/i,
|
|
77
|
+
/\bOPENAI_API_KEY\b/i,
|
|
78
|
+
/\bGEMINI_API_KEY\b/i,
|
|
79
|
+
/api\.(?:anthropic|openai)\.com/i,
|
|
80
|
+
/@anthropic-ai\//i,
|
|
81
|
+
/\bclaude-(?:3|4|opus|sonnet|haiku)\b/i,
|
|
82
|
+
/\bgpt-(?:4|4o|5)\b/i,
|
|
83
|
+
// Explicit agent phrasing
|
|
84
|
+
/\bai[ -]agents?\b/i,
|
|
85
|
+
/\bcoding agents?\b/i,
|
|
86
|
+
/\bautonomous agents?\b/i,
|
|
87
|
+
/\bllm[ -]agents?\b/i,
|
|
88
|
+
/\bmodel context protocol\b/i
|
|
89
|
+
];
|
|
90
|
+
var UNTRUSTED_CONTEXT_PATTERNS = [
|
|
91
|
+
/github\.event\.pull_request\.(body|title|head\.ref|head\.sha)/i,
|
|
92
|
+
/github\.event\.issue\.(body|title)/i,
|
|
93
|
+
/github\.event\.comment\.body/i,
|
|
94
|
+
/github\.event\.review\.body/i,
|
|
95
|
+
/github\.event\.head_commit\.message/i,
|
|
96
|
+
/github\.head_ref/i,
|
|
97
|
+
/github\.ref_name/i
|
|
98
|
+
];
|
|
99
|
+
var SECRET_PATTERNS = [
|
|
100
|
+
/secrets\./i,
|
|
101
|
+
/GITHUB_TOKEN/i,
|
|
102
|
+
/\b[A-Z0-9_]*TOKEN\b/,
|
|
103
|
+
/\b[A-Z0-9_]*KEY\b/
|
|
104
|
+
];
|
|
105
|
+
var SHELL_PATTERNS = [
|
|
106
|
+
/\bshell\b/i,
|
|
107
|
+
/\bbash\b/i,
|
|
108
|
+
/\bsh\b/i,
|
|
109
|
+
/\bcurl\b/i,
|
|
110
|
+
/\bwget\b/i,
|
|
111
|
+
/\bnpx\b/i,
|
|
112
|
+
/\bpython\b/i,
|
|
113
|
+
/\bnode\b/i,
|
|
114
|
+
/\bexec\b/i
|
|
115
|
+
];
|
|
116
|
+
function looksLikeAiUsage(value) {
|
|
117
|
+
return AI_AGENT_PATTERNS.some((pattern) => pattern.test(value));
|
|
118
|
+
}
|
|
119
|
+
function containsUntrustedGitHubContext(value) {
|
|
120
|
+
return UNTRUSTED_CONTEXT_PATTERNS.some((pattern) => pattern.test(value));
|
|
121
|
+
}
|
|
122
|
+
function containsSecretReference(value) {
|
|
123
|
+
return SECRET_PATTERNS.some((pattern) => pattern.test(value));
|
|
124
|
+
}
|
|
125
|
+
function containsShellAccess(value) {
|
|
126
|
+
return SHELL_PATTERNS.some((pattern) => pattern.test(value));
|
|
127
|
+
}
|
|
128
|
+
function isPinnedAction(uses) {
|
|
129
|
+
const ref = uses.split("@")[1];
|
|
130
|
+
return Boolean(ref && /^[a-f0-9]{40}$/i.test(ref));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/report.ts
|
|
134
|
+
import pc from "picocolors";
|
|
135
|
+
function formatGithubOutputs(result, sarifPath) {
|
|
136
|
+
return [
|
|
137
|
+
`findings=${result.findings.length}`,
|
|
138
|
+
`critical=${result.summary.critical}`,
|
|
139
|
+
`high=${result.summary.high}`,
|
|
140
|
+
`medium=${result.summary.medium}`,
|
|
141
|
+
`low=${result.summary.low}`,
|
|
142
|
+
`sarif-path=${sarifPath ?? ""}`
|
|
143
|
+
].join("\n") + "\n";
|
|
144
|
+
}
|
|
145
|
+
function renderTextReport(result) {
|
|
146
|
+
const lines = [
|
|
147
|
+
"AgentCI Guard scan",
|
|
148
|
+
`Workflows: ${result.workflow_count}`,
|
|
149
|
+
`Findings: ${result.findings.length}`,
|
|
150
|
+
`Summary: critical=${result.summary.critical} high=${result.summary.high} medium=${result.summary.medium} low=${result.summary.low}`,
|
|
151
|
+
""
|
|
152
|
+
];
|
|
153
|
+
for (const finding of result.findings) {
|
|
154
|
+
lines.push(`${label(finding.severity)} ${finding.rule_id}`);
|
|
155
|
+
lines.push(
|
|
156
|
+
`File: ${finding.file}${finding.job ? ` / job: ${finding.job}` : ""}${finding.step ? ` / step: ${finding.step}` : ""}`
|
|
157
|
+
);
|
|
158
|
+
lines.push(`Evidence: ${finding.evidence}`);
|
|
159
|
+
lines.push(`Why: ${finding.why}`);
|
|
160
|
+
lines.push("Fix:");
|
|
161
|
+
for (const fix of finding.fix) lines.push(`- ${fix}`);
|
|
162
|
+
lines.push("");
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
function renderMarkdownReport(result) {
|
|
167
|
+
return [
|
|
168
|
+
"# AgentCI Guard Scan",
|
|
169
|
+
"",
|
|
170
|
+
`- Workflows: ${result.workflow_count}`,
|
|
171
|
+
`- Findings: ${result.findings.length}`,
|
|
172
|
+
`- Critical: ${result.summary.critical}`,
|
|
173
|
+
`- High: ${result.summary.high}`,
|
|
174
|
+
`- Medium: ${result.summary.medium}`,
|
|
175
|
+
`- Low: ${result.summary.low}`,
|
|
176
|
+
"",
|
|
177
|
+
...result.findings.flatMap(renderFindingMarkdown),
|
|
178
|
+
""
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
function renderFindingMarkdown(finding) {
|
|
182
|
+
return [
|
|
183
|
+
`## ${finding.severity.toUpperCase()} ${finding.rule_id}`,
|
|
184
|
+
"",
|
|
185
|
+
`**File:** ${finding.file}`,
|
|
186
|
+
finding.job ? `**Job:** ${finding.job}` : "",
|
|
187
|
+
finding.step ? `**Step:** ${finding.step}` : "",
|
|
188
|
+
`**Evidence:** \`${finding.evidence.replace(/`/g, "'")}\``,
|
|
189
|
+
"",
|
|
190
|
+
finding.why,
|
|
191
|
+
"",
|
|
192
|
+
"**Fix:**",
|
|
193
|
+
"",
|
|
194
|
+
...finding.fix.map((fix) => `- ${fix}`),
|
|
195
|
+
""
|
|
196
|
+
].filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
function label(severity) {
|
|
199
|
+
if (severity === "critical") return pc.red("[CRITICAL]");
|
|
200
|
+
if (severity === "high") return pc.red("[HIGH]");
|
|
201
|
+
if (severity === "medium") return pc.yellow("[MEDIUM]");
|
|
202
|
+
return pc.cyan("[LOW]");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// src/rules.ts
|
|
206
|
+
var RULES = {
|
|
207
|
+
"agentci/untrusted-ai-write-token": {
|
|
208
|
+
id: "agentci/untrusted-ai-write-token",
|
|
209
|
+
title: "Untrusted event content can reach an AI agent with write permissions",
|
|
210
|
+
severity: "critical",
|
|
211
|
+
why: "An attacker can place prompt-injection text in a PR, issue, or comment. If that text reaches an AI agent with repository write permissions, the agent can be induced to modify code, comments, workflows, or releases.",
|
|
212
|
+
fix: [
|
|
213
|
+
"Do not run privileged AI agents on untrusted triggers.",
|
|
214
|
+
"Use read-only GITHUB_TOKEN permissions for untrusted events.",
|
|
215
|
+
"Require maintainer approval before running the agent.",
|
|
216
|
+
"Sanitize and summarize untrusted content before passing it to an agent."
|
|
217
|
+
]
|
|
218
|
+
},
|
|
219
|
+
"agentci/pull-request-target-ai": {
|
|
220
|
+
id: "agentci/pull-request-target-ai",
|
|
221
|
+
title: "AI agent runs on pull_request_target",
|
|
222
|
+
severity: "critical",
|
|
223
|
+
why: "pull_request_target runs in the base repository security context and can expose write tokens or secrets to workflows influenced by an untrusted pull request.",
|
|
224
|
+
fix: [
|
|
225
|
+
"Use pull_request with read-only permissions for untrusted code.",
|
|
226
|
+
"Split analysis into a read-only job and a separate maintainer-approved write job.",
|
|
227
|
+
"Avoid checking out untrusted PR head code in pull_request_target."
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
"agentci/ai-with-secrets": {
|
|
231
|
+
id: "agentci/ai-with-secrets",
|
|
232
|
+
title: "AI agent job has access to secrets",
|
|
233
|
+
severity: "medium",
|
|
234
|
+
why: "Secrets mounted into an AI-agent job can be exfiltrated if untrusted prompt content influences tool use, shell commands, or generated output. Most AI actions require a provider key, so this is a baseline exposure to review rather than a vulnerability on its own \u2014 it becomes high-risk when combined with untrusted input or write permissions (see agentci/untrusted-ai-write-token).",
|
|
235
|
+
fix: [
|
|
236
|
+
"Do not expose secrets to agent jobs that process untrusted content.",
|
|
237
|
+
"Use short-lived scoped tokens.",
|
|
238
|
+
"Move secret-bearing actions behind manual approval."
|
|
239
|
+
]
|
|
240
|
+
},
|
|
241
|
+
"agentci/untrusted-input-in-prompt": {
|
|
242
|
+
id: "agentci/untrusted-input-in-prompt",
|
|
243
|
+
title: "Untrusted GitHub event content is passed into an AI prompt or command",
|
|
244
|
+
severity: "high",
|
|
245
|
+
why: "PR bodies, issue bodies, comments, branch names, and commit messages are attacker-controlled in common workflows and can contain prompt-injection instructions.",
|
|
246
|
+
fix: [
|
|
247
|
+
"Avoid inserting raw GitHub event text into prompts.",
|
|
248
|
+
"Use structured extraction and length limits.",
|
|
249
|
+
"Add prompt-injection filtering before AI execution.",
|
|
250
|
+
"Run the agent with read-only permissions."
|
|
251
|
+
]
|
|
252
|
+
},
|
|
253
|
+
"agentci/ai-shell-access": {
|
|
254
|
+
id: "agentci/ai-shell-access",
|
|
255
|
+
title: "AI agent has shell or arbitrary command access",
|
|
256
|
+
severity: "high",
|
|
257
|
+
why: "Shell access allows a compromised agent prompt to inspect the workspace, call network endpoints, or alter build artifacts.",
|
|
258
|
+
fix: [
|
|
259
|
+
"Disable shell tools for untrusted events.",
|
|
260
|
+
"Run in a sandbox with no secrets.",
|
|
261
|
+
"Restrict network and filesystem access."
|
|
262
|
+
]
|
|
263
|
+
},
|
|
264
|
+
"agentci/broad-write-permissions": {
|
|
265
|
+
id: "agentci/broad-write-permissions",
|
|
266
|
+
title: "Workflow grants broad write permissions near AI usage",
|
|
267
|
+
severity: "medium",
|
|
268
|
+
why: "Broad write scopes increase blast radius if an AI-agent step is influenced by untrusted input.",
|
|
269
|
+
fix: [
|
|
270
|
+
"Set default permissions to read-only.",
|
|
271
|
+
"Grant write scopes only in narrowly scoped jobs.",
|
|
272
|
+
"Prefer job-level permissions over workflow-level write permissions."
|
|
273
|
+
]
|
|
274
|
+
},
|
|
275
|
+
"agentci/unpinned-ai-action": {
|
|
276
|
+
id: "agentci/unpinned-ai-action",
|
|
277
|
+
title: "AI-related action is not pinned to a commit SHA",
|
|
278
|
+
severity: "medium",
|
|
279
|
+
why: "Tag-pinned third-party actions can change over time. AI-agent actions often receive privileged context, so supply-chain drift matters.",
|
|
280
|
+
fix: [
|
|
281
|
+
"Pin third-party actions to full commit SHAs.",
|
|
282
|
+
"Review updates explicitly.",
|
|
283
|
+
"Prefer first-party or internally mirrored actions for privileged jobs."
|
|
284
|
+
]
|
|
285
|
+
},
|
|
286
|
+
"agentci/unsafe-checkout": {
|
|
287
|
+
id: "agentci/unsafe-checkout",
|
|
288
|
+
title: "Workflow checks out untrusted pull request head in a privileged context",
|
|
289
|
+
severity: "high",
|
|
290
|
+
why: "Checking out attacker-controlled code in a privileged workflow can let malicious build scripts or configuration affect the agent job.",
|
|
291
|
+
fix: [
|
|
292
|
+
"Do not checkout PR head code inside pull_request_target.",
|
|
293
|
+
"Use read-only analysis jobs.",
|
|
294
|
+
"Disable install/build scripts before trust is established."
|
|
295
|
+
]
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
var SEVERITY_ORDER = ["low", "medium", "high", "critical"];
|
|
299
|
+
|
|
300
|
+
// src/sarif.ts
|
|
301
|
+
function toSarif(findings) {
|
|
302
|
+
const usedRules = Object.values(RULES).filter(
|
|
303
|
+
(rule) => findings.some((finding) => finding.rule_id === rule.id)
|
|
304
|
+
);
|
|
305
|
+
return {
|
|
306
|
+
version: "2.1.0",
|
|
307
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
308
|
+
runs: [
|
|
309
|
+
{
|
|
310
|
+
tool: {
|
|
311
|
+
driver: {
|
|
312
|
+
name: "AgentCI Guard",
|
|
313
|
+
informationUri: "https://github.com/David-Wu1119/agentci-guard",
|
|
314
|
+
rules: usedRules.map((rule) => ({
|
|
315
|
+
id: rule.id,
|
|
316
|
+
name: rule.title,
|
|
317
|
+
shortDescription: { text: rule.title },
|
|
318
|
+
fullDescription: { text: rule.why },
|
|
319
|
+
help: {
|
|
320
|
+
text: rule.fix.join(" "),
|
|
321
|
+
markdown: rule.fix.map((fix) => `- ${fix}`).join("\n")
|
|
322
|
+
},
|
|
323
|
+
defaultConfiguration: { level: sarifLevel(rule.severity) }
|
|
324
|
+
}))
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
results: findings.map((finding) => ({
|
|
328
|
+
ruleId: finding.rule_id,
|
|
329
|
+
level: sarifLevel(finding.severity),
|
|
330
|
+
message: { text: `${finding.title}: ${finding.evidence}` },
|
|
331
|
+
locations: [
|
|
332
|
+
{
|
|
333
|
+
physicalLocation: {
|
|
334
|
+
artifactLocation: { uri: finding.file },
|
|
335
|
+
region: { startLine: 1 }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
]
|
|
339
|
+
}))
|
|
340
|
+
}
|
|
341
|
+
]
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
function sarifLevel(severity) {
|
|
345
|
+
if (severity === "critical" || severity === "high") return "error";
|
|
346
|
+
if (severity === "medium") return "warning";
|
|
347
|
+
return "note";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/scanner.ts
|
|
351
|
+
import path2 from "path";
|
|
352
|
+
import fg from "fast-glob";
|
|
353
|
+
import YAML from "yaml";
|
|
354
|
+
import fs2 from "fs/promises";
|
|
355
|
+
async function scanRepository(root, options = {}) {
|
|
356
|
+
const cwd = options.cwd ?? process.cwd();
|
|
357
|
+
const scanRoot = path2.resolve(cwd, root);
|
|
358
|
+
const config = await loadConfig(scanRoot, options.configPath);
|
|
359
|
+
const workflows = await loadWorkflowFiles(scanRoot);
|
|
360
|
+
const findings = workflows.flatMap((workflow) => scanWorkflow(workflow, scanRoot)).filter(
|
|
361
|
+
(finding) => !config.ignore.includes(finding.rule_id) && !config.ignorePaths.some((glob) => matchesPath(glob, finding.file))
|
|
362
|
+
);
|
|
363
|
+
return {
|
|
364
|
+
scanned_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
365
|
+
root: scanRoot,
|
|
366
|
+
workflow_count: workflows.length,
|
|
367
|
+
findings,
|
|
368
|
+
summary: summarize(findings)
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async function loadWorkflowFiles(root) {
|
|
372
|
+
const entries = await fg([".github/workflows/*.{yml,yaml}"], {
|
|
373
|
+
cwd: root,
|
|
374
|
+
dot: true,
|
|
375
|
+
absolute: true
|
|
376
|
+
});
|
|
377
|
+
const workflows = [];
|
|
378
|
+
for (const file of entries.sort()) {
|
|
379
|
+
const raw = await fs2.readFile(file, "utf8");
|
|
380
|
+
try {
|
|
381
|
+
workflows.push({ path: file, raw, document: YAML.parse(raw) });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
workflows.push({
|
|
384
|
+
path: file,
|
|
385
|
+
raw,
|
|
386
|
+
document: {
|
|
387
|
+
__parse_error: error instanceof Error ? error.message : String(error)
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return workflows;
|
|
393
|
+
}
|
|
394
|
+
function scanWorkflow(workflow, root) {
|
|
395
|
+
const doc = isRecord(workflow.document) ? workflow.document : {};
|
|
396
|
+
const file = path2.relative(root, workflow.path);
|
|
397
|
+
const findings = [];
|
|
398
|
+
if ("__parse_error" in doc) {
|
|
399
|
+
findings.push(
|
|
400
|
+
makeFinding("agentci/untrusted-input-in-prompt", {
|
|
401
|
+
file,
|
|
402
|
+
evidence: `YAML parse error: ${String(doc.__parse_error)}`
|
|
403
|
+
})
|
|
404
|
+
);
|
|
405
|
+
return findings;
|
|
406
|
+
}
|
|
407
|
+
const triggers = normalizeTriggers(doc.on ?? doc["on"]);
|
|
408
|
+
const jobs = isRecord(doc.jobs) ? doc.jobs : {};
|
|
409
|
+
const workflowPermissions = normalizePermissions(doc.permissions);
|
|
410
|
+
const workflowIsUntrusted = triggers.some(isUntrustedTrigger);
|
|
411
|
+
const isPullRequestTarget = triggers.includes("pull_request_target");
|
|
412
|
+
for (const [jobName, rawJob] of Object.entries(jobs)) {
|
|
413
|
+
if (!isRecord(rawJob)) continue;
|
|
414
|
+
const jobPermissions = {
|
|
415
|
+
...workflowPermissions,
|
|
416
|
+
...normalizePermissions(rawJob.permissions)
|
|
417
|
+
};
|
|
418
|
+
const jobText = JSON.stringify(rawJob);
|
|
419
|
+
const steps = Array.isArray(rawJob.steps) ? rawJob.steps : [];
|
|
420
|
+
const jobUsesAi = looksLikeAiUsage(jobText);
|
|
421
|
+
const jobHasWrite = hasWritePermission(jobPermissions);
|
|
422
|
+
const jobHasSecrets = containsSecretReference(jobText);
|
|
423
|
+
const jobHasUntrusted = containsUntrustedGitHubContext(
|
|
424
|
+
JSON.stringify(stripGuards(rawJob))
|
|
425
|
+
);
|
|
426
|
+
if (jobUsesAi && isPullRequestTarget) {
|
|
427
|
+
findings.push(
|
|
428
|
+
makeFinding("agentci/pull-request-target-ai", {
|
|
429
|
+
file,
|
|
430
|
+
job: jobName,
|
|
431
|
+
evidence: "on: pull_request_target + AI usage"
|
|
432
|
+
})
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
if (jobUsesAi && workflowIsUntrusted && jobHasWrite && jobHasUntrusted) {
|
|
436
|
+
findings.push(
|
|
437
|
+
makeFinding("agentci/untrusted-ai-write-token", {
|
|
438
|
+
file,
|
|
439
|
+
job: jobName,
|
|
440
|
+
evidence: "untrusted trigger + AI usage + write permissions + untrusted GitHub event context"
|
|
441
|
+
})
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (jobUsesAi && jobHasSecrets) {
|
|
445
|
+
findings.push(
|
|
446
|
+
makeFinding("agentci/ai-with-secrets", {
|
|
447
|
+
file,
|
|
448
|
+
job: jobName,
|
|
449
|
+
evidence: "AI job references secrets or token-like environment variables"
|
|
450
|
+
})
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (jobUsesAi && jobHasWrite) {
|
|
454
|
+
findings.push(
|
|
455
|
+
makeFinding("agentci/broad-write-permissions", {
|
|
456
|
+
file,
|
|
457
|
+
job: jobName,
|
|
458
|
+
evidence: `permissions: ${JSON.stringify(jobPermissions)}`
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
for (const [index, rawStep] of steps.entries()) {
|
|
463
|
+
if (!isRecord(rawStep)) continue;
|
|
464
|
+
const stepName = typeof rawStep.name === "string" ? rawStep.name : `step ${index + 1}`;
|
|
465
|
+
const stepText = JSON.stringify(rawStep);
|
|
466
|
+
const stepUses = typeof rawStep.uses === "string" ? rawStep.uses : "";
|
|
467
|
+
const stepRun = typeof rawStep.run === "string" ? rawStep.run : "";
|
|
468
|
+
const stepUsesAi = looksLikeAiUsage(stepText);
|
|
469
|
+
const stepUntrustedText = JSON.stringify(stripGuards(rawStep));
|
|
470
|
+
if (stepUsesAi && containsUntrustedGitHubContext(stepUntrustedText)) {
|
|
471
|
+
findings.push(
|
|
472
|
+
makeFinding("agentci/untrusted-input-in-prompt", {
|
|
473
|
+
file,
|
|
474
|
+
job: jobName,
|
|
475
|
+
step: stepName,
|
|
476
|
+
evidence: shrink(stepText)
|
|
477
|
+
})
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
if (stepUsesAi && (containsShellAccess(stepRun) || containsShellAccess(stepText))) {
|
|
481
|
+
findings.push(
|
|
482
|
+
makeFinding("agentci/ai-shell-access", {
|
|
483
|
+
file,
|
|
484
|
+
job: jobName,
|
|
485
|
+
step: stepName,
|
|
486
|
+
evidence: shrink(stepText)
|
|
487
|
+
})
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
if (stepUsesAi && stepUses && !isPinnedAction(stepUses) && !isLocalAction(stepUses)) {
|
|
491
|
+
findings.push(
|
|
492
|
+
makeFinding("agentci/unpinned-ai-action", {
|
|
493
|
+
file,
|
|
494
|
+
job: jobName,
|
|
495
|
+
step: stepName,
|
|
496
|
+
evidence: `uses: ${stepUses}`
|
|
497
|
+
})
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
if (isPullRequestTarget && stepUses.includes("actions/checkout") && stepText.includes("github.event.pull_request.head")) {
|
|
501
|
+
findings.push(
|
|
502
|
+
makeFinding("agentci/unsafe-checkout", {
|
|
503
|
+
file,
|
|
504
|
+
job: jobName,
|
|
505
|
+
step: stepName,
|
|
506
|
+
evidence: shrink(stepText)
|
|
507
|
+
})
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const ignores = parseInlineIgnores(workflow.raw);
|
|
513
|
+
const visible = ignores.all ? [] : findings.filter((finding) => !ignores.rules.has(finding.rule_id));
|
|
514
|
+
return dedupe(visible);
|
|
515
|
+
}
|
|
516
|
+
function hasFindingAtOrAbove(findings, severity) {
|
|
517
|
+
return findings.some(
|
|
518
|
+
(finding) => SEVERITY_ORDER.indexOf(finding.severity) >= SEVERITY_ORDER.indexOf(severity)
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
function makeFinding(ruleId, context) {
|
|
522
|
+
const rule = RULES[ruleId];
|
|
523
|
+
if (!rule) throw new Error(`Unknown rule: ${ruleId}`);
|
|
524
|
+
const id = `${ruleId}:${context.file}:${context.job ?? ""}:${context.step ?? ""}`;
|
|
525
|
+
return {
|
|
526
|
+
id,
|
|
527
|
+
rule_id: rule.id,
|
|
528
|
+
title: rule.title,
|
|
529
|
+
severity: rule.severity,
|
|
530
|
+
file: context.file,
|
|
531
|
+
job: context.job,
|
|
532
|
+
step: context.step,
|
|
533
|
+
message: rule.title,
|
|
534
|
+
why: rule.why,
|
|
535
|
+
fix: rule.fix,
|
|
536
|
+
evidence: context.evidence
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function normalizeTriggers(raw) {
|
|
540
|
+
if (typeof raw === "string") return [raw];
|
|
541
|
+
if (Array.isArray(raw))
|
|
542
|
+
return raw.filter((item) => typeof item === "string");
|
|
543
|
+
if (isRecord(raw)) return Object.keys(raw);
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
function isUntrustedTrigger(trigger) {
|
|
547
|
+
return [
|
|
548
|
+
"pull_request",
|
|
549
|
+
"pull_request_target",
|
|
550
|
+
"issue_comment",
|
|
551
|
+
"issues",
|
|
552
|
+
"discussion",
|
|
553
|
+
"discussion_comment",
|
|
554
|
+
"workflow_run"
|
|
555
|
+
].includes(trigger);
|
|
556
|
+
}
|
|
557
|
+
function normalizePermissions(raw) {
|
|
558
|
+
if (typeof raw === "string") return { contents: raw };
|
|
559
|
+
if (!isRecord(raw)) return {};
|
|
560
|
+
return Object.fromEntries(
|
|
561
|
+
Object.entries(raw).filter(
|
|
562
|
+
(entry) => typeof entry[1] === "string"
|
|
563
|
+
)
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
var SENSITIVE_WRITE_SCOPES = /* @__PURE__ */ new Set([
|
|
567
|
+
"contents",
|
|
568
|
+
"pull-requests",
|
|
569
|
+
"issues",
|
|
570
|
+
"packages",
|
|
571
|
+
"deployments"
|
|
572
|
+
]);
|
|
573
|
+
function hasWritePermission(permissions) {
|
|
574
|
+
return Object.entries(permissions).some(([scope, level]) => {
|
|
575
|
+
if (level !== "write" && level !== "write-all") return false;
|
|
576
|
+
return level === "write-all" || SENSITIVE_WRITE_SCOPES.has(scope);
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function stripGuards(value) {
|
|
580
|
+
if (Array.isArray(value)) return value.map(stripGuards);
|
|
581
|
+
if (isRecord(value)) {
|
|
582
|
+
const out = {};
|
|
583
|
+
for (const [key, val] of Object.entries(value)) {
|
|
584
|
+
if (key === "if") continue;
|
|
585
|
+
out[key] = stripGuards(val);
|
|
586
|
+
}
|
|
587
|
+
return out;
|
|
588
|
+
}
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
591
|
+
function isRecord(value) {
|
|
592
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
593
|
+
}
|
|
594
|
+
function isLocalAction(uses) {
|
|
595
|
+
return uses.startsWith("./") || uses.startsWith("docker://");
|
|
596
|
+
}
|
|
597
|
+
function shrink(value) {
|
|
598
|
+
return value.length > 500 ? `${value.slice(0, 500)}...` : value;
|
|
599
|
+
}
|
|
600
|
+
function dedupe(findings) {
|
|
601
|
+
const seen = /* @__PURE__ */ new Set();
|
|
602
|
+
return findings.filter((finding) => {
|
|
603
|
+
const key = `${finding.rule_id}:${finding.file}:${finding.job ?? ""}:${finding.step ?? ""}`;
|
|
604
|
+
if (seen.has(key)) return false;
|
|
605
|
+
seen.add(key);
|
|
606
|
+
return true;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
function summarize(findings) {
|
|
610
|
+
return {
|
|
611
|
+
low: findings.filter((finding) => finding.severity === "low").length,
|
|
612
|
+
medium: findings.filter((finding) => finding.severity === "medium").length,
|
|
613
|
+
high: findings.filter((finding) => finding.severity === "high").length,
|
|
614
|
+
critical: findings.filter((finding) => finding.severity === "critical").length
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
export {
|
|
618
|
+
AI_AGENT_PATTERNS,
|
|
619
|
+
RULES,
|
|
620
|
+
SEVERITY_ORDER,
|
|
621
|
+
containsSecretReference,
|
|
622
|
+
containsShellAccess,
|
|
623
|
+
containsUntrustedGitHubContext,
|
|
624
|
+
formatGithubOutputs,
|
|
625
|
+
hasFindingAtOrAbove,
|
|
626
|
+
isPinnedAction,
|
|
627
|
+
loadConfig,
|
|
628
|
+
loadWorkflowFiles,
|
|
629
|
+
looksLikeAiUsage,
|
|
630
|
+
matchesPath,
|
|
631
|
+
parseInlineIgnores,
|
|
632
|
+
renderMarkdownReport,
|
|
633
|
+
renderTextReport,
|
|
634
|
+
scanRepository,
|
|
635
|
+
scanWorkflow,
|
|
636
|
+
toSarif
|
|
637
|
+
};
|
|
638
|
+
//# sourceMappingURL=index.js.map
|