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