@node9/proxy 1.58.5 → 1.60.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 +655 -67
- package/dist/cli.mjs +655 -67
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -52974,9 +52974,39 @@ 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";
|
|
52988
|
+
var SURFACE_BASENAME = /(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|(^|\/)\.(windsurf|cline)rules$|(^|\/)copilot-instructions\.md$|(^|\/)\.claude\/settings(\.local)?\.json$|(^|\/)\.mcp\.json$|(^|\/)\.cursor\/mcp\.json$|(^|\/)\.codex\/config\.toml$/;
|
|
52989
|
+
var IGNORE_HARD = /(^|\/)(node_modules|vendor|\.git|\.next|\.venv|site-packages)\//;
|
|
52990
|
+
var IGNORE_SOFT = /(^|\/)(dist|build|out|target)\//;
|
|
52991
|
+
var isIgnoredDir = (relSlash) => IGNORE_HARD.test(relSlash) || IGNORE_SOFT.test(relSlash);
|
|
52992
|
+
var MAX_SURFACE_FILES = 200;
|
|
52993
|
+
function pickSurfacePaths(paths, truncated, notes) {
|
|
52994
|
+
const surface = paths.filter((p) => SURFACE_BASENAME.test(p) && !IGNORE_HARD.test(p));
|
|
52995
|
+
const matched = surface.filter((p) => !IGNORE_SOFT.test(p));
|
|
52996
|
+
const softSkipped = surface.filter((p) => IGNORE_SOFT.test(p));
|
|
52997
|
+
const capped = matched.slice(0, MAX_SURFACE_FILES);
|
|
52998
|
+
if (truncated || matched.length > MAX_SURFACE_FILES) {
|
|
52999
|
+
notes.push(
|
|
53000
|
+
`repo tree is large/truncated \u2014 some agent-surface files may be INCOMPLETE (scanned ${capped.length} of ${matched.length}${truncated ? "+" : ""}).`
|
|
53001
|
+
);
|
|
53002
|
+
}
|
|
53003
|
+
if (softSkipped.length) {
|
|
53004
|
+
notes.push(
|
|
53005
|
+
`skipped ${softSkipped.length} agent-surface file(s) under a build-output dir (dist/build/out/target), e.g. ${softSkipped.slice(0, 3).join(", ")} \u2014 if any is a real committed config, move it out of the build dir to have it scanned.`
|
|
53006
|
+
);
|
|
53007
|
+
}
|
|
53008
|
+
return capped;
|
|
53009
|
+
}
|
|
52980
53010
|
async function pooled(items, limit, fn) {
|
|
52981
53011
|
const out = new Array(items.length);
|
|
52982
53012
|
let next = 0;
|
|
@@ -53068,13 +53098,37 @@ async function listWorkflowPaths(owner, repo, notes) {
|
|
|
53068
53098
|
if (status !== 200 || !Array.isArray(json)) return [];
|
|
53069
53099
|
return json.filter((e) => e.type === "file" && /\.ya?ml$/.test(e.name)).map((e) => e.path);
|
|
53070
53100
|
}
|
|
53101
|
+
var ROOT_WORKFLOW_RE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
53102
|
+
async function listSurfaceTree(owner, repo, notes) {
|
|
53103
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`;
|
|
53104
|
+
const { status, json } = await ghGet(url);
|
|
53105
|
+
if (status === 403 || status === 429) {
|
|
53106
|
+
if (!notes.includes(RATE_LIMIT_NOTE)) notes.push(RATE_LIMIT_NOTE);
|
|
53107
|
+
return null;
|
|
53108
|
+
}
|
|
53109
|
+
if (status === 0) {
|
|
53110
|
+
if (!notes.includes(NETWORK_NOTE)) notes.push(NETWORK_NOTE);
|
|
53111
|
+
return null;
|
|
53112
|
+
}
|
|
53113
|
+
if (status !== 200 || !json || typeof json !== "object") return null;
|
|
53114
|
+
const tree = json;
|
|
53115
|
+
if (!Array.isArray(tree.tree)) return null;
|
|
53116
|
+
const blobs = tree.tree.filter((e) => e.type === "blob" && typeof e.path === "string").map((e) => e.path);
|
|
53117
|
+
return {
|
|
53118
|
+
surface: pickSurfacePaths(blobs, !!tree.truncated, notes),
|
|
53119
|
+
workflows: blobs.filter((p) => ROOT_WORKFLOW_RE.test(p))
|
|
53120
|
+
};
|
|
53121
|
+
}
|
|
53071
53122
|
var FETCH_CONCURRENCY = 8;
|
|
53072
53123
|
async function fetchGitHubTree(owner, repo, onProgress) {
|
|
53073
53124
|
const notes = [];
|
|
53074
53125
|
try {
|
|
53075
|
-
onProgress?.({ phase: "
|
|
53076
|
-
const
|
|
53077
|
-
const
|
|
53126
|
+
onProgress?.({ phase: "discovering agent surface", done: 0, total: 1 });
|
|
53127
|
+
const discovered = await listSurfaceTree(owner, repo, notes);
|
|
53128
|
+
const workflowPaths = discovered ? discovered.workflows : await listWorkflowPaths(owner, repo, notes);
|
|
53129
|
+
const allPaths = [
|
|
53130
|
+
.../* @__PURE__ */ new Set([...SURFACE_FILES, ...discovered?.surface ?? [], ...workflowPaths])
|
|
53131
|
+
];
|
|
53078
53132
|
let done = 0;
|
|
53079
53133
|
const fetched = await pooled(allPaths, FETCH_CONCURRENCY, async (p) => {
|
|
53080
53134
|
const f = await fetchOne(owner, repo, p, notes);
|
|
@@ -53083,7 +53137,9 @@ async function fetchGitHubTree(owner, repo, onProgress) {
|
|
|
53083
53137
|
});
|
|
53084
53138
|
return { source: `${owner}/${repo}`, files: fetched.filter((f) => !!f), notes };
|
|
53085
53139
|
} catch (err2) {
|
|
53086
|
-
notes.push(
|
|
53140
|
+
notes.push(
|
|
53141
|
+
`fetch degraded: ${err2?.message ?? "network error"} \u2014 results may be INCOMPLETE (the repo could not be fetched).`
|
|
53142
|
+
);
|
|
53087
53143
|
return { source: `${owner}/${repo}`, files: [], notes };
|
|
53088
53144
|
}
|
|
53089
53145
|
}
|
|
@@ -53100,7 +53156,42 @@ function readLocalTree(dir) {
|
|
|
53100
53156
|
} catch {
|
|
53101
53157
|
}
|
|
53102
53158
|
};
|
|
53103
|
-
|
|
53159
|
+
const seen = /* @__PURE__ */ new Set();
|
|
53160
|
+
const collect = (rel) => {
|
|
53161
|
+
if (seen.has(rel)) return;
|
|
53162
|
+
seen.add(rel);
|
|
53163
|
+
add(rel);
|
|
53164
|
+
};
|
|
53165
|
+
for (const p of SURFACE_FILES) collect(p);
|
|
53166
|
+
const matches = [];
|
|
53167
|
+
const MAX_DIRS = 5e3;
|
|
53168
|
+
let dirsVisited = 0;
|
|
53169
|
+
const walk = (relDir) => {
|
|
53170
|
+
if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
|
|
53171
|
+
dirsVisited++;
|
|
53172
|
+
let entries;
|
|
53173
|
+
try {
|
|
53174
|
+
entries = import_fs59.default.readdirSync(import_path56.default.join(root, relDir), { withFileTypes: true });
|
|
53175
|
+
} catch {
|
|
53176
|
+
return;
|
|
53177
|
+
}
|
|
53178
|
+
for (const e of entries) {
|
|
53179
|
+
if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
|
|
53180
|
+
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
53181
|
+
if (e.isDirectory()) {
|
|
53182
|
+
if (isIgnoredDir(`${rel}/`)) continue;
|
|
53183
|
+
walk(rel);
|
|
53184
|
+
} else if (e.isFile() && SURFACE_BASENAME.test(rel)) {
|
|
53185
|
+
matches.push(rel);
|
|
53186
|
+
}
|
|
53187
|
+
}
|
|
53188
|
+
};
|
|
53189
|
+
walk("");
|
|
53190
|
+
if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS)
|
|
53191
|
+
notes.push(
|
|
53192
|
+
`repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files / ${MAX_DIRS} dirs).`
|
|
53193
|
+
);
|
|
53194
|
+
for (const rel of matches) collect(rel);
|
|
53104
53195
|
const wfDir = import_path56.default.join(root, WORKFLOW_DIR);
|
|
53105
53196
|
try {
|
|
53106
53197
|
if (import_fs59.default.existsSync(wfDir)) {
|
|
@@ -53141,6 +53232,18 @@ var AGENT_ACTION_RE = /(anthropics\/claude-code(-base)?-action|anthropics\/claud
|
|
|
53141
53232
|
var BROAD_TOOL_RE = /(^|["\s,])(Bash|Write|Edit)(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|git push|git commit|git config|git:|rm|sh|bash|eval|npx|pip)/i;
|
|
53142
53233
|
var EXFIL_RCE_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|sh[\s):]|eval|rm[\s):]|git push)/i;
|
|
53143
53234
|
var GH_WRITE_TOOL_RE = /Bash\(\s*(gh api|gh:|gh (pr|issue) (comment|edit|merge|close|review|create|ready|lock|reopen)|git push|git:)/i;
|
|
53235
|
+
var EGRESS_TOOL_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\(\s*(curl|wget|python|python3|node|deno|bun|ruby|perl|php|pip|pipx|npx|nc|ncat|ssh|scp|http|gh api)|WebFetch|WebSearch/i;
|
|
53236
|
+
var MEMBERSHIP_CHECK_RE = /orgs\/[^/'"\s]+\/memberships\/|teams\/[^/'"\s]+\/memberships\/|getCollaboratorPermissionLevel|collaborators\/[^/'"\s]+\/permission|checkMembershipForUser|getMembershipForUser/i;
|
|
53237
|
+
var STRANGER_ISSUE_TYPES = /* @__PURE__ */ new Set(["opened", "edited", "reopened", "closed"]);
|
|
53238
|
+
var STRANGER_PR_TYPES = /* @__PURE__ */ new Set([
|
|
53239
|
+
"opened",
|
|
53240
|
+
"edited",
|
|
53241
|
+
"reopened",
|
|
53242
|
+
"closed",
|
|
53243
|
+
"synchronize",
|
|
53244
|
+
"ready_for_review",
|
|
53245
|
+
"converted_to_draft"
|
|
53246
|
+
]);
|
|
53144
53247
|
function str(v) {
|
|
53145
53248
|
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
|
|
53146
53249
|
}
|
|
@@ -53151,9 +53254,57 @@ function triggerKeys(wf, raw) {
|
|
|
53151
53254
|
if (on && typeof on === "object") return Object.keys(on);
|
|
53152
53255
|
return [];
|
|
53153
53256
|
}
|
|
53257
|
+
function onObject(wf, raw) {
|
|
53258
|
+
return wf.on ?? raw["on"] ?? raw[true];
|
|
53259
|
+
}
|
|
53260
|
+
function activityTypes(node) {
|
|
53261
|
+
return node && Array.isArray(node.types) ? node.types.map(String) : [];
|
|
53262
|
+
}
|
|
53263
|
+
function keyStrangerFirable(on, key) {
|
|
53264
|
+
switch (key) {
|
|
53265
|
+
case "issue_comment":
|
|
53266
|
+
case "pull_request_review":
|
|
53267
|
+
case "pull_request_review_comment":
|
|
53268
|
+
case "workflow_run":
|
|
53269
|
+
case "discussion":
|
|
53270
|
+
case "discussion_comment":
|
|
53271
|
+
case "fork":
|
|
53272
|
+
case "watch":
|
|
53273
|
+
case "public":
|
|
53274
|
+
return true;
|
|
53275
|
+
case "pull_request":
|
|
53276
|
+
case "pull_request_target": {
|
|
53277
|
+
const ts = activityTypes(on[key]);
|
|
53278
|
+
return ts.length ? ts.some((t) => STRANGER_PR_TYPES.has(t)) : true;
|
|
53279
|
+
}
|
|
53280
|
+
case "issues": {
|
|
53281
|
+
const ts = activityTypes(on["issues"]);
|
|
53282
|
+
return ts.length ? ts.some((t) => STRANGER_ISSUE_TYPES.has(t)) : true;
|
|
53283
|
+
}
|
|
53284
|
+
// workflow_call / workflow_dispatch / schedule / push / create / delete / …
|
|
53285
|
+
// are NOT stranger-firable (require write access, or run in a trusted context).
|
|
53286
|
+
default:
|
|
53287
|
+
return false;
|
|
53288
|
+
}
|
|
53289
|
+
}
|
|
53290
|
+
function triggerReach(wf, raw) {
|
|
53291
|
+
const on = onObject(wf, raw) ?? {};
|
|
53292
|
+
const keys = triggerKeys(wf, raw);
|
|
53293
|
+
const firable = keys.filter((k) => keyStrangerFirable(on, k));
|
|
53294
|
+
const untrusted = firable.length > 0;
|
|
53295
|
+
const reusable = !untrusted && keys.some((k) => /^workflow_call$/i.test(k));
|
|
53296
|
+
const secretExposed = firable.some((t) => /pull_request_target|workflow_run/i.test(t));
|
|
53297
|
+
const privileged = untrusted && firable.some(
|
|
53298
|
+
(t) => /pull_request_target|pull_request_review|workflow_run|issue|discussion/i.test(t)
|
|
53299
|
+
);
|
|
53300
|
+
return { keys, untrusted, reusable, secretExposed, privileged };
|
|
53301
|
+
}
|
|
53302
|
+
function jobList(wf) {
|
|
53303
|
+
return Object.values(wf.jobs ?? {}).filter((j) => j != null);
|
|
53304
|
+
}
|
|
53154
53305
|
function allSteps(wf) {
|
|
53155
53306
|
const out = [];
|
|
53156
|
-
for (const job of
|
|
53307
|
+
for (const job of jobList(wf)) {
|
|
53157
53308
|
for (const step of job.steps ?? []) out.push({ job, step });
|
|
53158
53309
|
}
|
|
53159
53310
|
return out;
|
|
@@ -53164,18 +53315,32 @@ function isAgentStep(step) {
|
|
|
53164
53315
|
return true;
|
|
53165
53316
|
return false;
|
|
53166
53317
|
}
|
|
53318
|
+
function allowedToolsFromArgs(claudeArgs) {
|
|
53319
|
+
let out = "";
|
|
53320
|
+
for (const m of claudeArgs.matchAll(/--allowed[-_]?tools[=\s]+("[^"]*"|'[^']*'|\S+)/gi))
|
|
53321
|
+
out += " " + m[1];
|
|
53322
|
+
return out;
|
|
53323
|
+
}
|
|
53324
|
+
function allowedToolsFromSettings(settings) {
|
|
53325
|
+
let out = "";
|
|
53326
|
+
for (const key of ["allowedTools", "allow"]) {
|
|
53327
|
+
for (const m of settings.matchAll(new RegExp(`"${key}"\\s*:\\s*(\\[[^\\]]*\\])`, "gi")))
|
|
53328
|
+
out += " " + m[1];
|
|
53329
|
+
}
|
|
53330
|
+
return out;
|
|
53331
|
+
}
|
|
53167
53332
|
function collectTools(steps) {
|
|
53168
|
-
const stripDeny = (x) => x.replace(/--disallowed[-_]?tools\s+("[^"]*"|'[^']*'|\S+)/gi, " ").replace(/["']disallowed[_]?[tT]ools["']\s*:\s*\[[^\]]*\]/g, " ");
|
|
53169
53333
|
let s = "";
|
|
53170
53334
|
for (const st of steps) {
|
|
53171
53335
|
const w = st.with ?? {};
|
|
53172
|
-
s += " " +
|
|
53173
|
-
s += " " +
|
|
53336
|
+
s += " " + allowedToolsFromArgs(str(w["claude_args"]));
|
|
53337
|
+
s += " " + str(w["allowed_tools"]) + " " + str(w["allowedTools"]);
|
|
53338
|
+
s += " " + allowedToolsFromSettings(str(w["settings"]));
|
|
53174
53339
|
}
|
|
53175
53340
|
return s;
|
|
53176
53341
|
}
|
|
53177
|
-
function untrustedHeadCheckout(
|
|
53178
|
-
for (const
|
|
53342
|
+
function untrustedHeadCheckout(steps) {
|
|
53343
|
+
for (const step of steps) {
|
|
53179
53344
|
if (!step.uses || !/actions\/checkout/.test(step.uses)) continue;
|
|
53180
53345
|
const ref = str(step.with?.["ref"]);
|
|
53181
53346
|
if (/pull_request\.head|head[._]sha|head_ref|expected_head|workflow_run\.head|inputs\.[\w]*head/i.test(
|
|
@@ -53194,43 +53359,100 @@ function promptTakesUntrusted(steps) {
|
|
|
53194
53359
|
}
|
|
53195
53360
|
return false;
|
|
53196
53361
|
}
|
|
53197
|
-
|
|
53362
|
+
var NONCONTAINS_GATE_RE = new RegExp(
|
|
53363
|
+
[
|
|
53364
|
+
String.raw`==\s*['"]?(OWNER|MEMBER|COLLABORATOR)\b`,
|
|
53365
|
+
String.raw`==\s*['"](write|admin|maintain)`,
|
|
53366
|
+
String.raw`github\.actor\s*==`,
|
|
53367
|
+
String.raw`user\.login\s*==`,
|
|
53368
|
+
String.raw`head\.repo\.full_name\s*==\s*github\.repository`
|
|
53369
|
+
].join("|"),
|
|
53370
|
+
"i"
|
|
53371
|
+
);
|
|
53372
|
+
var PERMISSION_OUTPUT_GATE_RE = /steps\.[\w-]+\.outputs\.[\w-]*(permission|allowed|authoriz|is[_-]?(admin|member|maintainer|collaborator))[\w-]*\s*==\s*['"]?(true|admin|write|maintain)/i;
|
|
53373
|
+
var CONTAINS_GATE_RE = /contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
|
|
53374
|
+
var NEGATED_CONTAINS_RE = /!\s*\(?\s*contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
|
|
53375
|
+
function labelTypeConfigured(wf, raw) {
|
|
53198
53376
|
const on = wf.on ?? raw["on"] ?? raw[true];
|
|
53199
53377
|
const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
|
|
53200
|
-
|
|
53201
|
-
|
|
53202
|
-
|
|
53203
|
-
|
|
53204
|
-
|
|
53205
|
-
const
|
|
53206
|
-
ifs
|
|
53207
|
-
);
|
|
53208
|
-
const labelGated = !!labeled && /event\.label|label\.name/i.test(ifs);
|
|
53378
|
+
return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
|
|
53379
|
+
}
|
|
53380
|
+
function ifsAreGated(ifs, labelConfigured) {
|
|
53381
|
+
const containsGate = CONTAINS_GATE_RE.test(ifs) && !NEGATED_CONTAINS_RE.test(ifs);
|
|
53382
|
+
const gated = NONCONTAINS_GATE_RE.test(ifs) || containsGate;
|
|
53383
|
+
const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
|
|
53209
53384
|
return gated || labelGated;
|
|
53210
53385
|
}
|
|
53386
|
+
function hasActorGate(wf, raw) {
|
|
53387
|
+
const ifs = [jobList(wf).map((j) => j.if), allSteps(wf).map((s) => s.step.if)].flat().map(str).join(" ");
|
|
53388
|
+
return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
|
|
53389
|
+
}
|
|
53390
|
+
function hasStepMembershipGate(job) {
|
|
53391
|
+
const steps = job.steps ?? [];
|
|
53392
|
+
const gateIds = steps.filter((s) => s.id && MEMBERSHIP_CHECK_RE.test(str(s.run))).map((s) => s.id);
|
|
53393
|
+
if (!gateIds.length) return false;
|
|
53394
|
+
return steps.some(
|
|
53395
|
+
(s) => isAgentStep(s) && gateIds.some(
|
|
53396
|
+
(id) => new RegExp(`steps\\.${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.outputs\\.`).test(
|
|
53397
|
+
str(s.if)
|
|
53398
|
+
)
|
|
53399
|
+
)
|
|
53400
|
+
);
|
|
53401
|
+
}
|
|
53402
|
+
function jobActorGate(job, wf, raw) {
|
|
53403
|
+
const ifs = [job.if, ...(job.steps ?? []).map((s) => s.if)].map(str).join(" ");
|
|
53404
|
+
return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs) || PERMISSION_OUTPUT_GATE_RE.test(ifs) || // [2] job-scoped permission-check-output gate
|
|
53405
|
+
hasStepMembershipGate(job);
|
|
53406
|
+
}
|
|
53211
53407
|
function hasImplicitActorGate(agentSteps, bypassActive) {
|
|
53212
53408
|
if (bypassActive) return false;
|
|
53213
53409
|
return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
|
|
53214
53410
|
}
|
|
53215
|
-
function
|
|
53216
|
-
const
|
|
53217
|
-
|
|
53218
|
-
|
|
53219
|
-
|
|
53220
|
-
|
|
53411
|
+
function injectableJobs(wf, raw, untrustedTrigger) {
|
|
53412
|
+
const out = [];
|
|
53413
|
+
for (const job of jobList(wf)) {
|
|
53414
|
+
const a = (job.steps ?? []).filter(isAgentStep);
|
|
53415
|
+
if (!a.length) continue;
|
|
53416
|
+
const jobStar = str(a.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53417
|
+
if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobStar && untrustedTrigger))
|
|
53418
|
+
continue;
|
|
53419
|
+
out.push(job);
|
|
53420
|
+
}
|
|
53421
|
+
return out;
|
|
53422
|
+
}
|
|
53423
|
+
function usesPatIn(jobs) {
|
|
53424
|
+
for (const j of jobs)
|
|
53425
|
+
for (const step of j.steps ?? []) {
|
|
53426
|
+
const gt = str(step.with?.["github_token"]);
|
|
53427
|
+
if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
|
|
53428
|
+
}
|
|
53429
|
+
return false;
|
|
53430
|
+
}
|
|
53431
|
+
function jobPerms(wf, job) {
|
|
53432
|
+
return job.permissions != null ? str(job.permissions) : str(wf.permissions);
|
|
53433
|
+
}
|
|
53434
|
+
function jobsHaveWrite(wf, jobs, rx) {
|
|
53435
|
+
return jobs.some((j) => {
|
|
53436
|
+
const p = jobPerms(wf, j);
|
|
53437
|
+
return /\bwrite-all\b/i.test(p) || rx.test(p);
|
|
53438
|
+
});
|
|
53439
|
+
}
|
|
53440
|
+
function hasIdTokenWrite(wf, jobs) {
|
|
53441
|
+
const rx = /["']?id-token["']?\s*:\s*["']?write/i;
|
|
53442
|
+
return jobs.some((j) => rx.test(jobPerms(wf, j)));
|
|
53443
|
+
}
|
|
53444
|
+
function hasPrWritePerm(wf, jobs) {
|
|
53445
|
+
return jobsHaveWrite(wf, jobs, /["']?pull-requests["']?\s*:\s*["']?write/i);
|
|
53221
53446
|
}
|
|
53222
|
-
function
|
|
53223
|
-
|
|
53224
|
-
|
|
53447
|
+
function hasCodeWritePerm(wf, agentJobs) {
|
|
53448
|
+
return jobsHaveWrite(
|
|
53449
|
+
wf,
|
|
53450
|
+
agentJobs,
|
|
53451
|
+
/["']?(contents|packages|actions|deployments)["']?\s*:\s*["']?write/i
|
|
53225
53452
|
);
|
|
53226
|
-
return check(wf.permissions) || agentJobs.some((j) => check(j.permissions));
|
|
53227
53453
|
}
|
|
53228
|
-
function
|
|
53229
|
-
|
|
53230
|
-
const gt = str(step.with?.["github_token"]);
|
|
53231
|
-
if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
|
|
53232
|
-
}
|
|
53233
|
-
return false;
|
|
53454
|
+
function hasMetaWritePerm(wf, agentJobs) {
|
|
53455
|
+
return jobsHaveWrite(wf, agentJobs, /["']?(pull-requests|issues)["']?\s*:\s*["']?write/i);
|
|
53234
53456
|
}
|
|
53235
53457
|
function hasEnvDeny(steps) {
|
|
53236
53458
|
return steps.some((s) => /"?mode"?\s*:\s*"?deny/i.test(str(s.with?.["settings"])));
|
|
@@ -53263,31 +53485,38 @@ function analyzeWorkflow(path70, content) {
|
|
|
53263
53485
|
)
|
|
53264
53486
|
];
|
|
53265
53487
|
if (agentSteps.length === 0) return null;
|
|
53266
|
-
const
|
|
53267
|
-
|
|
53268
|
-
|
|
53269
|
-
|
|
53270
|
-
|
|
53271
|
-
|
|
53488
|
+
const {
|
|
53489
|
+
keys: triggers,
|
|
53490
|
+
untrusted: forkInput,
|
|
53491
|
+
secretExposed,
|
|
53492
|
+
reusable,
|
|
53493
|
+
privileged
|
|
53494
|
+
} = triggerReach(wf, raw);
|
|
53272
53495
|
const nonWrite = str(agentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean));
|
|
53273
53496
|
const nonWriteStar = nonWrite === "*";
|
|
53274
53497
|
const nonWriteList = !!nonWrite && nonWrite !== "*";
|
|
53275
|
-
const untrustedTrigger =
|
|
53498
|
+
const untrustedTrigger = forkInput || reusable;
|
|
53276
53499
|
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53277
|
-
const head = untrustedHeadCheckout(
|
|
53500
|
+
const head = untrustedHeadCheckout(steps);
|
|
53278
53501
|
const promptUntrusted = promptTakesUntrusted(agentSteps);
|
|
53279
|
-
const
|
|
53502
|
+
const injJobs = injectableJobs(wf, raw, untrustedTrigger);
|
|
53503
|
+
const powerJobs = injJobs.length ? injJobs : agentJobs;
|
|
53504
|
+
const powerSteps = injJobs.flatMap((j) => (j.steps ?? []).filter(isAgentStep));
|
|
53505
|
+
const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
|
|
53506
|
+
const toolsBlob = collectTools(scopedSteps);
|
|
53507
|
+
const reach = untrustedTrigger && injJobs.length ? Math.max(
|
|
53280
53508
|
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53281
53509
|
promptUntrusted ? 2 : 0,
|
|
53282
53510
|
bypassActive ? 2 : 0
|
|
53283
|
-
);
|
|
53284
|
-
const toolsBlob = collectTools(agentSteps);
|
|
53511
|
+
) : 0;
|
|
53285
53512
|
const broadTools = BROAD_TOOL_RE.test(toolsBlob);
|
|
53286
|
-
const
|
|
53287
|
-
const
|
|
53513
|
+
const egressTool = EGRESS_TOOL_RE.test(toolsBlob);
|
|
53514
|
+
const elevated = hasCodeWritePerm(wf, powerJobs) || hasIdTokenWrite(wf, powerJobs) && egressTool;
|
|
53515
|
+
const pat = usesPatIn(powerJobs);
|
|
53288
53516
|
const power = (broadTools ? 2 : 0) + (bypassActive ? 1 : 0) + (elevated ? 1 : 0) + (pat ? 1 : 0);
|
|
53289
53517
|
const explicitGate = hasActorGate(wf, raw);
|
|
53290
53518
|
const implicitGate = hasImplicitActorGate(agentSteps, bypassActive);
|
|
53519
|
+
const membershipGated = injJobs.length === 0 && jobList(wf).some((j) => (j.steps ?? []).some(isAgentStep) && hasStepMembershipGate(j));
|
|
53291
53520
|
const gate = explicitGate || implicitGate;
|
|
53292
53521
|
const envDeny = hasEnvDeny(agentSteps);
|
|
53293
53522
|
const pinned = agentActionsPinned(agentSteps);
|
|
@@ -53296,7 +53525,7 @@ function analyzeWorkflow(path70, content) {
|
|
|
53296
53525
|
if (envDeny) score -= 1;
|
|
53297
53526
|
if (pinned) score -= 1;
|
|
53298
53527
|
score = Math.max(0, score);
|
|
53299
|
-
if (score === 0 && !secretExposed) return null;
|
|
53528
|
+
if (score === 0 && !secretExposed && !reusable) return null;
|
|
53300
53529
|
let severity = severityFromScore(score);
|
|
53301
53530
|
if (gate && severity && severity !== "advisory") severity = "advisory";
|
|
53302
53531
|
if (reach === 0 && severity && severity !== "advisory") severity = "advisory";
|
|
@@ -53304,9 +53533,20 @@ function analyzeWorkflow(path70, content) {
|
|
|
53304
53533
|
severity = "medium";
|
|
53305
53534
|
const exfilOrRce = EXFIL_RCE_RE.test(toolsBlob);
|
|
53306
53535
|
const githubWriteTool = GH_WRITE_TOOL_RE.test(toolsBlob);
|
|
53307
|
-
const
|
|
53536
|
+
const rce = exfilOrRce;
|
|
53537
|
+
const codeWrite = pat || hasCodeWritePerm(wf, powerJobs) && githubWriteTool;
|
|
53538
|
+
const metaWrite = hasMetaWritePerm(wf, powerJobs) && githubWriteTool;
|
|
53539
|
+
const canDamage = rce || codeWrite || metaWrite;
|
|
53308
53540
|
if (!canDamage && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
|
|
53309
|
-
if (
|
|
53541
|
+
if (canDamage && !rce && !codeWrite && severity === "critical") severity = "high";
|
|
53542
|
+
const issueOnlyWrite = metaWrite && !hasPrWritePerm(wf, powerJobs) && !hasCodeWritePerm(wf, powerJobs);
|
|
53543
|
+
if (issueOnlyWrite && !rce && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
|
|
53544
|
+
severity = "medium";
|
|
53545
|
+
if (untrustedTrigger && !privileged && !reusable && severity && severity !== "advisory")
|
|
53546
|
+
severity = "advisory";
|
|
53547
|
+
const reusableLoadedGun = reusable && (head === "root" || promptUntrusted);
|
|
53548
|
+
if (reusable && !reusableLoadedGun && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
|
|
53549
|
+
severity = "medium";
|
|
53310
53550
|
if (!severity) severity = "advisory";
|
|
53311
53551
|
const signals = [];
|
|
53312
53552
|
if (secretExposed)
|
|
@@ -53314,7 +53554,11 @@ function analyzeWorkflow(path70, content) {
|
|
|
53314
53554
|
`runs with base-repo secrets (${triggers.filter((t) => /target|workflow_run/i.test(t)).join(", ")})`
|
|
53315
53555
|
);
|
|
53316
53556
|
else if (forkInput) signals.push(`triggered by untrusted input (${triggers.join(", ")})`);
|
|
53317
|
-
if (
|
|
53557
|
+
else if (reusable)
|
|
53558
|
+
signals.push(
|
|
53559
|
+
reusableLoadedGun ? `reusable workflow (${triggers.join(", ")}) that checks out an untrusted head / ingests untrusted input \u2014 exploitable the moment a caller wires a fork trigger (reachability depends on the caller, but this workflow is built to process attacker input)` : `reusable workflow (${triggers.join(", ")}) \u2014 no untrusted trigger of its own; reachability depends on the caller's trigger + actor gate`
|
|
53560
|
+
);
|
|
53561
|
+
if (forkInput && !privileged && !reusable)
|
|
53318
53562
|
signals.push(
|
|
53319
53563
|
"triggered by `pull_request` \u2014 fork PRs run with a read-only token (lower risk than pull_request_target)"
|
|
53320
53564
|
);
|
|
@@ -53326,8 +53570,14 @@ function analyzeWorkflow(path70, content) {
|
|
|
53326
53570
|
if (elevated) signals.push("elevated permissions (contents/id-token: write)");
|
|
53327
53571
|
if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
|
|
53328
53572
|
if (!gate && reach > 0) signals.push("no effective actor gate");
|
|
53573
|
+
const noExplicitPerms = wf.permissions == null && jobList(wf).every((j) => j.permissions == null);
|
|
53574
|
+
if (noExplicitPerms && reach > 0 && (broadTools || githubWriteTool))
|
|
53575
|
+
signals.push(
|
|
53576
|
+
"no explicit `permissions:` \u2014 the token defaults to the repo/org setting, which may grant write; set it explicitly to read-only"
|
|
53577
|
+
);
|
|
53329
53578
|
const mitigations = [];
|
|
53330
|
-
if (explicitGate
|
|
53579
|
+
if (explicitGate || membershipGated)
|
|
53580
|
+
mitigations.push("actor-gated (maintainer/label/write-user required)");
|
|
53331
53581
|
else if (implicitGate)
|
|
53332
53582
|
mitigations.push("claude-code-action gates the agent to write-access users by default");
|
|
53333
53583
|
if (head === "subdir") mitigations.push("untrusted head isolated in a subdir, not root");
|
|
@@ -53346,6 +53596,105 @@ function analyzeWorkflow(path70, content) {
|
|
|
53346
53596
|
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
53597
|
};
|
|
53348
53598
|
}
|
|
53599
|
+
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;
|
|
53600
|
+
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;
|
|
53601
|
+
function fuelSecretNames(agentSteps) {
|
|
53602
|
+
const out = /* @__PURE__ */ new Set();
|
|
53603
|
+
const add = (v) => {
|
|
53604
|
+
for (const m of str(v).matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) out.add(m[1]);
|
|
53605
|
+
};
|
|
53606
|
+
for (const st of agentSteps) {
|
|
53607
|
+
for (const [k, v] of Object.entries(st.with ?? {})) if (FUEL_INPUT_KEYS.test(k)) add(v);
|
|
53608
|
+
for (const [k, v] of Object.entries(st.env ?? {})) if (AGENT_FUEL_RE.test(k)) add(v);
|
|
53609
|
+
}
|
|
53610
|
+
return out;
|
|
53611
|
+
}
|
|
53612
|
+
function classifySecret(name) {
|
|
53613
|
+
if (/AWS_|AZURE_|GCP_|GOOGLE_APPLICATION|GCLOUD/i.test(name)) return "cloud";
|
|
53614
|
+
if (/_PAT\b|PAT$|_TOKEN$|GH_TOKEN/i.test(name)) return "pat";
|
|
53615
|
+
if (/DATABASE|_DB_|POSTGRES|MYSQL|REDIS|MONGO|CONNECTION_STRING/i.test(name)) return "db";
|
|
53616
|
+
if (/API_KEY|_KEY$|SECRET|PASSWORD|PASSWD/i.test(name)) return "api-key";
|
|
53617
|
+
return "generic";
|
|
53618
|
+
}
|
|
53619
|
+
function agentReachableSecrets(wf, agentSteps, agentJobs) {
|
|
53620
|
+
const blobs = [];
|
|
53621
|
+
for (const st of agentSteps) blobs.push(str(st.env), str(st.with));
|
|
53622
|
+
for (const j of agentJobs) blobs.push(str(j.env));
|
|
53623
|
+
blobs.push(str(wf.env));
|
|
53624
|
+
const fuel = fuelSecretNames(agentSteps);
|
|
53625
|
+
const found = /* @__PURE__ */ new Map();
|
|
53626
|
+
for (const b of blobs) {
|
|
53627
|
+
for (const m of b.matchAll(/secrets\.([A-Za-z_][A-Za-z0-9_]*)/gi)) {
|
|
53628
|
+
const name = m[1];
|
|
53629
|
+
if (AGENT_FUEL_RE.test(name) || fuel.has(name)) continue;
|
|
53630
|
+
found.set(name, classifySecret(name));
|
|
53631
|
+
}
|
|
53632
|
+
}
|
|
53633
|
+
const idToken = hasIdTokenWrite(wf, agentJobs);
|
|
53634
|
+
const out = [...found].map(([name, kind]) => ({ name, kind }));
|
|
53635
|
+
if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
|
|
53636
|
+
return out;
|
|
53637
|
+
}
|
|
53638
|
+
function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
|
|
53639
|
+
const jobSteps = job.steps ?? [];
|
|
53640
|
+
const jobAgentSteps = jobSteps.filter(isAgentStep);
|
|
53641
|
+
if (jobAgentSteps.length === 0) return null;
|
|
53642
|
+
const secrets = agentReachableSecrets(wf, jobAgentSteps, [job]);
|
|
53643
|
+
if (secrets.length === 0) return null;
|
|
53644
|
+
const nonWriteStar = str(jobAgentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53645
|
+
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53646
|
+
const head = untrustedHeadCheckout(jobSteps);
|
|
53647
|
+
const reach = Math.max(
|
|
53648
|
+
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53649
|
+
promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
|
|
53650
|
+
bypassActive ? 2 : 0
|
|
53651
|
+
);
|
|
53652
|
+
const gate = jobActorGate(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
|
|
53653
|
+
const injectable = untrustedTrigger && !gate && reach > 0;
|
|
53654
|
+
const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
|
|
53655
|
+
const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
|
|
53656
|
+
const hasOidc = secrets.some((s) => s.kind === "cloud-oidc");
|
|
53657
|
+
let severity;
|
|
53658
|
+
if (injectable && canReadEnv) {
|
|
53659
|
+
severity = hasOidc || realSecrets.some((s) => ["cloud", "pat", "db"].includes(s.kind)) ? "critical" : "high";
|
|
53660
|
+
} else if (realSecrets.length > 0) {
|
|
53661
|
+
severity = "advisory";
|
|
53662
|
+
} else {
|
|
53663
|
+
return null;
|
|
53664
|
+
}
|
|
53665
|
+
const loadedGun = head === "root" || promptTakesUntrusted(jobAgentSteps);
|
|
53666
|
+
if (reusable && !loadedGun && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
|
|
53667
|
+
return { severity, secrets, injectable, canReadEnv };
|
|
53668
|
+
}
|
|
53669
|
+
function analyzeWorkflowSecrets(path70, content) {
|
|
53670
|
+
let raw;
|
|
53671
|
+
try {
|
|
53672
|
+
raw = (0, import_yaml.parse)(content) ?? {};
|
|
53673
|
+
} catch {
|
|
53674
|
+
return null;
|
|
53675
|
+
}
|
|
53676
|
+
const wf = raw;
|
|
53677
|
+
const all = allSteps(wf);
|
|
53678
|
+
if (!all.some((s) => isAgentStep(s.step))) return null;
|
|
53679
|
+
const { untrusted: forkInput, reusable } = triggerReach(wf, raw);
|
|
53680
|
+
const untrustedTrigger = forkInput || reusable;
|
|
53681
|
+
const agentJobs = [...new Set(all.filter((s) => isAgentStep(s.step)).map((s) => s.job))];
|
|
53682
|
+
const worst = agentJobs.map((job) => evalAgentJob(job, wf, raw, untrustedTrigger, reusable)).filter((e) => e !== null).sort((a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity])[0];
|
|
53683
|
+
if (!worst) return null;
|
|
53684
|
+
return {
|
|
53685
|
+
check: "CI-4",
|
|
53686
|
+
dimension: "data",
|
|
53687
|
+
severity: worst.severity,
|
|
53688
|
+
title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
|
|
53689
|
+
file: path70,
|
|
53690
|
+
signals: [
|
|
53691
|
+
`agent can reach: ${worst.secrets.map((s) => s.name).join(", ")}`,
|
|
53692
|
+
worst.injectable ? "the agent is externally triggerable (untrusted trigger, no gate)" : "gated / not externally triggerable \u2014 latent risk only",
|
|
53693
|
+
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"
|
|
53694
|
+
],
|
|
53695
|
+
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."
|
|
53696
|
+
};
|
|
53697
|
+
}
|
|
53349
53698
|
|
|
53350
53699
|
// src/ci-check/agent-config.ts
|
|
53351
53700
|
function asStrings(v) {
|
|
@@ -53373,18 +53722,20 @@ function analyzeAgentConfig(path70, content) {
|
|
|
53373
53722
|
}
|
|
53374
53723
|
const findings = [];
|
|
53375
53724
|
for (const cmd of hookCommands(cfg.hooks)) {
|
|
53376
|
-
const
|
|
53377
|
-
|
|
53378
|
-
|
|
53725
|
+
const remoteExec = /\|\s*(sh|bash|zsh)\b/.test(cmd) || /\b(curl|wget|iwr|irm)\b/.test(cmd);
|
|
53726
|
+
const isNpx = /\bnpx\b/.test(cmd);
|
|
53727
|
+
if (!remoteExec && !isNpx) continue;
|
|
53728
|
+
const unpinned = /@latest\b/.test(cmd) || isNpx && !/@\d/.test(cmd);
|
|
53729
|
+
const high = remoteExec || unpinned;
|
|
53379
53730
|
findings.push({
|
|
53380
53731
|
check: "CI-1",
|
|
53381
53732
|
dimension: "toolRules",
|
|
53382
|
-
severity:
|
|
53383
|
-
title:
|
|
53733
|
+
severity: high ? "high" : "medium",
|
|
53734
|
+
title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
|
|
53384
53735
|
file: path70,
|
|
53385
53736
|
signals: [
|
|
53386
53737
|
`hook command: \`${cmd.slice(0, 120)}\``,
|
|
53387
|
-
unpinned ? "unpinned \u2014 a compromised/yanked package = code execution on every contributor" : "pinned, but still a standing supply-chain dependency in the agent hot path"
|
|
53738
|
+
remoteExec ? "fetch-and-run (curl|wget / pipe-to-shell) \u2014 unpinnable remote code execution on every contributor" : unpinned ? "unpinned \u2014 a compromised/yanked package = code execution on every contributor" : "pinned, but still a standing supply-chain dependency in the agent hot path"
|
|
53388
53739
|
],
|
|
53389
53740
|
fix: "Vendor the command as a committed local script, or pin an exact version and treat updates as security-reviewed."
|
|
53390
53741
|
});
|
|
@@ -53395,17 +53746,18 @@ function analyzeAgentConfig(path70, content) {
|
|
|
53395
53746
|
(a) => /^Bash$|^Bash\(\s*\*|^Bash\(git:|^Write\(\s*\*|^Write$|^Edit$/.test(a)
|
|
53396
53747
|
);
|
|
53397
53748
|
if (broad.length > 0) {
|
|
53749
|
+
const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
|
|
53398
53750
|
findings.push({
|
|
53399
53751
|
check: "CI-1",
|
|
53400
53752
|
dimension: "toolRules",
|
|
53401
|
-
severity: "medium",
|
|
53402
|
-
title: "Committed agent config pre-authorizes broad tools",
|
|
53753
|
+
severity: hasBackstop ? "medium" : "high",
|
|
53754
|
+
title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
|
|
53403
53755
|
file: path70,
|
|
53404
53756
|
signals: [
|
|
53405
53757
|
`broad allow(s): ${broad.slice(0, 5).join(", ")}`,
|
|
53406
|
-
|
|
53758
|
+
hasBackstop ? "a `deny` list backstops the broad allow" : "no `deny` entry covers Bash/Write/Edit \u2014 every contributor is pre-authorized for catastrophic tools"
|
|
53407
53759
|
],
|
|
53408
|
-
fix: "Scope the allow-list to specific read-only subcommands (e.g. `Bash(gh pr view:*)`); avoid bare `Bash`/`git:`/`Write
|
|
53760
|
+
fix: "Scope the allow-list to specific read-only subcommands (e.g. `Bash(gh pr view:*)`); avoid bare `Bash`/`git:`/`Write`, or add a `deny` backstop."
|
|
53409
53761
|
});
|
|
53410
53762
|
}
|
|
53411
53763
|
return findings;
|
|
@@ -53420,8 +53772,11 @@ function analyzeMcp(path70, content) {
|
|
|
53420
53772
|
} catch {
|
|
53421
53773
|
return [];
|
|
53422
53774
|
}
|
|
53775
|
+
return analyzeMcpServers(cfg.mcpServers ?? {}, path70);
|
|
53776
|
+
}
|
|
53777
|
+
function analyzeMcpServers(servers, path70) {
|
|
53423
53778
|
const findings = [];
|
|
53424
|
-
for (const [name, srv] of Object.entries(
|
|
53779
|
+
for (const [name, srv] of Object.entries(servers ?? {})) {
|
|
53425
53780
|
if (!srv || srv.disabled) continue;
|
|
53426
53781
|
const argv = [srv.command, ...Array.isArray(srv.args) ? srv.args.map(String) : []].join(" ");
|
|
53427
53782
|
if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
|
|
@@ -53456,6 +53811,199 @@ function analyzeMcp(path70, content) {
|
|
|
53456
53811
|
return findings;
|
|
53457
53812
|
}
|
|
53458
53813
|
|
|
53814
|
+
// src/ci-check/codex.ts
|
|
53815
|
+
var import_smol_toml5 = require("smol-toml");
|
|
53816
|
+
function analyzeCodexConfig(path70, content) {
|
|
53817
|
+
let cfg;
|
|
53818
|
+
try {
|
|
53819
|
+
cfg = (0, import_smol_toml5.parse)(content);
|
|
53820
|
+
} catch {
|
|
53821
|
+
return [];
|
|
53822
|
+
}
|
|
53823
|
+
const findings = [];
|
|
53824
|
+
findings.push(...analyzeMcpServers(cfg.mcp_servers ?? {}, path70));
|
|
53825
|
+
const sandbox = typeof cfg.sandbox_mode === "string" ? cfg.sandbox_mode : "";
|
|
53826
|
+
const approval = typeof cfg.approval_policy === "string" ? cfg.approval_policy : "";
|
|
53827
|
+
const fullAccess = /danger-full-access/i.test(sandbox);
|
|
53828
|
+
const noApproval = /^never$/i.test(approval);
|
|
53829
|
+
if (fullAccess || noApproval) {
|
|
53830
|
+
const signals = [
|
|
53831
|
+
fullAccess ? 'sandbox_mode = "danger-full-access" \u2014 the agent runs arbitrary commands with full disk + network access' : null,
|
|
53832
|
+
noApproval ? 'approval_policy = "never" \u2014 no human approval for agent actions' : null
|
|
53833
|
+
].filter((s) => s !== null);
|
|
53834
|
+
findings.push({
|
|
53835
|
+
check: "CI-1",
|
|
53836
|
+
dimension: "toolRules",
|
|
53837
|
+
severity: fullAccess ? "high" : "medium",
|
|
53838
|
+
title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
|
|
53839
|
+
file: path70,
|
|
53840
|
+
signals,
|
|
53841
|
+
fix: 'Commit a least-privilege Codex config: prefer `sandbox_mode = "read-only"` (or `"workspace-write"`) and `approval_policy = "on-request"`/`"on-failure"`. A repo-committed config applies to every contributor who runs Codex here.'
|
|
53842
|
+
});
|
|
53843
|
+
}
|
|
53844
|
+
return findings;
|
|
53845
|
+
}
|
|
53846
|
+
|
|
53847
|
+
// src/ci-check/instructions.ts
|
|
53848
|
+
var TAG_CHARS = /[\u{E0000}-\u{E007F}]/u;
|
|
53849
|
+
var BIDI_OVERRIDE = /[]/;
|
|
53850
|
+
var BIDI_EMBED_ISOLATE = /[--]/;
|
|
53851
|
+
function isZwLegitScript(cp) {
|
|
53852
|
+
if (cp === void 0) return false;
|
|
53853
|
+
return cp >= 3584 && cp <= 3711 || cp >= 3712 && cp <= 3839 || cp >= 4096 && cp <= 4255 || cp >= 6016 && cp <= 6143 || cp >= 12352 && cp <= 12543 || cp >= 13312 && cp <= 40959 || cp >= 44032 && cp <= 55215;
|
|
53854
|
+
}
|
|
53855
|
+
var isAsciiWordChar = (ch) => !!ch && /[A-Za-z0-9]/.test(ch);
|
|
53856
|
+
function suspiciousZeroWidth(text) {
|
|
53857
|
+
let n = 0;
|
|
53858
|
+
for (let i = 0; i < text.length; i++) {
|
|
53859
|
+
const c = text.charCodeAt(i);
|
|
53860
|
+
if (c !== 8203 && c !== 8288) continue;
|
|
53861
|
+
if (isZwLegitScript(text.codePointAt(i - 1)) || isZwLegitScript(text.codePointAt(i + 1)))
|
|
53862
|
+
continue;
|
|
53863
|
+
const prev = text[i - 1];
|
|
53864
|
+
const next = text[i + 1];
|
|
53865
|
+
if (!prev || !next || /\s/.test(prev) || /\s/.test(next)) continue;
|
|
53866
|
+
if (isAsciiWordChar(prev) && isAsciiWordChar(next)) n++;
|
|
53867
|
+
}
|
|
53868
|
+
return n;
|
|
53869
|
+
}
|
|
53870
|
+
var stripZeroWidth = (t) => t.replace(/[]/g, "");
|
|
53871
|
+
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;
|
|
53872
|
+
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;
|
|
53873
|
+
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;
|
|
53874
|
+
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;
|
|
53875
|
+
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;
|
|
53876
|
+
var NEGATION_RE = /\b(never|do not|don'?t|avoid|must not|should not|no need to|refuse to)\b/i;
|
|
53877
|
+
function isNegated(text, idx) {
|
|
53878
|
+
return NEGATION_RE.test(text.slice(Math.max(0, idx - 40), idx));
|
|
53879
|
+
}
|
|
53880
|
+
function inHumanSection(text, idx) {
|
|
53881
|
+
const heading = text.slice(0, idx).split("\n").reverse().find((l) => /^#+\s/.test(l));
|
|
53882
|
+
return !!heading && HUMAN_SECTION_RE.test(heading);
|
|
53883
|
+
}
|
|
53884
|
+
function decodeSuspiciousBase64(text) {
|
|
53885
|
+
let out = "";
|
|
53886
|
+
for (const m of text.matchAll(/[A-Za-z0-9+/]{40,}={0,2}/g)) {
|
|
53887
|
+
try {
|
|
53888
|
+
const d = Buffer.from(m[0], "base64").toString("utf8");
|
|
53889
|
+
if (/[\x20-\x7E]{16,}/.test(d) && /[a-z]{4,}/i.test(d)) out += " " + d;
|
|
53890
|
+
} catch {
|
|
53891
|
+
}
|
|
53892
|
+
}
|
|
53893
|
+
return out;
|
|
53894
|
+
}
|
|
53895
|
+
function mk(severity, title, signals, fix, path70) {
|
|
53896
|
+
return { check: "CI-6", dimension: "instructions", severity, title, file: path70, signals, fix };
|
|
53897
|
+
}
|
|
53898
|
+
function analyzeInstructionFile(path70, content) {
|
|
53899
|
+
const findings = [];
|
|
53900
|
+
const decoded = decodeSuspiciousBase64(content);
|
|
53901
|
+
if (TAG_CHARS.test(content))
|
|
53902
|
+
findings.push(
|
|
53903
|
+
mk(
|
|
53904
|
+
"critical",
|
|
53905
|
+
"Unicode tag characters in an agent instruction file",
|
|
53906
|
+
[
|
|
53907
|
+
"contains Unicode tag characters (U+E0000\u2013E007F) \u2014 an invisible instruction-smuggling channel with no legitimate use in text"
|
|
53908
|
+
],
|
|
53909
|
+
"Remove the tag characters. Instruction files must be plain, reviewable text.",
|
|
53910
|
+
path70
|
|
53911
|
+
)
|
|
53912
|
+
);
|
|
53913
|
+
if (BIDI_OVERRIDE.test(content))
|
|
53914
|
+
findings.push(
|
|
53915
|
+
mk(
|
|
53916
|
+
"critical",
|
|
53917
|
+
"Bidirectional override characters in an agent instruction file",
|
|
53918
|
+
[
|
|
53919
|
+
"contains a bidi override (U+202D/U+202E) \u2014 a Trojan-Source technique that visually reorders text so a human reads something different from what the agent parses"
|
|
53920
|
+
],
|
|
53921
|
+
"Remove the bidi override characters.",
|
|
53922
|
+
path70
|
|
53923
|
+
)
|
|
53924
|
+
);
|
|
53925
|
+
else if (BIDI_EMBED_ISOLATE.test(content))
|
|
53926
|
+
findings.push(
|
|
53927
|
+
mk(
|
|
53928
|
+
"advisory",
|
|
53929
|
+
"Bidirectional formatting characters in an agent instruction file",
|
|
53930
|
+
[
|
|
53931
|
+
"contains bidi embed/isolate characters (U+202A\u2013202C / U+2066\u20132069) \u2014 legitimate in right-to-left text, but confirm they are not being used to hide or reorder instructions"
|
|
53932
|
+
],
|
|
53933
|
+
"Confirm the bidi marks are legitimate RTL formatting; remove otherwise.",
|
|
53934
|
+
path70
|
|
53935
|
+
)
|
|
53936
|
+
);
|
|
53937
|
+
const zw = suspiciousZeroWidth(content);
|
|
53938
|
+
if (zw > 0) {
|
|
53939
|
+
const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
|
|
53940
|
+
findings.push(
|
|
53941
|
+
mk(
|
|
53942
|
+
revealed ? "critical" : "medium",
|
|
53943
|
+
"Zero-width characters splitting text in an agent instruction file",
|
|
53944
|
+
[
|
|
53945
|
+
revealed ? "a zero-width character conceals a prompt-override directive that only appears once the hidden characters are stripped" : "a zero-width character splits a visible Latin word \u2014 a concealment technique (hides text from human review while the agent reads it as contiguous)"
|
|
53946
|
+
],
|
|
53947
|
+
"Remove the zero-width characters. Instruction files must be plain, reviewable text.",
|
|
53948
|
+
path70
|
|
53949
|
+
)
|
|
53950
|
+
);
|
|
53951
|
+
}
|
|
53952
|
+
const ov = OVERRIDE_RE.exec(content);
|
|
53953
|
+
const ovEnc = !ov ? OVERRIDE_RE.exec(decoded) : null;
|
|
53954
|
+
if (ov || ovEnc) {
|
|
53955
|
+
const m = ov || ovEnc;
|
|
53956
|
+
findings.push(
|
|
53957
|
+
mk(
|
|
53958
|
+
ovEnc ? "critical" : "high",
|
|
53959
|
+
"Prompt-override directive in an agent instruction file",
|
|
53960
|
+
[
|
|
53961
|
+
`contains a prompt-override / role-impersonation directive (\`${m[0].slice(0, 60).trim()}\`)${ovEnc ? " \u2014 concealed in a base64 blob" : ""}`
|
|
53962
|
+
],
|
|
53963
|
+
"Remove the override text. An instruction file should not tell the agent to ignore its own rules.",
|
|
53964
|
+
path70
|
|
53965
|
+
)
|
|
53966
|
+
);
|
|
53967
|
+
}
|
|
53968
|
+
const fo = FETCH_OBEY_RE.exec(content);
|
|
53969
|
+
if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
|
|
53970
|
+
findings.push(
|
|
53971
|
+
mk(
|
|
53972
|
+
"medium",
|
|
53973
|
+
"Instruction directs the agent to fetch and run remote code",
|
|
53974
|
+
[`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
|
|
53975
|
+
"Do not instruct the agent to pipe remote content into a shell; pin and vendor scripts instead.",
|
|
53976
|
+
path70
|
|
53977
|
+
)
|
|
53978
|
+
);
|
|
53979
|
+
}
|
|
53980
|
+
const sp = SECRET_PATH_RE.exec(content);
|
|
53981
|
+
if (sp && !isNegated(content, sp.index)) {
|
|
53982
|
+
findings.push(
|
|
53983
|
+
mk(
|
|
53984
|
+
"medium",
|
|
53985
|
+
"Instruction points the agent at credential material",
|
|
53986
|
+
[`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
|
|
53987
|
+
"Do not reference credential files or paths in agent instructions.",
|
|
53988
|
+
path70
|
|
53989
|
+
)
|
|
53990
|
+
);
|
|
53991
|
+
}
|
|
53992
|
+
const ex = EXFIL_RE.exec(content);
|
|
53993
|
+
if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
|
|
53994
|
+
findings.push(
|
|
53995
|
+
mk(
|
|
53996
|
+
"medium",
|
|
53997
|
+
"Instruction directs the agent to send data to an external endpoint",
|
|
53998
|
+
[`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
|
|
53999
|
+
"Remove external post/upload directives from agent instructions.",
|
|
54000
|
+
path70
|
|
54001
|
+
)
|
|
54002
|
+
);
|
|
54003
|
+
}
|
|
54004
|
+
return findings;
|
|
54005
|
+
}
|
|
54006
|
+
|
|
53459
54007
|
// src/ci-check/index.ts
|
|
53460
54008
|
function worstOf(findings) {
|
|
53461
54009
|
let worst = null;
|
|
@@ -53474,10 +54022,18 @@ function scanTree(tree) {
|
|
|
53474
54022
|
if (/\.github\/workflows\/.+\.ya?ml$/.test(file.path)) {
|
|
53475
54023
|
const f = analyzeWorkflow(file.path, file.content);
|
|
53476
54024
|
if (f) findings.push(f);
|
|
54025
|
+
const s = analyzeWorkflowSecrets(file.path, file.content);
|
|
54026
|
+
if (s) findings.push(s);
|
|
53477
54027
|
} else if (/\.claude\/settings(\.local)?\.json$/.test(file.path)) {
|
|
53478
54028
|
findings.push(...analyzeAgentConfig(file.path, file.content));
|
|
53479
54029
|
} else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
|
|
53480
54030
|
findings.push(...analyzeMcp(file.path, file.content));
|
|
54031
|
+
} else if (/(^|\/)\.codex\/config\.toml$/.test(file.path)) {
|
|
54032
|
+
findings.push(...analyzeCodexConfig(file.path, file.content));
|
|
54033
|
+
} else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
|
|
54034
|
+
file.path
|
|
54035
|
+
)) {
|
|
54036
|
+
findings.push(...analyzeInstructionFile(file.path, file.content));
|
|
53481
54037
|
}
|
|
53482
54038
|
} catch (err2) {
|
|
53483
54039
|
notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
|
|
@@ -53508,6 +54064,36 @@ var COLOR = {
|
|
|
53508
54064
|
medium: import_chalk29.default.yellow,
|
|
53509
54065
|
advisory: import_chalk29.default.gray
|
|
53510
54066
|
};
|
|
54067
|
+
var ACTION_URL = "https://github.com/marketplace/actions/node9-agent-security-check?ref=cli_scan_repo";
|
|
54068
|
+
function renderCta(res) {
|
|
54069
|
+
const L = [];
|
|
54070
|
+
L.push(import_chalk29.default.dim(" " + "\u2500".repeat(63)));
|
|
54071
|
+
if (res.worst === "critical" || res.worst === "high") {
|
|
54072
|
+
const n = res.findings.filter((f) => f.severity === "critical" || f.severity === "high").length;
|
|
54073
|
+
L.push(
|
|
54074
|
+
" " + import_chalk29.default.red.bold(
|
|
54075
|
+
`\u{1F534} ${n} ${n === 1 ? "issue" : "issues"} to fix \u2014 then stop the next at the PR.`
|
|
54076
|
+
)
|
|
54077
|
+
);
|
|
54078
|
+
L.push("");
|
|
54079
|
+
L.push(" " + import_chalk29.default.bold("Catch this class of issue on every PR, automatically:"));
|
|
54080
|
+
} else if (res.worst) {
|
|
54081
|
+
L.push(" " + import_chalk29.default.yellow("\u{1F7E1} Review the findings above, then keep it covered:"));
|
|
54082
|
+
L.push("");
|
|
54083
|
+
L.push(" " + import_chalk29.default.bold("Check every PR for agent-CI risk:"));
|
|
54084
|
+
} else if (res.incomplete) {
|
|
54085
|
+
L.push(" " + import_chalk29.default.yellow.bold("\u26A0\uFE0F Incomplete \u2014 not a clean bill of health."));
|
|
54086
|
+
L.push("");
|
|
54087
|
+
L.push(" " + import_chalk29.default.bold("Get a complete check on every PR (CI reads the tree directly):"));
|
|
54088
|
+
} else {
|
|
54089
|
+
L.push(" " + import_chalk29.default.green("\u2705 Agent CI is well-configured \u2014 0 unmitigated issues."));
|
|
54090
|
+
L.push("");
|
|
54091
|
+
L.push(" " + import_chalk29.default.bold("Keep it green as you add agent workflows \u2014 check every PR:"));
|
|
54092
|
+
}
|
|
54093
|
+
L.push(" " + import_chalk29.default.dim("\u2192 ") + import_chalk29.default.cyan.underline(ACTION_URL));
|
|
54094
|
+
L.push(" " + import_chalk29.default.gray(" zero setup \xB7 no token \xB7 runs in your CI"));
|
|
54095
|
+
return L;
|
|
54096
|
+
}
|
|
53511
54097
|
function ownedHint(source) {
|
|
53512
54098
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
53513
54099
|
}
|
|
@@ -53552,6 +54138,8 @@ function renderScan(res) {
|
|
|
53552
54138
|
)
|
|
53553
54139
|
);
|
|
53554
54140
|
}
|
|
54141
|
+
L.push("");
|
|
54142
|
+
L.push(...renderCta(res));
|
|
53555
54143
|
return L.join("\n");
|
|
53556
54144
|
}
|
|
53557
54145
|
function renderScanMarkdown(res) {
|