@node9/proxy 1.58.5 โ 1.59.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/README.md +47 -0
- package/dist/cli.js +298 -17
- package/dist/cli.mjs +298 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,6 +68,53 @@ Findings are grouped by **who can fix them**: ๐ the ones node9 reduces (just
|
|
|
68
68
|
Track this across your fleet & keep it green โ node9.ai
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
## Scan a repo โ agent-CI security
|
|
72
|
+
|
|
73
|
+
`node9 scan-repo` checks any repo (or a local folder) for ways an AI agent wired into GitHub Actions could be **hijacked by an outsider** โ injectable workflows, agent-reachable secrets, unpinned MCP servers, over-broad agent config, and poisoned instruction files. Static and parse-only: it reads only committed config, never executes repo code. No install or token needed for public repos.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx node9-ai scan-repo <owner/repo> # any public repo, no install
|
|
77
|
+
node9 scan-repo . # a local checkout โ no network
|
|
78
|
+
node9 scan-repo <owner/repo> --json # machine-readable
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
๐ก๏ธ node9 scan-repo ยท node9-ai/agent-security-demo ยท โ ๏ธ agent-security risk found
|
|
83
|
+
inspected 2 config file(s), 2 finding(s)
|
|
84
|
+
|
|
85
|
+
๐ด CRITICAL Injectable agent workflow โ untrusted input reaches a tool-using agent with secrets
|
|
86
|
+
.github/workflows/vulnerable-example.yml ยท CI-2
|
|
87
|
+
โข runs with base-repo secrets (pull_request_target)
|
|
88
|
+
โข checks out the untrusted PR head into the workspace root
|
|
89
|
+
โข allowed_non_write_users: "*" โ any user can trigger the agent
|
|
90
|
+
โข no effective actor gate
|
|
91
|
+
|
|
92
|
+
๐ด CRITICAL Exfiltratable secrets reachable by an injectable agent
|
|
93
|
+
.github/workflows/vulnerable-example.yml ยท CI-4
|
|
94
|
+
โข agent has arbitrary shell (bare Bash) โ can read env and exfiltrate
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
What it checks:
|
|
98
|
+
|
|
99
|
+
| Check | Flags |
|
|
100
|
+
| -------- | ---------------------------------------------------------------------------- |
|
|
101
|
+
| **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks |
|
|
102
|
+
| **CI-2** | injectable agent workflows โ an outsider can trigger the agent and hijack it |
|
|
103
|
+
| **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain) |
|
|
104
|
+
| **CI-4** | secrets an injected agent could exfiltrate |
|
|
105
|
+
| **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` / skills |
|
|
106
|
+
|
|
107
|
+
**Gate every PR** โ the same engine as a GitHub Action, so a hijackable config can't get merged:
|
|
108
|
+
|
|
109
|
+
```yaml
|
|
110
|
+
# .github/workflows/agent-security.yml
|
|
111
|
+
- uses: node9-ai/agent-security-action@v1
|
|
112
|
+
with:
|
|
113
|
+
fail-on: high # or 'never' to just comment
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Marketplace: **[node9 Agent Security Check](https://github.com/marketplace/actions/node9-agent-security-check)**
|
|
117
|
+
|
|
71
118
|
## Live monitoring
|
|
72
119
|
|
|
73
120
|
<p align="center">
|
package/dist/cli.js
CHANGED
|
@@ -52974,7 +52974,15 @@ var SURFACE_FILES = [
|
|
|
52974
52974
|
".claude/settings.local.json",
|
|
52975
52975
|
".mcp.json",
|
|
52976
52976
|
".cursor/mcp.json",
|
|
52977
|
-
".codex/config.toml"
|
|
52977
|
+
".codex/config.toml",
|
|
52978
|
+
// CI-6: agent instruction files (auto-loaded into the agent's system prompt).
|
|
52979
|
+
"CLAUDE.md",
|
|
52980
|
+
"AGENTS.md",
|
|
52981
|
+
"GEMINI.md",
|
|
52982
|
+
".cursorrules",
|
|
52983
|
+
".github/copilot-instructions.md",
|
|
52984
|
+
".windsurfrules",
|
|
52985
|
+
".clinerules"
|
|
52978
52986
|
];
|
|
52979
52987
|
var WORKFLOW_DIR = ".github/workflows";
|
|
52980
52988
|
async function pooled(items, limit, fn) {
|
|
@@ -53164,18 +53172,32 @@ function isAgentStep(step) {
|
|
|
53164
53172
|
return true;
|
|
53165
53173
|
return false;
|
|
53166
53174
|
}
|
|
53175
|
+
function allowedToolsFromArgs(claudeArgs) {
|
|
53176
|
+
let out = "";
|
|
53177
|
+
for (const m of claudeArgs.matchAll(/--allowed[-_]?tools[=\s]+("[^"]*"|'[^']*'|\S+)/gi))
|
|
53178
|
+
out += " " + m[1];
|
|
53179
|
+
return out;
|
|
53180
|
+
}
|
|
53181
|
+
function allowedToolsFromSettings(settings) {
|
|
53182
|
+
let out = "";
|
|
53183
|
+
for (const key of ["allowedTools", "allow"]) {
|
|
53184
|
+
for (const m of settings.matchAll(new RegExp(`"${key}"\\s*:\\s*(\\[[^\\]]*\\])`, "gi")))
|
|
53185
|
+
out += " " + m[1];
|
|
53186
|
+
}
|
|
53187
|
+
return out;
|
|
53188
|
+
}
|
|
53167
53189
|
function collectTools(steps) {
|
|
53168
|
-
const stripDeny = (x) => x.replace(/--disallowed[-_]?tools\s+("[^"]*"|'[^']*'|\S+)/gi, " ").replace(/["']disallowed[_]?[tT]ools["']\s*:\s*\[[^\]]*\]/g, " ");
|
|
53169
53190
|
let s = "";
|
|
53170
53191
|
for (const st of steps) {
|
|
53171
53192
|
const w = st.with ?? {};
|
|
53172
|
-
s += " " +
|
|
53173
|
-
s += " " +
|
|
53193
|
+
s += " " + allowedToolsFromArgs(str(w["claude_args"]));
|
|
53194
|
+
s += " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
|
|
53195
|
+
s += " " + allowedToolsFromSettings(str(w["settings"]));
|
|
53174
53196
|
}
|
|
53175
53197
|
return s;
|
|
53176
53198
|
}
|
|
53177
|
-
function untrustedHeadCheckout(
|
|
53178
|
-
for (const
|
|
53199
|
+
function untrustedHeadCheckout(steps) {
|
|
53200
|
+
for (const step of steps) {
|
|
53179
53201
|
if (!step.uses || !/actions\/checkout/.test(step.uses)) continue;
|
|
53180
53202
|
const ref = str(step.with?.["ref"]);
|
|
53181
53203
|
if (/pull_request\.head|head[._]sha|head_ref|expected_head|workflow_run\.head|inputs\.[\w]*head/i.test(
|
|
@@ -53194,24 +53216,44 @@ function promptTakesUntrusted(steps) {
|
|
|
53194
53216
|
}
|
|
53195
53217
|
return false;
|
|
53196
53218
|
}
|
|
53197
|
-
|
|
53219
|
+
var ACTOR_GATE_RE = /author_association|FIRST_TIME_CONTRIBUTOR|collaborator|\b(MEMBER|OWNER)\b|permission|github\.actor\s*==|user\.login\s*==|==\s*['"](write|admin|maintain)|contains\([^)]*(login|actor|association)|head\.repo\.full_name\s*==\s*github\.repository/i;
|
|
53220
|
+
function labelTypeConfigured(wf, raw) {
|
|
53198
53221
|
const on = wf.on ?? raw["on"] ?? raw[true];
|
|
53199
53222
|
const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
|
|
53200
|
-
|
|
53223
|
+
return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
|
|
53224
|
+
}
|
|
53225
|
+
function ifsAreGated(ifs, labelConfigured) {
|
|
53226
|
+
const gated = ACTOR_GATE_RE.test(ifs);
|
|
53227
|
+
const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
|
|
53228
|
+
return gated || labelGated;
|
|
53229
|
+
}
|
|
53230
|
+
function hasActorGate(wf, raw) {
|
|
53201
53231
|
const ifs = [
|
|
53202
53232
|
wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
|
|
53203
53233
|
allSteps(wf).map((s) => s.step.if)
|
|
53204
53234
|
].flat().map(str).join(" ");
|
|
53205
|
-
|
|
53206
|
-
|
|
53207
|
-
|
|
53208
|
-
const
|
|
53209
|
-
return
|
|
53235
|
+
return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
|
|
53236
|
+
}
|
|
53237
|
+
function jobActorGate(job, wf, raw) {
|
|
53238
|
+
const ifs = [job.if, ...(job.steps ?? []).map((s) => s.if)].map(str).join(" ");
|
|
53239
|
+
return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs);
|
|
53210
53240
|
}
|
|
53211
53241
|
function hasImplicitActorGate(agentSteps, bypassActive) {
|
|
53212
53242
|
if (bypassActive) return false;
|
|
53213
53243
|
return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
|
|
53214
53244
|
}
|
|
53245
|
+
function injectableAgentSteps(wf, raw, untrustedTrigger) {
|
|
53246
|
+
const out = [];
|
|
53247
|
+
for (const job of Object.values(wf.jobs ?? {})) {
|
|
53248
|
+
const a = (job.steps ?? []).filter(isAgentStep);
|
|
53249
|
+
if (!a.length) continue;
|
|
53250
|
+
const jobStar = str(a.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53251
|
+
const jobBypass = jobStar && untrustedTrigger;
|
|
53252
|
+
if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobBypass)) continue;
|
|
53253
|
+
out.push(...a);
|
|
53254
|
+
}
|
|
53255
|
+
return out;
|
|
53256
|
+
}
|
|
53215
53257
|
function permsElevated(wf, agentJobs) {
|
|
53216
53258
|
const check = (p) => {
|
|
53217
53259
|
const s = str(p);
|
|
@@ -53274,14 +53316,16 @@ function analyzeWorkflow(path70, content) {
|
|
|
53274
53316
|
const nonWriteList = !!nonWrite && nonWrite !== "*";
|
|
53275
53317
|
const untrustedTrigger = secretExposed || forkInput;
|
|
53276
53318
|
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53277
|
-
const head = untrustedHeadCheckout(
|
|
53319
|
+
const head = untrustedHeadCheckout(steps);
|
|
53278
53320
|
const promptUntrusted = promptTakesUntrusted(agentSteps);
|
|
53279
|
-
const reach = Math.max(
|
|
53321
|
+
const reach = untrustedTrigger ? Math.max(
|
|
53280
53322
|
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53281
53323
|
promptUntrusted ? 2 : 0,
|
|
53282
53324
|
bypassActive ? 2 : 0
|
|
53283
|
-
);
|
|
53284
|
-
const
|
|
53325
|
+
) : 0;
|
|
53326
|
+
const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
|
|
53327
|
+
const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
|
|
53328
|
+
const toolsBlob = collectTools(scopedSteps);
|
|
53285
53329
|
const broadTools = BROAD_TOOL_RE.test(toolsBlob);
|
|
53286
53330
|
const elevated = permsElevated(wf, agentJobs);
|
|
53287
53331
|
const pat = usesPat(wf);
|
|
@@ -53346,6 +53390,105 @@ function analyzeWorkflow(path70, content) {
|
|
|
53346
53390
|
fix: head === "root" && privileged ? "Do not check out the untrusted PR head into the workspace root under a privileged trigger (pull_request_target/workflow_run) \u2014 check out the base ref, or isolate the head in a subdir (--add-dir). Add an actor gate and scope the agent tools." : head === "root" ? "This runs under `pull_request` (fork PRs get a read-only token), so the head checkout is low-risk today \u2014 keep it on `pull_request` (not `pull_request_target`) and keep the actor gate + scoped tools." : "Add/verify an actor gate, scope the agent tools to read-only, and env-deny secrets. See Anthropic\u2019s claude-code-action security doc."
|
|
53347
53391
|
};
|
|
53348
53392
|
}
|
|
53393
|
+
var AGENT_FUEL_RE = /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|OPENAI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GITHUB_TOKEN)$/i;
|
|
53394
|
+
var FUEL_INPUT_KEYS = /^(anthropic_api_key|anthropic_auth_token|anthropic_base_url|claude_code_oauth_token|openai_api_key|openai_base_url|gemini_api_key|google_api_key)$/i;
|
|
53395
|
+
function fuelSecretNames(agentSteps) {
|
|
53396
|
+
const out = /* @__PURE__ */ new Set();
|
|
53397
|
+
const add = (v) => {
|
|
53398
|
+
for (const m of str(v).matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) out.add(m[1]);
|
|
53399
|
+
};
|
|
53400
|
+
for (const st of agentSteps) {
|
|
53401
|
+
for (const [k, v] of Object.entries(st.with ?? {})) if (FUEL_INPUT_KEYS.test(k)) add(v);
|
|
53402
|
+
for (const [k, v] of Object.entries(st.env ?? {})) if (AGENT_FUEL_RE.test(k)) add(v);
|
|
53403
|
+
}
|
|
53404
|
+
return out;
|
|
53405
|
+
}
|
|
53406
|
+
function classifySecret(name) {
|
|
53407
|
+
if (/AWS_|AZURE_|GCP_|GOOGLE_APPLICATION|GCLOUD/i.test(name)) return "cloud";
|
|
53408
|
+
if (/_PAT\b|PAT$|_TOKEN$|GH_TOKEN/i.test(name)) return "pat";
|
|
53409
|
+
if (/DATABASE|_DB_|POSTGRES|MYSQL|REDIS|MONGO|CONNECTION_STRING/i.test(name)) return "db";
|
|
53410
|
+
if (/API_KEY|_KEY$|SECRET|PASSWORD|PASSWD/i.test(name)) return "api-key";
|
|
53411
|
+
return "generic";
|
|
53412
|
+
}
|
|
53413
|
+
function agentReachableSecrets(wf, agentSteps, agentJobs) {
|
|
53414
|
+
const blobs = [];
|
|
53415
|
+
for (const st of agentSteps) blobs.push(str(st.env), str(st.with));
|
|
53416
|
+
for (const j of agentJobs) blobs.push(str(j.env));
|
|
53417
|
+
blobs.push(str(wf.env));
|
|
53418
|
+
const fuel = fuelSecretNames(agentSteps);
|
|
53419
|
+
const found = /* @__PURE__ */ new Map();
|
|
53420
|
+
for (const b of blobs) {
|
|
53421
|
+
for (const m of b.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) {
|
|
53422
|
+
const name = m[1];
|
|
53423
|
+
if (AGENT_FUEL_RE.test(name) || fuel.has(name)) continue;
|
|
53424
|
+
found.set(name, classifySecret(name));
|
|
53425
|
+
}
|
|
53426
|
+
}
|
|
53427
|
+
const idToken = /["']?id-token["']?\s*:\s*["']?write/i.test(str(wf.permissions)) || agentJobs.some((j) => /["']?id-token["']?\s*:\s*["']?write/i.test(str(j.permissions)));
|
|
53428
|
+
const out = [...found].map(([name, kind]) => ({ name, kind }));
|
|
53429
|
+
if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
|
|
53430
|
+
return out;
|
|
53431
|
+
}
|
|
53432
|
+
function evalAgentJob(job, wf, raw, untrustedTrigger) {
|
|
53433
|
+
const jobSteps = job.steps ?? [];
|
|
53434
|
+
const jobAgentSteps = jobSteps.filter(isAgentStep);
|
|
53435
|
+
if (jobAgentSteps.length === 0) return null;
|
|
53436
|
+
const secrets = agentReachableSecrets(wf, jobAgentSteps, [job]);
|
|
53437
|
+
if (secrets.length === 0) return null;
|
|
53438
|
+
const nonWriteStar = str(jobAgentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53439
|
+
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53440
|
+
const head = untrustedHeadCheckout(jobSteps);
|
|
53441
|
+
const reach = Math.max(
|
|
53442
|
+
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53443
|
+
promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
|
|
53444
|
+
bypassActive ? 2 : 0
|
|
53445
|
+
);
|
|
53446
|
+
const gate = jobActorGate(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
|
|
53447
|
+
const injectable = untrustedTrigger && !gate && reach > 0;
|
|
53448
|
+
const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
|
|
53449
|
+
const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
|
|
53450
|
+
const hasOidc = secrets.some((s) => s.kind === "cloud-oidc");
|
|
53451
|
+
let severity;
|
|
53452
|
+
if (injectable && canReadEnv) {
|
|
53453
|
+
severity = hasOidc || realSecrets.some((s) => ["cloud", "pat", "db"].includes(s.kind)) ? "critical" : "high";
|
|
53454
|
+
} else if (realSecrets.length > 0) {
|
|
53455
|
+
severity = "advisory";
|
|
53456
|
+
} else {
|
|
53457
|
+
return null;
|
|
53458
|
+
}
|
|
53459
|
+
return { severity, secrets, injectable, canReadEnv };
|
|
53460
|
+
}
|
|
53461
|
+
function analyzeWorkflowSecrets(path70, content) {
|
|
53462
|
+
let raw;
|
|
53463
|
+
try {
|
|
53464
|
+
raw = (0, import_yaml.parse)(content) ?? {};
|
|
53465
|
+
} catch {
|
|
53466
|
+
return null;
|
|
53467
|
+
}
|
|
53468
|
+
const wf = raw;
|
|
53469
|
+
const all = allSteps(wf);
|
|
53470
|
+
if (!all.some((s) => isAgentStep(s.step))) return null;
|
|
53471
|
+
const triggers = triggerKeys(wf, raw);
|
|
53472
|
+
const untrustedTrigger = triggers.some(
|
|
53473
|
+
(t) => /pull_request_target|workflow_run|issue|pull_request|workflow_call/i.test(t)
|
|
53474
|
+
);
|
|
53475
|
+
const agentJobs = [...new Set(all.filter((s) => isAgentStep(s.step)).map((s) => s.job))];
|
|
53476
|
+
const worst = agentJobs.map((job) => evalAgentJob(job, wf, raw, untrustedTrigger)).filter((e) => e !== null).sort((a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity])[0];
|
|
53477
|
+
if (!worst) return null;
|
|
53478
|
+
return {
|
|
53479
|
+
check: "CI-4",
|
|
53480
|
+
dimension: "data",
|
|
53481
|
+
severity: worst.severity,
|
|
53482
|
+
title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
|
|
53483
|
+
file: path70,
|
|
53484
|
+
signals: [
|
|
53485
|
+
`agent can reach: ${worst.secrets.map((s) => s.name).join(", ")}`,
|
|
53486
|
+
worst.injectable ? "the agent is externally triggerable (untrusted trigger, no gate)" : "gated / not externally triggerable \u2014 latent risk only",
|
|
53487
|
+
worst.canReadEnv ? "agent has arbitrary shell (bare Bash) \u2192 can read env and exfiltrate" : "no arbitrary-shell tool \u2014 not exfiltratable today, but one tool-add away"
|
|
53488
|
+
],
|
|
53489
|
+
fix: "Move extra secrets to a separate trusted job the agent cannot reach; drop id-token:write if unused; scope the agent tools to read-only and gate the trigger."
|
|
53490
|
+
};
|
|
53491
|
+
}
|
|
53349
53492
|
|
|
53350
53493
|
// src/ci-check/agent-config.ts
|
|
53351
53494
|
function asStrings(v) {
|
|
@@ -53456,6 +53599,106 @@ function analyzeMcp(path70, content) {
|
|
|
53456
53599
|
return findings;
|
|
53457
53600
|
}
|
|
53458
53601
|
|
|
53602
|
+
// src/ci-check/instructions.ts
|
|
53603
|
+
var HIDDEN_CHARS = /[\u200B\u2060\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/u;
|
|
53604
|
+
var OVERRIDE_RE = /ignore\s+(all\s+)?(previous|prior|the\s+above)\s+(instructions|prompts?|rules)|disregard\s+(the\s+|your\s+)?(system\s+)?(prompt|instructions|rules)|forget\s+(everything|all\s+(previous|prior))|you\s+are\s+now\s+(a|an|the)\b|<\/?system>/i;
|
|
53605
|
+
var FETCH_OBEY_RE = /\b(curl|wget|iwr|invoke-webrequest)\b[^\n|]*\|\s*(bash|sh|zsh|python3?|node|iex)\b|\b(curl|wget)\b[^\n]*&&[^\n]*\b(bash|sh)\b/i;
|
|
53606
|
+
var SECRET_PATH_RE = /~\/\.aws\/credentials|~\/\.ssh\/id_[a-z]+|~\/\.config\/gh\/hosts|read\s+the\s+(token|secret|api[_ ]?key|password)\s+(in|from)\s+[.`'"]?\.?env/i;
|
|
53607
|
+
var EXFIL_RE = /\b(post|send|upload|exfiltrate|forward)\b[^\n]{0,40}\b(to|at)\b[^\n]{0,50}(https?:\/\/|webhook|hook\.[a-z])/i;
|
|
53608
|
+
var HUMAN_SECTION_RE = /^#+\s*(install|installation|setup|set ?up|getting started|quick ?start|contributing|contribution|development|dev setup|build|prerequisites|requirements|usage)\b/i;
|
|
53609
|
+
var NEGATION_RE = /\b(never|do not|don'?t|avoid|must not|should not|no need to|refuse to)\b/i;
|
|
53610
|
+
function isNegated(text, idx) {
|
|
53611
|
+
return NEGATION_RE.test(text.slice(Math.max(0, idx - 40), idx));
|
|
53612
|
+
}
|
|
53613
|
+
function inHumanSection(text, idx) {
|
|
53614
|
+
const heading = text.slice(0, idx).split("\n").reverse().find((l) => /^#+\s/.test(l));
|
|
53615
|
+
return !!heading && HUMAN_SECTION_RE.test(heading);
|
|
53616
|
+
}
|
|
53617
|
+
function decodeSuspiciousBase64(text) {
|
|
53618
|
+
let out = "";
|
|
53619
|
+
for (const m of text.matchAll(/[A-Za-z0-9+/]{40,}={0,2}/g)) {
|
|
53620
|
+
try {
|
|
53621
|
+
const d = Buffer.from(m[0], "base64").toString("utf8");
|
|
53622
|
+
if (/[\x20-\x7E]{16,}/.test(d) && /[a-z]{4,}/i.test(d)) out += " " + d;
|
|
53623
|
+
} catch {
|
|
53624
|
+
}
|
|
53625
|
+
}
|
|
53626
|
+
return out;
|
|
53627
|
+
}
|
|
53628
|
+
function mk(severity, title, signals, fix, path70) {
|
|
53629
|
+
return { check: "CI-6", dimension: "instructions", severity, title, file: path70, signals, fix };
|
|
53630
|
+
}
|
|
53631
|
+
function analyzeInstructionFile(path70, content) {
|
|
53632
|
+
const findings = [];
|
|
53633
|
+
const decoded = decodeSuspiciousBase64(content);
|
|
53634
|
+
if (HIDDEN_CHARS.test(content)) {
|
|
53635
|
+
findings.push(
|
|
53636
|
+
mk(
|
|
53637
|
+
"critical",
|
|
53638
|
+
"Hidden characters in an agent instruction file",
|
|
53639
|
+
[
|
|
53640
|
+
"contains zero-width / bidi / Unicode-tag characters \u2014 a technique to hide instructions from human review while the agent still reads them"
|
|
53641
|
+
],
|
|
53642
|
+
"Remove the hidden characters. Instruction files must be plain, reviewable text.",
|
|
53643
|
+
path70
|
|
53644
|
+
)
|
|
53645
|
+
);
|
|
53646
|
+
}
|
|
53647
|
+
const ov = OVERRIDE_RE.exec(content);
|
|
53648
|
+
const ovEnc = !ov ? OVERRIDE_RE.exec(decoded) : null;
|
|
53649
|
+
if (ov || ovEnc) {
|
|
53650
|
+
const m = ov || ovEnc;
|
|
53651
|
+
findings.push(
|
|
53652
|
+
mk(
|
|
53653
|
+
ovEnc ? "critical" : "high",
|
|
53654
|
+
"Prompt-override directive in an agent instruction file",
|
|
53655
|
+
[
|
|
53656
|
+
`contains a prompt-override / role-impersonation directive (\`${m[0].slice(0, 60).trim()}\`)${ovEnc ? " \u2014 concealed in a base64 blob" : ""}`
|
|
53657
|
+
],
|
|
53658
|
+
"Remove the override text. An instruction file should not tell the agent to ignore its own rules.",
|
|
53659
|
+
path70
|
|
53660
|
+
)
|
|
53661
|
+
);
|
|
53662
|
+
}
|
|
53663
|
+
const fo = FETCH_OBEY_RE.exec(content);
|
|
53664
|
+
if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
|
|
53665
|
+
findings.push(
|
|
53666
|
+
mk(
|
|
53667
|
+
"medium",
|
|
53668
|
+
"Instruction directs the agent to fetch and run remote code",
|
|
53669
|
+
[`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
|
|
53670
|
+
"Do not instruct the agent to pipe remote content into a shell; pin and vendor scripts instead.",
|
|
53671
|
+
path70
|
|
53672
|
+
)
|
|
53673
|
+
);
|
|
53674
|
+
}
|
|
53675
|
+
const sp = SECRET_PATH_RE.exec(content);
|
|
53676
|
+
if (sp && !isNegated(content, sp.index)) {
|
|
53677
|
+
findings.push(
|
|
53678
|
+
mk(
|
|
53679
|
+
"medium",
|
|
53680
|
+
"Instruction points the agent at credential material",
|
|
53681
|
+
[`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
|
|
53682
|
+
"Do not reference credential files or paths in agent instructions.",
|
|
53683
|
+
path70
|
|
53684
|
+
)
|
|
53685
|
+
);
|
|
53686
|
+
}
|
|
53687
|
+
const ex = EXFIL_RE.exec(content);
|
|
53688
|
+
if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
|
|
53689
|
+
findings.push(
|
|
53690
|
+
mk(
|
|
53691
|
+
"medium",
|
|
53692
|
+
"Instruction directs the agent to send data to an external endpoint",
|
|
53693
|
+
[`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
|
|
53694
|
+
"Remove external post/upload directives from agent instructions.",
|
|
53695
|
+
path70
|
|
53696
|
+
)
|
|
53697
|
+
);
|
|
53698
|
+
}
|
|
53699
|
+
return findings;
|
|
53700
|
+
}
|
|
53701
|
+
|
|
53459
53702
|
// src/ci-check/index.ts
|
|
53460
53703
|
function worstOf(findings) {
|
|
53461
53704
|
let worst = null;
|
|
@@ -53474,10 +53717,16 @@ function scanTree(tree) {
|
|
|
53474
53717
|
if (/\.github\/workflows\/.+\.ya?ml$/.test(file.path)) {
|
|
53475
53718
|
const f = analyzeWorkflow(file.path, file.content);
|
|
53476
53719
|
if (f) findings.push(f);
|
|
53720
|
+
const s = analyzeWorkflowSecrets(file.path, file.content);
|
|
53721
|
+
if (s) findings.push(s);
|
|
53477
53722
|
} else if (/\.claude\/settings(\.local)?\.json$/.test(file.path)) {
|
|
53478
53723
|
findings.push(...analyzeAgentConfig(file.path, file.content));
|
|
53479
53724
|
} else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
|
|
53480
53725
|
findings.push(...analyzeMcp(file.path, file.content));
|
|
53726
|
+
} else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
|
|
53727
|
+
file.path
|
|
53728
|
+
)) {
|
|
53729
|
+
findings.push(...analyzeInstructionFile(file.path, file.content));
|
|
53481
53730
|
}
|
|
53482
53731
|
} catch (err2) {
|
|
53483
53732
|
notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
|
|
@@ -53508,6 +53757,36 @@ var COLOR = {
|
|
|
53508
53757
|
medium: import_chalk29.default.yellow,
|
|
53509
53758
|
advisory: import_chalk29.default.gray
|
|
53510
53759
|
};
|
|
53760
|
+
var ACTION_URL = "https://github.com/marketplace/actions/node9-agent-security-check?ref=cli_scan_repo";
|
|
53761
|
+
function renderCta(res) {
|
|
53762
|
+
const L = [];
|
|
53763
|
+
L.push(import_chalk29.default.dim(" " + "\u2500".repeat(63)));
|
|
53764
|
+
if (res.worst === "critical" || res.worst === "high") {
|
|
53765
|
+
const n = res.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
|
|
53766
|
+
L.push(
|
|
53767
|
+
" " + import_chalk29.default.red.bold(
|
|
53768
|
+
`\u{1F534} ${n} ${n === 1 ? "issue" : "issues"} to fix \u2014 then stop the next at the PR.`
|
|
53769
|
+
)
|
|
53770
|
+
);
|
|
53771
|
+
L.push("");
|
|
53772
|
+
L.push(" " + import_chalk29.default.bold("Catch this class of issue on every PR, automatically:"));
|
|
53773
|
+
} else if (res.worst) {
|
|
53774
|
+
L.push(" " + import_chalk29.default.yellow("\u{1F7E1} Review the findings above, then keep it covered:"));
|
|
53775
|
+
L.push("");
|
|
53776
|
+
L.push(" " + import_chalk29.default.bold("Check every PR for agent-CI risk:"));
|
|
53777
|
+
} else if (res.incomplete) {
|
|
53778
|
+
L.push(" " + import_chalk29.default.yellow.bold("\u26A0\uFE0F Incomplete \u2014 not a clean bill of health."));
|
|
53779
|
+
L.push("");
|
|
53780
|
+
L.push(" " + import_chalk29.default.bold("Get a complete check on every PR (CI reads the tree directly):"));
|
|
53781
|
+
} else {
|
|
53782
|
+
L.push(" " + import_chalk29.default.green("\u2705 Agent CI is well-configured \u2014 0 unmitigated issues."));
|
|
53783
|
+
L.push("");
|
|
53784
|
+
L.push(" " + import_chalk29.default.bold("Keep it green as you add agent workflows \u2014 check every PR:"));
|
|
53785
|
+
}
|
|
53786
|
+
L.push(" " + import_chalk29.default.dim("\u2192 ") + import_chalk29.default.cyan.underline(ACTION_URL));
|
|
53787
|
+
L.push(" " + import_chalk29.default.gray(" zero setup \xB7 no token \xB7 runs in your CI"));
|
|
53788
|
+
return L;
|
|
53789
|
+
}
|
|
53511
53790
|
function ownedHint(source) {
|
|
53512
53791
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
53513
53792
|
}
|
|
@@ -53552,6 +53831,8 @@ function renderScan(res) {
|
|
|
53552
53831
|
)
|
|
53553
53832
|
);
|
|
53554
53833
|
}
|
|
53834
|
+
L.push("");
|
|
53835
|
+
L.push(...renderCta(res));
|
|
53555
53836
|
return L.join("\n");
|
|
53556
53837
|
}
|
|
53557
53838
|
function renderScanMarkdown(res) {
|
package/dist/cli.mjs
CHANGED
|
@@ -52967,7 +52967,15 @@ var SURFACE_FILES = [
|
|
|
52967
52967
|
".claude/settings.local.json",
|
|
52968
52968
|
".mcp.json",
|
|
52969
52969
|
".cursor/mcp.json",
|
|
52970
|
-
".codex/config.toml"
|
|
52970
|
+
".codex/config.toml",
|
|
52971
|
+
// CI-6: agent instruction files (auto-loaded into the agent's system prompt).
|
|
52972
|
+
"CLAUDE.md",
|
|
52973
|
+
"AGENTS.md",
|
|
52974
|
+
"GEMINI.md",
|
|
52975
|
+
".cursorrules",
|
|
52976
|
+
".github/copilot-instructions.md",
|
|
52977
|
+
".windsurfrules",
|
|
52978
|
+
".clinerules"
|
|
52971
52979
|
];
|
|
52972
52980
|
var WORKFLOW_DIR = ".github/workflows";
|
|
52973
52981
|
async function pooled(items, limit, fn) {
|
|
@@ -53157,18 +53165,32 @@ function isAgentStep(step) {
|
|
|
53157
53165
|
return true;
|
|
53158
53166
|
return false;
|
|
53159
53167
|
}
|
|
53168
|
+
function allowedToolsFromArgs(claudeArgs) {
|
|
53169
|
+
let out = "";
|
|
53170
|
+
for (const m of claudeArgs.matchAll(/--allowed[-_]?tools[=\s]+("[^"]*"|'[^']*'|\S+)/gi))
|
|
53171
|
+
out += " " + m[1];
|
|
53172
|
+
return out;
|
|
53173
|
+
}
|
|
53174
|
+
function allowedToolsFromSettings(settings) {
|
|
53175
|
+
let out = "";
|
|
53176
|
+
for (const key of ["allowedTools", "allow"]) {
|
|
53177
|
+
for (const m of settings.matchAll(new RegExp(`"${key}"\\s*:\\s*(\\[[^\\]]*\\])`, "gi")))
|
|
53178
|
+
out += " " + m[1];
|
|
53179
|
+
}
|
|
53180
|
+
return out;
|
|
53181
|
+
}
|
|
53160
53182
|
function collectTools(steps) {
|
|
53161
|
-
const stripDeny = (x) => x.replace(/--disallowed[-_]?tools\s+("[^"]*"|'[^']*'|\S+)/gi, " ").replace(/["']disallowed[_]?[tT]ools["']\s*:\s*\[[^\]]*\]/g, " ");
|
|
53162
53183
|
let s = "";
|
|
53163
53184
|
for (const st of steps) {
|
|
53164
53185
|
const w = st.with ?? {};
|
|
53165
|
-
s += " " +
|
|
53166
|
-
s += " " +
|
|
53186
|
+
s += " " + allowedToolsFromArgs(str(w["claude_args"]));
|
|
53187
|
+
s += " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
|
|
53188
|
+
s += " " + allowedToolsFromSettings(str(w["settings"]));
|
|
53167
53189
|
}
|
|
53168
53190
|
return s;
|
|
53169
53191
|
}
|
|
53170
|
-
function untrustedHeadCheckout(
|
|
53171
|
-
for (const
|
|
53192
|
+
function untrustedHeadCheckout(steps) {
|
|
53193
|
+
for (const step of steps) {
|
|
53172
53194
|
if (!step.uses || !/actions\/checkout/.test(step.uses)) continue;
|
|
53173
53195
|
const ref = str(step.with?.["ref"]);
|
|
53174
53196
|
if (/pull_request\.head|head[._]sha|head_ref|expected_head|workflow_run\.head|inputs\.[\w]*head/i.test(
|
|
@@ -53187,24 +53209,44 @@ function promptTakesUntrusted(steps) {
|
|
|
53187
53209
|
}
|
|
53188
53210
|
return false;
|
|
53189
53211
|
}
|
|
53190
|
-
|
|
53212
|
+
var ACTOR_GATE_RE = /author_association|FIRST_TIME_CONTRIBUTOR|collaborator|\b(MEMBER|OWNER)\b|permission|github\.actor\s*==|user\.login\s*==|==\s*['"](write|admin|maintain)|contains\([^)]*(login|actor|association)|head\.repo\.full_name\s*==\s*github\.repository/i;
|
|
53213
|
+
function labelTypeConfigured(wf, raw) {
|
|
53191
53214
|
const on = wf.on ?? raw["on"] ?? raw[true];
|
|
53192
53215
|
const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
|
|
53193
|
-
|
|
53216
|
+
return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
|
|
53217
|
+
}
|
|
53218
|
+
function ifsAreGated(ifs, labelConfigured) {
|
|
53219
|
+
const gated = ACTOR_GATE_RE.test(ifs);
|
|
53220
|
+
const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
|
|
53221
|
+
return gated || labelGated;
|
|
53222
|
+
}
|
|
53223
|
+
function hasActorGate(wf, raw) {
|
|
53194
53224
|
const ifs = [
|
|
53195
53225
|
wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
|
|
53196
53226
|
allSteps(wf).map((s) => s.step.if)
|
|
53197
53227
|
].flat().map(str).join(" ");
|
|
53198
|
-
|
|
53199
|
-
|
|
53200
|
-
|
|
53201
|
-
const
|
|
53202
|
-
return
|
|
53228
|
+
return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
|
|
53229
|
+
}
|
|
53230
|
+
function jobActorGate(job, wf, raw) {
|
|
53231
|
+
const ifs = [job.if, ...(job.steps ?? []).map((s) => s.if)].map(str).join(" ");
|
|
53232
|
+
return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs);
|
|
53203
53233
|
}
|
|
53204
53234
|
function hasImplicitActorGate(agentSteps, bypassActive) {
|
|
53205
53235
|
if (bypassActive) return false;
|
|
53206
53236
|
return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
|
|
53207
53237
|
}
|
|
53238
|
+
function injectableAgentSteps(wf, raw, untrustedTrigger) {
|
|
53239
|
+
const out = [];
|
|
53240
|
+
for (const job of Object.values(wf.jobs ?? {})) {
|
|
53241
|
+
const a = (job.steps ?? []).filter(isAgentStep);
|
|
53242
|
+
if (!a.length) continue;
|
|
53243
|
+
const jobStar = str(a.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53244
|
+
const jobBypass = jobStar && untrustedTrigger;
|
|
53245
|
+
if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobBypass)) continue;
|
|
53246
|
+
out.push(...a);
|
|
53247
|
+
}
|
|
53248
|
+
return out;
|
|
53249
|
+
}
|
|
53208
53250
|
function permsElevated(wf, agentJobs) {
|
|
53209
53251
|
const check = (p) => {
|
|
53210
53252
|
const s = str(p);
|
|
@@ -53267,14 +53309,16 @@ function analyzeWorkflow(path70, content) {
|
|
|
53267
53309
|
const nonWriteList = !!nonWrite && nonWrite !== "*";
|
|
53268
53310
|
const untrustedTrigger = secretExposed || forkInput;
|
|
53269
53311
|
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53270
|
-
const head = untrustedHeadCheckout(
|
|
53312
|
+
const head = untrustedHeadCheckout(steps);
|
|
53271
53313
|
const promptUntrusted = promptTakesUntrusted(agentSteps);
|
|
53272
|
-
const reach = Math.max(
|
|
53314
|
+
const reach = untrustedTrigger ? Math.max(
|
|
53273
53315
|
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53274
53316
|
promptUntrusted ? 2 : 0,
|
|
53275
53317
|
bypassActive ? 2 : 0
|
|
53276
|
-
);
|
|
53277
|
-
const
|
|
53318
|
+
) : 0;
|
|
53319
|
+
const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
|
|
53320
|
+
const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
|
|
53321
|
+
const toolsBlob = collectTools(scopedSteps);
|
|
53278
53322
|
const broadTools = BROAD_TOOL_RE.test(toolsBlob);
|
|
53279
53323
|
const elevated = permsElevated(wf, agentJobs);
|
|
53280
53324
|
const pat = usesPat(wf);
|
|
@@ -53339,6 +53383,105 @@ function analyzeWorkflow(path70, content) {
|
|
|
53339
53383
|
fix: head === "root" && privileged ? "Do not check out the untrusted PR head into the workspace root under a privileged trigger (pull_request_target/workflow_run) \u2014 check out the base ref, or isolate the head in a subdir (--add-dir). Add an actor gate and scope the agent tools." : head === "root" ? "This runs under `pull_request` (fork PRs get a read-only token), so the head checkout is low-risk today \u2014 keep it on `pull_request` (not `pull_request_target`) and keep the actor gate + scoped tools." : "Add/verify an actor gate, scope the agent tools to read-only, and env-deny secrets. See Anthropic\u2019s claude-code-action security doc."
|
|
53340
53384
|
};
|
|
53341
53385
|
}
|
|
53386
|
+
var AGENT_FUEL_RE = /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ANTHROPIC_BASE_URL|CLAUDE_CODE_OAUTH_TOKEN|OPENAI_API_KEY|OPENAI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GITHUB_TOKEN)$/i;
|
|
53387
|
+
var FUEL_INPUT_KEYS = /^(anthropic_api_key|anthropic_auth_token|anthropic_base_url|claude_code_oauth_token|openai_api_key|openai_base_url|gemini_api_key|google_api_key)$/i;
|
|
53388
|
+
function fuelSecretNames(agentSteps) {
|
|
53389
|
+
const out = /* @__PURE__ */ new Set();
|
|
53390
|
+
const add = (v) => {
|
|
53391
|
+
for (const m of str(v).matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) out.add(m[1]);
|
|
53392
|
+
};
|
|
53393
|
+
for (const st of agentSteps) {
|
|
53394
|
+
for (const [k, v] of Object.entries(st.with ?? {})) if (FUEL_INPUT_KEYS.test(k)) add(v);
|
|
53395
|
+
for (const [k, v] of Object.entries(st.env ?? {})) if (AGENT_FUEL_RE.test(k)) add(v);
|
|
53396
|
+
}
|
|
53397
|
+
return out;
|
|
53398
|
+
}
|
|
53399
|
+
function classifySecret(name) {
|
|
53400
|
+
if (/AWS_|AZURE_|GCP_|GOOGLE_APPLICATION|GCLOUD/i.test(name)) return "cloud";
|
|
53401
|
+
if (/_PAT\b|PAT$|_TOKEN$|GH_TOKEN/i.test(name)) return "pat";
|
|
53402
|
+
if (/DATABASE|_DB_|POSTGRES|MYSQL|REDIS|MONGO|CONNECTION_STRING/i.test(name)) return "db";
|
|
53403
|
+
if (/API_KEY|_KEY$|SECRET|PASSWORD|PASSWD/i.test(name)) return "api-key";
|
|
53404
|
+
return "generic";
|
|
53405
|
+
}
|
|
53406
|
+
function agentReachableSecrets(wf, agentSteps, agentJobs) {
|
|
53407
|
+
const blobs = [];
|
|
53408
|
+
for (const st of agentSteps) blobs.push(str(st.env), str(st.with));
|
|
53409
|
+
for (const j of agentJobs) blobs.push(str(j.env));
|
|
53410
|
+
blobs.push(str(wf.env));
|
|
53411
|
+
const fuel = fuelSecretNames(agentSteps);
|
|
53412
|
+
const found = /* @__PURE__ */ new Map();
|
|
53413
|
+
for (const b of blobs) {
|
|
53414
|
+
for (const m of b.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) {
|
|
53415
|
+
const name = m[1];
|
|
53416
|
+
if (AGENT_FUEL_RE.test(name) || fuel.has(name)) continue;
|
|
53417
|
+
found.set(name, classifySecret(name));
|
|
53418
|
+
}
|
|
53419
|
+
}
|
|
53420
|
+
const idToken = /["']?id-token["']?\s*:\s*["']?write/i.test(str(wf.permissions)) || agentJobs.some((j) => /["']?id-token["']?\s*:\s*["']?write/i.test(str(j.permissions)));
|
|
53421
|
+
const out = [...found].map(([name, kind]) => ({ name, kind }));
|
|
53422
|
+
if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
|
|
53423
|
+
return out;
|
|
53424
|
+
}
|
|
53425
|
+
function evalAgentJob(job, wf, raw, untrustedTrigger) {
|
|
53426
|
+
const jobSteps = job.steps ?? [];
|
|
53427
|
+
const jobAgentSteps = jobSteps.filter(isAgentStep);
|
|
53428
|
+
if (jobAgentSteps.length === 0) return null;
|
|
53429
|
+
const secrets = agentReachableSecrets(wf, jobAgentSteps, [job]);
|
|
53430
|
+
if (secrets.length === 0) return null;
|
|
53431
|
+
const nonWriteStar = str(jobAgentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53432
|
+
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53433
|
+
const head = untrustedHeadCheckout(jobSteps);
|
|
53434
|
+
const reach = Math.max(
|
|
53435
|
+
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53436
|
+
promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
|
|
53437
|
+
bypassActive ? 2 : 0
|
|
53438
|
+
);
|
|
53439
|
+
const gate = jobActorGate(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
|
|
53440
|
+
const injectable = untrustedTrigger && !gate && reach > 0;
|
|
53441
|
+
const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
|
|
53442
|
+
const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
|
|
53443
|
+
const hasOidc = secrets.some((s) => s.kind === "cloud-oidc");
|
|
53444
|
+
let severity;
|
|
53445
|
+
if (injectable && canReadEnv) {
|
|
53446
|
+
severity = hasOidc || realSecrets.some((s) => ["cloud", "pat", "db"].includes(s.kind)) ? "critical" : "high";
|
|
53447
|
+
} else if (realSecrets.length > 0) {
|
|
53448
|
+
severity = "advisory";
|
|
53449
|
+
} else {
|
|
53450
|
+
return null;
|
|
53451
|
+
}
|
|
53452
|
+
return { severity, secrets, injectable, canReadEnv };
|
|
53453
|
+
}
|
|
53454
|
+
function analyzeWorkflowSecrets(path70, content) {
|
|
53455
|
+
let raw;
|
|
53456
|
+
try {
|
|
53457
|
+
raw = parseYaml(content) ?? {};
|
|
53458
|
+
} catch {
|
|
53459
|
+
return null;
|
|
53460
|
+
}
|
|
53461
|
+
const wf = raw;
|
|
53462
|
+
const all = allSteps(wf);
|
|
53463
|
+
if (!all.some((s) => isAgentStep(s.step))) return null;
|
|
53464
|
+
const triggers = triggerKeys(wf, raw);
|
|
53465
|
+
const untrustedTrigger = triggers.some(
|
|
53466
|
+
(t) => /pull_request_target|workflow_run|issue|pull_request|workflow_call/i.test(t)
|
|
53467
|
+
);
|
|
53468
|
+
const agentJobs = [...new Set(all.filter((s) => isAgentStep(s.step)).map((s) => s.job))];
|
|
53469
|
+
const worst = agentJobs.map((job) => evalAgentJob(job, wf, raw, untrustedTrigger)).filter((e) => e !== null).sort((a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity])[0];
|
|
53470
|
+
if (!worst) return null;
|
|
53471
|
+
return {
|
|
53472
|
+
check: "CI-4",
|
|
53473
|
+
dimension: "data",
|
|
53474
|
+
severity: worst.severity,
|
|
53475
|
+
title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
|
|
53476
|
+
file: path70,
|
|
53477
|
+
signals: [
|
|
53478
|
+
`agent can reach: ${worst.secrets.map((s) => s.name).join(", ")}`,
|
|
53479
|
+
worst.injectable ? "the agent is externally triggerable (untrusted trigger, no gate)" : "gated / not externally triggerable \u2014 latent risk only",
|
|
53480
|
+
worst.canReadEnv ? "agent has arbitrary shell (bare Bash) \u2192 can read env and exfiltrate" : "no arbitrary-shell tool \u2014 not exfiltratable today, but one tool-add away"
|
|
53481
|
+
],
|
|
53482
|
+
fix: "Move extra secrets to a separate trusted job the agent cannot reach; drop id-token:write if unused; scope the agent tools to read-only and gate the trigger."
|
|
53483
|
+
};
|
|
53484
|
+
}
|
|
53342
53485
|
|
|
53343
53486
|
// src/ci-check/agent-config.ts
|
|
53344
53487
|
function asStrings(v) {
|
|
@@ -53449,6 +53592,106 @@ function analyzeMcp(path70, content) {
|
|
|
53449
53592
|
return findings;
|
|
53450
53593
|
}
|
|
53451
53594
|
|
|
53595
|
+
// src/ci-check/instructions.ts
|
|
53596
|
+
var HIDDEN_CHARS = /[\u200B\u2060\u202A-\u202E\u2066-\u2069]|[\u{E0000}-\u{E007F}]/u;
|
|
53597
|
+
var OVERRIDE_RE = /ignore\s+(all\s+)?(previous|prior|the\s+above)\s+(instructions|prompts?|rules)|disregard\s+(the\s+|your\s+)?(system\s+)?(prompt|instructions|rules)|forget\s+(everything|all\s+(previous|prior))|you\s+are\s+now\s+(a|an|the)\b|<\/?system>/i;
|
|
53598
|
+
var FETCH_OBEY_RE = /\b(curl|wget|iwr|invoke-webrequest)\b[^\n|]*\|\s*(bash|sh|zsh|python3?|node|iex)\b|\b(curl|wget)\b[^\n]*&&[^\n]*\b(bash|sh)\b/i;
|
|
53599
|
+
var SECRET_PATH_RE = /~\/\.aws\/credentials|~\/\.ssh\/id_[a-z]+|~\/\.config\/gh\/hosts|read\s+the\s+(token|secret|api[_ ]?key|password)\s+(in|from)\s+[.`'"]?\.?env/i;
|
|
53600
|
+
var EXFIL_RE = /\b(post|send|upload|exfiltrate|forward)\b[^\n]{0,40}\b(to|at)\b[^\n]{0,50}(https?:\/\/|webhook|hook\.[a-z])/i;
|
|
53601
|
+
var HUMAN_SECTION_RE = /^#+\s*(install|installation|setup|set ?up|getting started|quick ?start|contributing|contribution|development|dev setup|build|prerequisites|requirements|usage)\b/i;
|
|
53602
|
+
var NEGATION_RE = /\b(never|do not|don'?t|avoid|must not|should not|no need to|refuse to)\b/i;
|
|
53603
|
+
function isNegated(text, idx) {
|
|
53604
|
+
return NEGATION_RE.test(text.slice(Math.max(0, idx - 40), idx));
|
|
53605
|
+
}
|
|
53606
|
+
function inHumanSection(text, idx) {
|
|
53607
|
+
const heading = text.slice(0, idx).split("\n").reverse().find((l) => /^#+\s/.test(l));
|
|
53608
|
+
return !!heading && HUMAN_SECTION_RE.test(heading);
|
|
53609
|
+
}
|
|
53610
|
+
function decodeSuspiciousBase64(text) {
|
|
53611
|
+
let out = "";
|
|
53612
|
+
for (const m of text.matchAll(/[A-Za-z0-9+/]{40,}={0,2}/g)) {
|
|
53613
|
+
try {
|
|
53614
|
+
const d = Buffer.from(m[0], "base64").toString("utf8");
|
|
53615
|
+
if (/[\x20-\x7E]{16,}/.test(d) && /[a-z]{4,}/i.test(d)) out += " " + d;
|
|
53616
|
+
} catch {
|
|
53617
|
+
}
|
|
53618
|
+
}
|
|
53619
|
+
return out;
|
|
53620
|
+
}
|
|
53621
|
+
function mk(severity, title, signals, fix, path70) {
|
|
53622
|
+
return { check: "CI-6", dimension: "instructions", severity, title, file: path70, signals, fix };
|
|
53623
|
+
}
|
|
53624
|
+
function analyzeInstructionFile(path70, content) {
|
|
53625
|
+
const findings = [];
|
|
53626
|
+
const decoded = decodeSuspiciousBase64(content);
|
|
53627
|
+
if (HIDDEN_CHARS.test(content)) {
|
|
53628
|
+
findings.push(
|
|
53629
|
+
mk(
|
|
53630
|
+
"critical",
|
|
53631
|
+
"Hidden characters in an agent instruction file",
|
|
53632
|
+
[
|
|
53633
|
+
"contains zero-width / bidi / Unicode-tag characters \u2014 a technique to hide instructions from human review while the agent still reads them"
|
|
53634
|
+
],
|
|
53635
|
+
"Remove the hidden characters. Instruction files must be plain, reviewable text.",
|
|
53636
|
+
path70
|
|
53637
|
+
)
|
|
53638
|
+
);
|
|
53639
|
+
}
|
|
53640
|
+
const ov = OVERRIDE_RE.exec(content);
|
|
53641
|
+
const ovEnc = !ov ? OVERRIDE_RE.exec(decoded) : null;
|
|
53642
|
+
if (ov || ovEnc) {
|
|
53643
|
+
const m = ov || ovEnc;
|
|
53644
|
+
findings.push(
|
|
53645
|
+
mk(
|
|
53646
|
+
ovEnc ? "critical" : "high",
|
|
53647
|
+
"Prompt-override directive in an agent instruction file",
|
|
53648
|
+
[
|
|
53649
|
+
`contains a prompt-override / role-impersonation directive (\`${m[0].slice(0, 60).trim()}\`)${ovEnc ? " \u2014 concealed in a base64 blob" : ""}`
|
|
53650
|
+
],
|
|
53651
|
+
"Remove the override text. An instruction file should not tell the agent to ignore its own rules.",
|
|
53652
|
+
path70
|
|
53653
|
+
)
|
|
53654
|
+
);
|
|
53655
|
+
}
|
|
53656
|
+
const fo = FETCH_OBEY_RE.exec(content);
|
|
53657
|
+
if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
|
|
53658
|
+
findings.push(
|
|
53659
|
+
mk(
|
|
53660
|
+
"medium",
|
|
53661
|
+
"Instruction directs the agent to fetch and run remote code",
|
|
53662
|
+
[`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
|
|
53663
|
+
"Do not instruct the agent to pipe remote content into a shell; pin and vendor scripts instead.",
|
|
53664
|
+
path70
|
|
53665
|
+
)
|
|
53666
|
+
);
|
|
53667
|
+
}
|
|
53668
|
+
const sp = SECRET_PATH_RE.exec(content);
|
|
53669
|
+
if (sp && !isNegated(content, sp.index)) {
|
|
53670
|
+
findings.push(
|
|
53671
|
+
mk(
|
|
53672
|
+
"medium",
|
|
53673
|
+
"Instruction points the agent at credential material",
|
|
53674
|
+
[`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
|
|
53675
|
+
"Do not reference credential files or paths in agent instructions.",
|
|
53676
|
+
path70
|
|
53677
|
+
)
|
|
53678
|
+
);
|
|
53679
|
+
}
|
|
53680
|
+
const ex = EXFIL_RE.exec(content);
|
|
53681
|
+
if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
|
|
53682
|
+
findings.push(
|
|
53683
|
+
mk(
|
|
53684
|
+
"medium",
|
|
53685
|
+
"Instruction directs the agent to send data to an external endpoint",
|
|
53686
|
+
[`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
|
|
53687
|
+
"Remove external post/upload directives from agent instructions.",
|
|
53688
|
+
path70
|
|
53689
|
+
)
|
|
53690
|
+
);
|
|
53691
|
+
}
|
|
53692
|
+
return findings;
|
|
53693
|
+
}
|
|
53694
|
+
|
|
53452
53695
|
// src/ci-check/index.ts
|
|
53453
53696
|
function worstOf(findings) {
|
|
53454
53697
|
let worst = null;
|
|
@@ -53467,10 +53710,16 @@ function scanTree(tree) {
|
|
|
53467
53710
|
if (/\.github\/workflows\/.+\.ya?ml$/.test(file.path)) {
|
|
53468
53711
|
const f = analyzeWorkflow(file.path, file.content);
|
|
53469
53712
|
if (f) findings.push(f);
|
|
53713
|
+
const s = analyzeWorkflowSecrets(file.path, file.content);
|
|
53714
|
+
if (s) findings.push(s);
|
|
53470
53715
|
} else if (/\.claude\/settings(\.local)?\.json$/.test(file.path)) {
|
|
53471
53716
|
findings.push(...analyzeAgentConfig(file.path, file.content));
|
|
53472
53717
|
} else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
|
|
53473
53718
|
findings.push(...analyzeMcp(file.path, file.content));
|
|
53719
|
+
} else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
|
|
53720
|
+
file.path
|
|
53721
|
+
)) {
|
|
53722
|
+
findings.push(...analyzeInstructionFile(file.path, file.content));
|
|
53474
53723
|
}
|
|
53475
53724
|
} catch (err2) {
|
|
53476
53725
|
notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
|
|
@@ -53501,6 +53750,36 @@ var COLOR = {
|
|
|
53501
53750
|
medium: chalk29.yellow,
|
|
53502
53751
|
advisory: chalk29.gray
|
|
53503
53752
|
};
|
|
53753
|
+
var ACTION_URL = "https://github.com/marketplace/actions/node9-agent-security-check?ref=cli_scan_repo";
|
|
53754
|
+
function renderCta(res) {
|
|
53755
|
+
const L = [];
|
|
53756
|
+
L.push(chalk29.dim(" " + "\u2500".repeat(63)));
|
|
53757
|
+
if (res.worst === "critical" || res.worst === "high") {
|
|
53758
|
+
const n = res.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
|
|
53759
|
+
L.push(
|
|
53760
|
+
" " + chalk29.red.bold(
|
|
53761
|
+
`\u{1F534} ${n} ${n === 1 ? "issue" : "issues"} to fix \u2014 then stop the next at the PR.`
|
|
53762
|
+
)
|
|
53763
|
+
);
|
|
53764
|
+
L.push("");
|
|
53765
|
+
L.push(" " + chalk29.bold("Catch this class of issue on every PR, automatically:"));
|
|
53766
|
+
} else if (res.worst) {
|
|
53767
|
+
L.push(" " + chalk29.yellow("\u{1F7E1} Review the findings above, then keep it covered:"));
|
|
53768
|
+
L.push("");
|
|
53769
|
+
L.push(" " + chalk29.bold("Check every PR for agent-CI risk:"));
|
|
53770
|
+
} else if (res.incomplete) {
|
|
53771
|
+
L.push(" " + chalk29.yellow.bold("\u26A0\uFE0F Incomplete \u2014 not a clean bill of health."));
|
|
53772
|
+
L.push("");
|
|
53773
|
+
L.push(" " + chalk29.bold("Get a complete check on every PR (CI reads the tree directly):"));
|
|
53774
|
+
} else {
|
|
53775
|
+
L.push(" " + chalk29.green("\u2705 Agent CI is well-configured \u2014 0 unmitigated issues."));
|
|
53776
|
+
L.push("");
|
|
53777
|
+
L.push(" " + chalk29.bold("Keep it green as you add agent workflows \u2014 check every PR:"));
|
|
53778
|
+
}
|
|
53779
|
+
L.push(" " + chalk29.dim("\u2192 ") + chalk29.cyan.underline(ACTION_URL));
|
|
53780
|
+
L.push(" " + chalk29.gray(" zero setup \xB7 no token \xB7 runs in your CI"));
|
|
53781
|
+
return L;
|
|
53782
|
+
}
|
|
53504
53783
|
function ownedHint(source) {
|
|
53505
53784
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
53506
53785
|
}
|
|
@@ -53545,6 +53824,8 @@ function renderScan(res) {
|
|
|
53545
53824
|
)
|
|
53546
53825
|
);
|
|
53547
53826
|
}
|
|
53827
|
+
L.push("");
|
|
53828
|
+
L.push(...renderCta(res));
|
|
53548
53829
|
return L.join("\n");
|
|
53549
53830
|
}
|
|
53550
53831
|
function renderScanMarkdown(res) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.59.0",
|
|
4
4
|
"description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|