@node9/proxy 1.59.0 → 1.61.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/dist/cli.js +408 -83
- package/dist/cli.mjs +408 -83
- package/dist/dashboard.mjs +17 -6
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -49205,9 +49205,10 @@ function loadGeminiCost(start, end, geminiTmpDir2) {
|
|
|
49205
49205
|
function dimensionOfBlock(checkedBy, ruleName) {
|
|
49206
49206
|
if (checkedBy.includes("egress")) return "network";
|
|
49207
49207
|
if (checkedBy.includes("pii") || checkedBy.includes("dlp")) return "data";
|
|
49208
|
-
if (checkedBy === "
|
|
49208
|
+
if (checkedBy === "loop-detected" || checkedBy === "mcp-pin-mismatch" || checkedBy.startsWith("injection"))
|
|
49209
|
+
return "detection";
|
|
49210
|
+
if (checkedBy === "app-permission-block" || ruleName.startsWith("app-permission:")) return "apps";
|
|
49209
49211
|
if (ruleName.startsWith("shield:project-jail")) return "files";
|
|
49210
|
-
if (checkedBy === "loop-detected") return "toolRules";
|
|
49211
49212
|
if (checkedBy === "timeout" || checkedBy === "local-decision") return "approvals";
|
|
49212
49213
|
return "toolRules";
|
|
49213
49214
|
}
|
|
@@ -49310,7 +49311,9 @@ function aggregateReportFromAudit(period, opts = {}) {
|
|
|
49310
49311
|
let dimDataObserved = 0;
|
|
49311
49312
|
let dimFilesBlocked = 0;
|
|
49312
49313
|
let dimToolRulesBlocked = 0;
|
|
49313
|
-
let
|
|
49314
|
+
let dimAppsBlocked = 0;
|
|
49315
|
+
let dimPinMismatch = 0;
|
|
49316
|
+
let dimInjectionBlocked = 0;
|
|
49314
49317
|
for (const e of entries) {
|
|
49315
49318
|
if (superseded.has(supersedeKey(e))) continue;
|
|
49316
49319
|
const allow = isAllow(e.decision);
|
|
@@ -49328,6 +49331,8 @@ function aggregateReportFromAudit(period, opts = {}) {
|
|
|
49328
49331
|
}
|
|
49329
49332
|
if (e.checkedBy === "loop-detected") loopHits++;
|
|
49330
49333
|
const cb = e.checkedBy ?? "";
|
|
49334
|
+
if (cb === "mcp-pin-mismatch") dimPinMismatch++;
|
|
49335
|
+
if (cb.startsWith("injection")) dimInjectionBlocked++;
|
|
49331
49336
|
if (cb.includes("would-block") && (cb.includes("pii") || cb.includes("dlp"))) {
|
|
49332
49337
|
dimDataObserved++;
|
|
49333
49338
|
}
|
|
@@ -49339,8 +49344,8 @@ function aggregateReportFromAudit(period, opts = {}) {
|
|
|
49339
49344
|
case "files":
|
|
49340
49345
|
dimFilesBlocked++;
|
|
49341
49346
|
break;
|
|
49342
|
-
case "
|
|
49343
|
-
|
|
49347
|
+
case "apps":
|
|
49348
|
+
dimAppsBlocked++;
|
|
49344
49349
|
break;
|
|
49345
49350
|
case "toolRules":
|
|
49346
49351
|
dimToolRulesBlocked++;
|
|
@@ -49420,9 +49425,15 @@ function aggregateReportFromAudit(period, opts = {}) {
|
|
|
49420
49425
|
dimensions: {
|
|
49421
49426
|
network: { blocked: dimNetworkBlocked },
|
|
49422
49427
|
data: { blocked: dlpBlocked, observed: dimDataObserved },
|
|
49428
|
+
detection: {
|
|
49429
|
+
loops: loopHits,
|
|
49430
|
+
pinMismatches: dimPinMismatch,
|
|
49431
|
+
injections: dimInjectionBlocked
|
|
49432
|
+
},
|
|
49423
49433
|
approvals: { approved: userApproved, denied: userDenied, timedOut },
|
|
49424
49434
|
files: { blocked: dimFilesBlocked },
|
|
49425
|
-
toolRules: { blocked: dimToolRulesBlocked
|
|
49435
|
+
toolRules: { blocked: dimToolRulesBlocked },
|
|
49436
|
+
apps: { blocked: dimAppsBlocked },
|
|
49426
49437
|
cost: { totalUSD: claudeCost.total + codexCost.total + geminiCost.total }
|
|
49427
49438
|
},
|
|
49428
49439
|
generatedAt: now.toISOString()
|
|
@@ -49722,6 +49733,12 @@ function renderTerminalReport(data, responseDlpEntries, excludeTests) {
|
|
|
49722
49733
|
d.data.blocked + d.data.observed > 0,
|
|
49723
49734
|
`${num2(d.data.blocked)} blocked \xB7 ${num2(d.data.observed)} observed (DLP/PII)`
|
|
49724
49735
|
);
|
|
49736
|
+
dimRow(
|
|
49737
|
+
"\u{1F501}",
|
|
49738
|
+
"Detection",
|
|
49739
|
+
d.detection.loops + d.detection.pinMismatches + d.detection.injections > 0,
|
|
49740
|
+
`${num2(d.detection.loops)} loops \xB7 ${num2(d.detection.pinMismatches)} pin \xB7 ${num2(d.detection.injections)} injection`
|
|
49741
|
+
);
|
|
49725
49742
|
dimRow(
|
|
49726
49743
|
"\u270B",
|
|
49727
49744
|
"Approvals",
|
|
@@ -49732,9 +49749,10 @@ function renderTerminalReport(data, responseDlpEntries, excludeTests) {
|
|
|
49732
49749
|
dimRow(
|
|
49733
49750
|
"\u{1F6E0}",
|
|
49734
49751
|
"Tool rules",
|
|
49735
|
-
d.toolRules.blocked
|
|
49736
|
-
`${num2(d.toolRules.blocked)} shields/rules
|
|
49752
|
+
d.toolRules.blocked > 0,
|
|
49753
|
+
`${num2(d.toolRules.blocked)} shields/rules`
|
|
49737
49754
|
);
|
|
49755
|
+
dimRow("\u{1F9E9}", "Apps (MCP)", d.apps.blocked > 0, `${num2(d.apps.blocked)} app-permission blocks`);
|
|
49738
49756
|
dimRow("\u{1F4B0}", "Cost", d.cost.totalUSD > 0, `${fmtCost2(d.cost.totalUSD)} this period`);
|
|
49739
49757
|
}
|
|
49740
49758
|
console.log("");
|
|
@@ -52978,6 +52996,28 @@ var SURFACE_FILES = [
|
|
|
52978
52996
|
".clinerules"
|
|
52979
52997
|
];
|
|
52980
52998
|
var WORKFLOW_DIR = ".github/workflows";
|
|
52999
|
+
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$/;
|
|
53000
|
+
var IGNORE_HARD = /(^|\/)(node_modules|vendor|\.git|\.next|\.venv|site-packages)\//;
|
|
53001
|
+
var IGNORE_SOFT = /(^|\/)(dist|build|out|target)\//;
|
|
53002
|
+
var isIgnoredDir = (relSlash) => IGNORE_HARD.test(relSlash) || IGNORE_SOFT.test(relSlash);
|
|
53003
|
+
var MAX_SURFACE_FILES = 200;
|
|
53004
|
+
function pickSurfacePaths(paths, truncated, notes) {
|
|
53005
|
+
const surface = paths.filter((p) => SURFACE_BASENAME.test(p) && !IGNORE_HARD.test(p));
|
|
53006
|
+
const matched = surface.filter((p) => !IGNORE_SOFT.test(p));
|
|
53007
|
+
const softSkipped = surface.filter((p) => IGNORE_SOFT.test(p));
|
|
53008
|
+
const capped = matched.slice(0, MAX_SURFACE_FILES);
|
|
53009
|
+
if (truncated || matched.length > MAX_SURFACE_FILES) {
|
|
53010
|
+
notes.push(
|
|
53011
|
+
`repo tree is large/truncated \u2014 some agent-surface files may be INCOMPLETE (scanned ${capped.length} of ${matched.length}${truncated ? "+" : ""}).`
|
|
53012
|
+
);
|
|
53013
|
+
}
|
|
53014
|
+
if (softSkipped.length) {
|
|
53015
|
+
notes.push(
|
|
53016
|
+
`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.`
|
|
53017
|
+
);
|
|
53018
|
+
}
|
|
53019
|
+
return capped;
|
|
53020
|
+
}
|
|
52981
53021
|
async function pooled(items, limit, fn) {
|
|
52982
53022
|
const out = new Array(items.length);
|
|
52983
53023
|
let next = 0;
|
|
@@ -53069,13 +53109,37 @@ async function listWorkflowPaths(owner, repo, notes) {
|
|
|
53069
53109
|
if (status !== 200 || !Array.isArray(json)) return [];
|
|
53070
53110
|
return json.filter((e) => e.type === "file" && /\.ya?ml$/.test(e.name)).map((e) => e.path);
|
|
53071
53111
|
}
|
|
53112
|
+
var ROOT_WORKFLOW_RE = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
53113
|
+
async function listSurfaceTree(owner, repo, notes) {
|
|
53114
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/trees/HEAD?recursive=1`;
|
|
53115
|
+
const { status, json } = await ghGet(url);
|
|
53116
|
+
if (status === 403 || status === 429) {
|
|
53117
|
+
if (!notes.includes(RATE_LIMIT_NOTE)) notes.push(RATE_LIMIT_NOTE);
|
|
53118
|
+
return null;
|
|
53119
|
+
}
|
|
53120
|
+
if (status === 0) {
|
|
53121
|
+
if (!notes.includes(NETWORK_NOTE)) notes.push(NETWORK_NOTE);
|
|
53122
|
+
return null;
|
|
53123
|
+
}
|
|
53124
|
+
if (status !== 200 || !json || typeof json !== "object") return null;
|
|
53125
|
+
const tree = json;
|
|
53126
|
+
if (!Array.isArray(tree.tree)) return null;
|
|
53127
|
+
const blobs = tree.tree.filter((e) => e.type === "blob" && typeof e.path === "string").map((e) => e.path);
|
|
53128
|
+
return {
|
|
53129
|
+
surface: pickSurfacePaths(blobs, !!tree.truncated, notes),
|
|
53130
|
+
workflows: blobs.filter((p) => ROOT_WORKFLOW_RE.test(p))
|
|
53131
|
+
};
|
|
53132
|
+
}
|
|
53072
53133
|
var FETCH_CONCURRENCY = 8;
|
|
53073
53134
|
async function fetchGitHubTree(owner, repo, onProgress) {
|
|
53074
53135
|
const notes = [];
|
|
53075
53136
|
try {
|
|
53076
|
-
onProgress?.({ phase: "
|
|
53077
|
-
const
|
|
53078
|
-
const
|
|
53137
|
+
onProgress?.({ phase: "discovering agent surface", done: 0, total: 1 });
|
|
53138
|
+
const discovered = await listSurfaceTree(owner, repo, notes);
|
|
53139
|
+
const workflowPaths = discovered ? discovered.workflows : await listWorkflowPaths(owner, repo, notes);
|
|
53140
|
+
const allPaths = [
|
|
53141
|
+
.../* @__PURE__ */ new Set([...SURFACE_FILES, ...discovered?.surface ?? [], ...workflowPaths])
|
|
53142
|
+
];
|
|
53079
53143
|
let done = 0;
|
|
53080
53144
|
const fetched = await pooled(allPaths, FETCH_CONCURRENCY, async (p) => {
|
|
53081
53145
|
const f = await fetchOne(owner, repo, p, notes);
|
|
@@ -53084,7 +53148,9 @@ async function fetchGitHubTree(owner, repo, onProgress) {
|
|
|
53084
53148
|
});
|
|
53085
53149
|
return { source: `${owner}/${repo}`, files: fetched.filter((f) => !!f), notes };
|
|
53086
53150
|
} catch (err2) {
|
|
53087
|
-
notes.push(
|
|
53151
|
+
notes.push(
|
|
53152
|
+
`fetch degraded: ${err2?.message ?? "network error"} \u2014 results may be INCOMPLETE (the repo could not be fetched).`
|
|
53153
|
+
);
|
|
53088
53154
|
return { source: `${owner}/${repo}`, files: [], notes };
|
|
53089
53155
|
}
|
|
53090
53156
|
}
|
|
@@ -53101,7 +53167,42 @@ function readLocalTree(dir) {
|
|
|
53101
53167
|
} catch {
|
|
53102
53168
|
}
|
|
53103
53169
|
};
|
|
53104
|
-
|
|
53170
|
+
const seen = /* @__PURE__ */ new Set();
|
|
53171
|
+
const collect = (rel) => {
|
|
53172
|
+
if (seen.has(rel)) return;
|
|
53173
|
+
seen.add(rel);
|
|
53174
|
+
add(rel);
|
|
53175
|
+
};
|
|
53176
|
+
for (const p of SURFACE_FILES) collect(p);
|
|
53177
|
+
const matches = [];
|
|
53178
|
+
const MAX_DIRS = 5e3;
|
|
53179
|
+
let dirsVisited = 0;
|
|
53180
|
+
const walk = (relDir) => {
|
|
53181
|
+
if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
|
|
53182
|
+
dirsVisited++;
|
|
53183
|
+
let entries;
|
|
53184
|
+
try {
|
|
53185
|
+
entries = fs60.readdirSync(path57.join(root, relDir), { withFileTypes: true });
|
|
53186
|
+
} catch {
|
|
53187
|
+
return;
|
|
53188
|
+
}
|
|
53189
|
+
for (const e of entries) {
|
|
53190
|
+
if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS) return;
|
|
53191
|
+
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
53192
|
+
if (e.isDirectory()) {
|
|
53193
|
+
if (isIgnoredDir(`${rel}/`)) continue;
|
|
53194
|
+
walk(rel);
|
|
53195
|
+
} else if (e.isFile() && SURFACE_BASENAME.test(rel)) {
|
|
53196
|
+
matches.push(rel);
|
|
53197
|
+
}
|
|
53198
|
+
}
|
|
53199
|
+
};
|
|
53200
|
+
walk("");
|
|
53201
|
+
if (matches.length >= MAX_SURFACE_FILES || dirsVisited >= MAX_DIRS)
|
|
53202
|
+
notes.push(
|
|
53203
|
+
`repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files / ${MAX_DIRS} dirs).`
|
|
53204
|
+
);
|
|
53205
|
+
for (const rel of matches) collect(rel);
|
|
53105
53206
|
const wfDir = path57.join(root, WORKFLOW_DIR);
|
|
53106
53207
|
try {
|
|
53107
53208
|
if (fs60.existsSync(wfDir)) {
|
|
@@ -53142,6 +53243,18 @@ var AGENT_ACTION_RE = /(anthropics\/claude-code(-base)?-action|anthropics\/claud
|
|
|
53142
53243
|
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;
|
|
53143
53244
|
var EXFIL_RCE_RE = /(^|["\s,])Bash(\s|,|"|$)|Bash\(\s*\*|Bash\((curl|wget|sh[\s):]|eval|rm[\s):]|git push)/i;
|
|
53144
53245
|
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;
|
|
53246
|
+
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;
|
|
53247
|
+
var MEMBERSHIP_CHECK_RE = /orgs\/[^/'"\s]+\/memberships\/|teams\/[^/'"\s]+\/memberships\/|getCollaboratorPermissionLevel|collaborators\/[^/'"\s]+\/permission|checkMembershipForUser|getMembershipForUser/i;
|
|
53248
|
+
var STRANGER_ISSUE_TYPES = /* @__PURE__ */ new Set(["opened", "edited", "reopened", "closed"]);
|
|
53249
|
+
var STRANGER_PR_TYPES = /* @__PURE__ */ new Set([
|
|
53250
|
+
"opened",
|
|
53251
|
+
"edited",
|
|
53252
|
+
"reopened",
|
|
53253
|
+
"closed",
|
|
53254
|
+
"synchronize",
|
|
53255
|
+
"ready_for_review",
|
|
53256
|
+
"converted_to_draft"
|
|
53257
|
+
]);
|
|
53145
53258
|
function str(v) {
|
|
53146
53259
|
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v);
|
|
53147
53260
|
}
|
|
@@ -53152,9 +53265,57 @@ function triggerKeys(wf, raw) {
|
|
|
53152
53265
|
if (on && typeof on === "object") return Object.keys(on);
|
|
53153
53266
|
return [];
|
|
53154
53267
|
}
|
|
53268
|
+
function onObject(wf, raw) {
|
|
53269
|
+
return wf.on ?? raw["on"] ?? raw[true];
|
|
53270
|
+
}
|
|
53271
|
+
function activityTypes(node) {
|
|
53272
|
+
return node && Array.isArray(node.types) ? node.types.map(String) : [];
|
|
53273
|
+
}
|
|
53274
|
+
function keyStrangerFirable(on, key) {
|
|
53275
|
+
switch (key) {
|
|
53276
|
+
case "issue_comment":
|
|
53277
|
+
case "pull_request_review":
|
|
53278
|
+
case "pull_request_review_comment":
|
|
53279
|
+
case "workflow_run":
|
|
53280
|
+
case "discussion":
|
|
53281
|
+
case "discussion_comment":
|
|
53282
|
+
case "fork":
|
|
53283
|
+
case "watch":
|
|
53284
|
+
case "public":
|
|
53285
|
+
return true;
|
|
53286
|
+
case "pull_request":
|
|
53287
|
+
case "pull_request_target": {
|
|
53288
|
+
const ts = activityTypes(on[key]);
|
|
53289
|
+
return ts.length ? ts.some((t) => STRANGER_PR_TYPES.has(t)) : true;
|
|
53290
|
+
}
|
|
53291
|
+
case "issues": {
|
|
53292
|
+
const ts = activityTypes(on["issues"]);
|
|
53293
|
+
return ts.length ? ts.some((t) => STRANGER_ISSUE_TYPES.has(t)) : true;
|
|
53294
|
+
}
|
|
53295
|
+
// workflow_call / workflow_dispatch / schedule / push / create / delete / …
|
|
53296
|
+
// are NOT stranger-firable (require write access, or run in a trusted context).
|
|
53297
|
+
default:
|
|
53298
|
+
return false;
|
|
53299
|
+
}
|
|
53300
|
+
}
|
|
53301
|
+
function triggerReach(wf, raw) {
|
|
53302
|
+
const on = onObject(wf, raw) ?? {};
|
|
53303
|
+
const keys = triggerKeys(wf, raw);
|
|
53304
|
+
const firable = keys.filter((k) => keyStrangerFirable(on, k));
|
|
53305
|
+
const untrusted = firable.length > 0;
|
|
53306
|
+
const reusable = !untrusted && keys.some((k) => /^workflow_call$/i.test(k));
|
|
53307
|
+
const secretExposed = firable.some((t) => /pull_request_target|workflow_run/i.test(t));
|
|
53308
|
+
const privileged = untrusted && firable.some(
|
|
53309
|
+
(t) => /pull_request_target|pull_request_review|workflow_run|issue|discussion/i.test(t)
|
|
53310
|
+
);
|
|
53311
|
+
return { keys, untrusted, reusable, secretExposed, privileged };
|
|
53312
|
+
}
|
|
53313
|
+
function jobList(wf) {
|
|
53314
|
+
return Object.values(wf.jobs ?? {}).filter((j) => j != null);
|
|
53315
|
+
}
|
|
53155
53316
|
function allSteps(wf) {
|
|
53156
53317
|
const out = [];
|
|
53157
|
-
for (const job of
|
|
53318
|
+
for (const job of jobList(wf)) {
|
|
53158
53319
|
for (const step of job.steps ?? []) out.push({ job, step });
|
|
53159
53320
|
}
|
|
53160
53321
|
return out;
|
|
@@ -53209,63 +53370,100 @@ function promptTakesUntrusted(steps) {
|
|
|
53209
53370
|
}
|
|
53210
53371
|
return false;
|
|
53211
53372
|
}
|
|
53212
|
-
var
|
|
53373
|
+
var NONCONTAINS_GATE_RE = new RegExp(
|
|
53374
|
+
[
|
|
53375
|
+
String.raw`==\s*['"]?(OWNER|MEMBER|COLLABORATOR)\b`,
|
|
53376
|
+
String.raw`==\s*['"](write|admin|maintain)`,
|
|
53377
|
+
String.raw`github\.actor\s*==`,
|
|
53378
|
+
String.raw`user\.login\s*==`,
|
|
53379
|
+
String.raw`head\.repo\.full_name\s*==\s*github\.repository`
|
|
53380
|
+
].join("|"),
|
|
53381
|
+
"i"
|
|
53382
|
+
);
|
|
53383
|
+
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;
|
|
53384
|
+
var CONTAINS_GATE_RE = /contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
|
|
53385
|
+
var NEGATED_CONTAINS_RE = /!\s*\(?\s*contains\((?:[^()]|\([^()]*\))*(login|actor|association|OWNER|MEMBER|COLLABORATOR)/i;
|
|
53213
53386
|
function labelTypeConfigured(wf, raw) {
|
|
53214
53387
|
const on = wf.on ?? raw["on"] ?? raw[true];
|
|
53215
53388
|
const prTypes = (t) => t && Array.isArray(t.types) ? t.types.map(String) : [];
|
|
53216
53389
|
return prTypes(on?.["pull_request_target"]).includes("labeled") || prTypes(on?.["pull_request"]).includes("labeled");
|
|
53217
53390
|
}
|
|
53218
53391
|
function ifsAreGated(ifs, labelConfigured) {
|
|
53219
|
-
const
|
|
53392
|
+
const containsGate = CONTAINS_GATE_RE.test(ifs) && !NEGATED_CONTAINS_RE.test(ifs);
|
|
53393
|
+
const gated = NONCONTAINS_GATE_RE.test(ifs) || containsGate;
|
|
53220
53394
|
const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
|
|
53221
53395
|
return gated || labelGated;
|
|
53222
53396
|
}
|
|
53223
53397
|
function hasActorGate(wf, raw) {
|
|
53224
|
-
const ifs = [
|
|
53225
|
-
wf.jobs ? Object.values(wf.jobs).map((j) => j.if) : [],
|
|
53226
|
-
allSteps(wf).map((s) => s.step.if)
|
|
53227
|
-
].flat().map(str).join(" ");
|
|
53398
|
+
const ifs = [jobList(wf).map((j) => j.if), allSteps(wf).map((s) => s.step.if)].flat().map(str).join(" ");
|
|
53228
53399
|
return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
|
|
53229
53400
|
}
|
|
53401
|
+
function hasStepMembershipGate(job) {
|
|
53402
|
+
const steps = job.steps ?? [];
|
|
53403
|
+
const gateIds = steps.filter((s) => s.id && MEMBERSHIP_CHECK_RE.test(str(s.run))).map((s) => s.id);
|
|
53404
|
+
if (!gateIds.length) return false;
|
|
53405
|
+
return steps.some(
|
|
53406
|
+
(s) => isAgentStep(s) && gateIds.some(
|
|
53407
|
+
(id) => new RegExp(`steps\\.${id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.outputs\\.`).test(
|
|
53408
|
+
str(s.if)
|
|
53409
|
+
)
|
|
53410
|
+
)
|
|
53411
|
+
);
|
|
53412
|
+
}
|
|
53230
53413
|
function jobActorGate(job, wf, raw) {
|
|
53231
53414
|
const ifs = [job.if, ...(job.steps ?? []).map((s) => s.if)].map(str).join(" ");
|
|
53232
|
-
return ifsAreGated(ifs, labelTypeConfigured(wf, raw)) || /assignee\.login\s*==|event\.assignee\b/i.test(ifs)
|
|
53415
|
+
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
|
|
53416
|
+
hasStepMembershipGate(job);
|
|
53233
53417
|
}
|
|
53234
53418
|
function hasImplicitActorGate(agentSteps, bypassActive) {
|
|
53235
53419
|
if (bypassActive) return false;
|
|
53236
53420
|
return agentSteps.some((s) => /anthropics\/claude-code-action@/i.test(s.uses ?? ""));
|
|
53237
53421
|
}
|
|
53238
|
-
function
|
|
53422
|
+
function injectableJobs(wf, raw, untrustedTrigger) {
|
|
53239
53423
|
const out = [];
|
|
53240
|
-
for (const job of
|
|
53424
|
+
for (const job of jobList(wf)) {
|
|
53241
53425
|
const a = (job.steps ?? []).filter(isAgentStep);
|
|
53242
53426
|
if (!a.length) continue;
|
|
53243
53427
|
const jobStar = str(a.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean)) === "*";
|
|
53244
|
-
|
|
53245
|
-
|
|
53246
|
-
out.push(
|
|
53428
|
+
if (jobActorGate(job, wf, raw) || hasImplicitActorGate(a, jobStar && untrustedTrigger))
|
|
53429
|
+
continue;
|
|
53430
|
+
out.push(job);
|
|
53247
53431
|
}
|
|
53248
53432
|
return out;
|
|
53249
53433
|
}
|
|
53250
|
-
function
|
|
53251
|
-
const
|
|
53252
|
-
const
|
|
53253
|
-
|
|
53254
|
-
|
|
53255
|
-
|
|
53434
|
+
function usesPatIn(jobs) {
|
|
53435
|
+
for (const j of jobs)
|
|
53436
|
+
for (const step of j.steps ?? []) {
|
|
53437
|
+
const gt = str(step.with?.["github_token"]);
|
|
53438
|
+
if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
|
|
53439
|
+
}
|
|
53440
|
+
return false;
|
|
53441
|
+
}
|
|
53442
|
+
function jobPerms(wf, job) {
|
|
53443
|
+
return job.permissions != null ? str(job.permissions) : str(wf.permissions);
|
|
53444
|
+
}
|
|
53445
|
+
function jobsHaveWrite(wf, jobs, rx) {
|
|
53446
|
+
return jobs.some((j) => {
|
|
53447
|
+
const p = jobPerms(wf, j);
|
|
53448
|
+
return /\bwrite-all\b/i.test(p) || rx.test(p);
|
|
53449
|
+
});
|
|
53450
|
+
}
|
|
53451
|
+
function hasIdTokenWrite(wf, jobs) {
|
|
53452
|
+
const rx = /["']?id-token["']?\s*:\s*["']?write/i;
|
|
53453
|
+
return jobs.some((j) => rx.test(jobPerms(wf, j)));
|
|
53454
|
+
}
|
|
53455
|
+
function hasPrWritePerm(wf, jobs) {
|
|
53456
|
+
return jobsHaveWrite(wf, jobs, /["']?pull-requests["']?\s*:\s*["']?write/i);
|
|
53256
53457
|
}
|
|
53257
|
-
function
|
|
53258
|
-
|
|
53259
|
-
|
|
53458
|
+
function hasCodeWritePerm(wf, agentJobs) {
|
|
53459
|
+
return jobsHaveWrite(
|
|
53460
|
+
wf,
|
|
53461
|
+
agentJobs,
|
|
53462
|
+
/["']?(contents|packages|actions|deployments)["']?\s*:\s*["']?write/i
|
|
53260
53463
|
);
|
|
53261
|
-
return check(wf.permissions) || agentJobs.some((j) => check(j.permissions));
|
|
53262
53464
|
}
|
|
53263
|
-
function
|
|
53264
|
-
|
|
53265
|
-
const gt = str(step.with?.["github_token"]);
|
|
53266
|
-
if (/secrets\./i.test(gt) && !/secrets\.GITHUB_TOKEN/i.test(gt)) return true;
|
|
53267
|
-
}
|
|
53268
|
-
return false;
|
|
53465
|
+
function hasMetaWritePerm(wf, agentJobs) {
|
|
53466
|
+
return jobsHaveWrite(wf, agentJobs, /["']?(pull-requests|issues)["']?\s*:\s*["']?write/i);
|
|
53269
53467
|
}
|
|
53270
53468
|
function hasEnvDeny(steps) {
|
|
53271
53469
|
return steps.some((s) => /"?mode"?\s*:\s*"?deny/i.test(str(s.with?.["settings"])));
|
|
@@ -53298,33 +53496,38 @@ function analyzeWorkflow(path70, content) {
|
|
|
53298
53496
|
)
|
|
53299
53497
|
];
|
|
53300
53498
|
if (agentSteps.length === 0) return null;
|
|
53301
|
-
const
|
|
53302
|
-
|
|
53303
|
-
|
|
53304
|
-
|
|
53305
|
-
|
|
53306
|
-
|
|
53499
|
+
const {
|
|
53500
|
+
keys: triggers,
|
|
53501
|
+
untrusted: forkInput,
|
|
53502
|
+
secretExposed,
|
|
53503
|
+
reusable,
|
|
53504
|
+
privileged
|
|
53505
|
+
} = triggerReach(wf, raw);
|
|
53307
53506
|
const nonWrite = str(agentSteps.map((s) => s.with?.["allowed_non_write_users"]).find(Boolean));
|
|
53308
53507
|
const nonWriteStar = nonWrite === "*";
|
|
53309
53508
|
const nonWriteList = !!nonWrite && nonWrite !== "*";
|
|
53310
|
-
const untrustedTrigger =
|
|
53509
|
+
const untrustedTrigger = forkInput || reusable;
|
|
53311
53510
|
const bypassActive = nonWriteStar && untrustedTrigger;
|
|
53312
53511
|
const head = untrustedHeadCheckout(steps);
|
|
53313
53512
|
const promptUntrusted = promptTakesUntrusted(agentSteps);
|
|
53314
|
-
const
|
|
53513
|
+
const injJobs = injectableJobs(wf, raw, untrustedTrigger);
|
|
53514
|
+
const powerJobs = injJobs.length ? injJobs : agentJobs;
|
|
53515
|
+
const powerSteps = injJobs.flatMap((j) => (j.steps ?? []).filter(isAgentStep));
|
|
53516
|
+
const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
|
|
53517
|
+
const toolsBlob = collectTools(scopedSteps);
|
|
53518
|
+
const reach = untrustedTrigger && injJobs.length ? Math.max(
|
|
53315
53519
|
head === "root" ? 3 : head === "subdir" ? 1 : 0,
|
|
53316
53520
|
promptUntrusted ? 2 : 0,
|
|
53317
53521
|
bypassActive ? 2 : 0
|
|
53318
53522
|
) : 0;
|
|
53319
|
-
const powerSteps = injectableAgentSteps(wf, raw, untrustedTrigger);
|
|
53320
|
-
const scopedSteps = powerSteps.length ? powerSteps : agentSteps;
|
|
53321
|
-
const toolsBlob = collectTools(scopedSteps);
|
|
53322
53523
|
const broadTools = BROAD_TOOL_RE.test(toolsBlob);
|
|
53323
|
-
const
|
|
53324
|
-
const
|
|
53524
|
+
const egressTool = EGRESS_TOOL_RE.test(toolsBlob);
|
|
53525
|
+
const elevated = hasCodeWritePerm(wf, powerJobs) || hasIdTokenWrite(wf, powerJobs) && egressTool;
|
|
53526
|
+
const pat = usesPatIn(powerJobs);
|
|
53325
53527
|
const power = (broadTools ? 2 : 0) + (bypassActive ? 1 : 0) + (elevated ? 1 : 0) + (pat ? 1 : 0);
|
|
53326
53528
|
const explicitGate = hasActorGate(wf, raw);
|
|
53327
53529
|
const implicitGate = hasImplicitActorGate(agentSteps, bypassActive);
|
|
53530
|
+
const membershipGated = injJobs.length === 0 && jobList(wf).some((j) => (j.steps ?? []).some(isAgentStep) && hasStepMembershipGate(j));
|
|
53328
53531
|
const gate = explicitGate || implicitGate;
|
|
53329
53532
|
const envDeny = hasEnvDeny(agentSteps);
|
|
53330
53533
|
const pinned = agentActionsPinned(agentSteps);
|
|
@@ -53333,7 +53536,7 @@ function analyzeWorkflow(path70, content) {
|
|
|
53333
53536
|
if (envDeny) score -= 1;
|
|
53334
53537
|
if (pinned) score -= 1;
|
|
53335
53538
|
score = Math.max(0, score);
|
|
53336
|
-
if (score === 0 && !secretExposed) return null;
|
|
53539
|
+
if (score === 0 && !secretExposed && !reusable) return null;
|
|
53337
53540
|
let severity = severityFromScore(score);
|
|
53338
53541
|
if (gate && severity && severity !== "advisory") severity = "advisory";
|
|
53339
53542
|
if (reach === 0 && severity && severity !== "advisory") severity = "advisory";
|
|
@@ -53341,9 +53544,20 @@ function analyzeWorkflow(path70, content) {
|
|
|
53341
53544
|
severity = "medium";
|
|
53342
53545
|
const exfilOrRce = EXFIL_RCE_RE.test(toolsBlob);
|
|
53343
53546
|
const githubWriteTool = GH_WRITE_TOOL_RE.test(toolsBlob);
|
|
53344
|
-
const
|
|
53547
|
+
const rce = exfilOrRce;
|
|
53548
|
+
const codeWrite = pat || hasCodeWritePerm(wf, powerJobs) && githubWriteTool;
|
|
53549
|
+
const metaWrite = hasMetaWritePerm(wf, powerJobs) && githubWriteTool;
|
|
53550
|
+
const canDamage = rce || codeWrite || metaWrite;
|
|
53345
53551
|
if (!canDamage && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
|
|
53346
|
-
if (
|
|
53552
|
+
if (canDamage && !rce && !codeWrite && severity === "critical") severity = "high";
|
|
53553
|
+
const issueOnlyWrite = metaWrite && !hasPrWritePerm(wf, powerJobs) && !hasCodeWritePerm(wf, powerJobs);
|
|
53554
|
+
if (issueOnlyWrite && !rce && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
|
|
53555
|
+
severity = "medium";
|
|
53556
|
+
if (untrustedTrigger && !privileged && !reusable && severity && severity !== "advisory")
|
|
53557
|
+
severity = "advisory";
|
|
53558
|
+
const reusableLoadedGun = reusable && (head === "root" || promptUntrusted);
|
|
53559
|
+
if (reusable && !reusableLoadedGun && severity && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium)
|
|
53560
|
+
severity = "medium";
|
|
53347
53561
|
if (!severity) severity = "advisory";
|
|
53348
53562
|
const signals = [];
|
|
53349
53563
|
if (secretExposed)
|
|
@@ -53351,7 +53565,11 @@ function analyzeWorkflow(path70, content) {
|
|
|
53351
53565
|
`runs with base-repo secrets (${triggers.filter((t) => /target|workflow_run/i.test(t)).join(", ")})`
|
|
53352
53566
|
);
|
|
53353
53567
|
else if (forkInput) signals.push(`triggered by untrusted input (${triggers.join(", ")})`);
|
|
53354
|
-
if (
|
|
53568
|
+
else if (reusable)
|
|
53569
|
+
signals.push(
|
|
53570
|
+
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`
|
|
53571
|
+
);
|
|
53572
|
+
if (forkInput && !privileged && !reusable)
|
|
53355
53573
|
signals.push(
|
|
53356
53574
|
"triggered by `pull_request` \u2014 fork PRs run with a read-only token (lower risk than pull_request_target)"
|
|
53357
53575
|
);
|
|
@@ -53363,8 +53581,14 @@ function analyzeWorkflow(path70, content) {
|
|
|
53363
53581
|
if (elevated) signals.push("elevated permissions (contents/id-token: write)");
|
|
53364
53582
|
if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
|
|
53365
53583
|
if (!gate && reach > 0) signals.push("no effective actor gate");
|
|
53584
|
+
const noExplicitPerms = wf.permissions == null && jobList(wf).every((j) => j.permissions == null);
|
|
53585
|
+
if (noExplicitPerms && reach > 0 && (broadTools || githubWriteTool))
|
|
53586
|
+
signals.push(
|
|
53587
|
+
"no explicit `permissions:` \u2014 the token defaults to the repo/org setting, which may grant write; set it explicitly to read-only"
|
|
53588
|
+
);
|
|
53366
53589
|
const mitigations = [];
|
|
53367
|
-
if (explicitGate
|
|
53590
|
+
if (explicitGate || membershipGated)
|
|
53591
|
+
mitigations.push("actor-gated (maintainer/label/write-user required)");
|
|
53368
53592
|
else if (implicitGate)
|
|
53369
53593
|
mitigations.push("claude-code-action gates the agent to write-access users by default");
|
|
53370
53594
|
if (head === "subdir") mitigations.push("untrusted head isolated in a subdir, not root");
|
|
@@ -53417,12 +53641,12 @@ function agentReachableSecrets(wf, agentSteps, agentJobs) {
|
|
|
53417
53641
|
found.set(name, classifySecret(name));
|
|
53418
53642
|
}
|
|
53419
53643
|
}
|
|
53420
|
-
const idToken =
|
|
53644
|
+
const idToken = hasIdTokenWrite(wf, agentJobs);
|
|
53421
53645
|
const out = [...found].map(([name, kind]) => ({ name, kind }));
|
|
53422
53646
|
if (idToken) out.push({ name: "id-token (cloud OIDC)", kind: "cloud-oidc" });
|
|
53423
53647
|
return out;
|
|
53424
53648
|
}
|
|
53425
|
-
function evalAgentJob(job, wf, raw, untrustedTrigger) {
|
|
53649
|
+
function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
|
|
53426
53650
|
const jobSteps = job.steps ?? [];
|
|
53427
53651
|
const jobAgentSteps = jobSteps.filter(isAgentStep);
|
|
53428
53652
|
if (jobAgentSteps.length === 0) return null;
|
|
@@ -53449,6 +53673,8 @@ function evalAgentJob(job, wf, raw, untrustedTrigger) {
|
|
|
53449
53673
|
} else {
|
|
53450
53674
|
return null;
|
|
53451
53675
|
}
|
|
53676
|
+
const loadedGun = head === "root" || promptTakesUntrusted(jobAgentSteps);
|
|
53677
|
+
if (reusable && !loadedGun && SEVERITY_RANK2[severity] > SEVERITY_RANK2.medium) severity = "medium";
|
|
53452
53678
|
return { severity, secrets, injectable, canReadEnv };
|
|
53453
53679
|
}
|
|
53454
53680
|
function analyzeWorkflowSecrets(path70, content) {
|
|
@@ -53461,12 +53687,10 @@ function analyzeWorkflowSecrets(path70, content) {
|
|
|
53461
53687
|
const wf = raw;
|
|
53462
53688
|
const all = allSteps(wf);
|
|
53463
53689
|
if (!all.some((s) => isAgentStep(s.step))) return null;
|
|
53464
|
-
const
|
|
53465
|
-
const untrustedTrigger =
|
|
53466
|
-
(t) => /pull_request_target|workflow_run|issue|pull_request|workflow_call/i.test(t)
|
|
53467
|
-
);
|
|
53690
|
+
const { untrusted: forkInput, reusable } = triggerReach(wf, raw);
|
|
53691
|
+
const untrustedTrigger = forkInput || reusable;
|
|
53468
53692
|
const agentJobs = [...new Set(all.filter((s) => isAgentStep(s.step)).map((s) => s.job))];
|
|
53469
|
-
const worst = agentJobs.map((job) => evalAgentJob(job, wf, raw, untrustedTrigger)).filter((e) => e !== null).sort((a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity])[0];
|
|
53693
|
+
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];
|
|
53470
53694
|
if (!worst) return null;
|
|
53471
53695
|
return {
|
|
53472
53696
|
check: "CI-4",
|
|
@@ -53509,18 +53733,20 @@ function analyzeAgentConfig(path70, content) {
|
|
|
53509
53733
|
}
|
|
53510
53734
|
const findings = [];
|
|
53511
53735
|
for (const cmd of hookCommands(cfg.hooks)) {
|
|
53512
|
-
const
|
|
53513
|
-
|
|
53514
|
-
|
|
53736
|
+
const remoteExec = /\|\s*(sh|bash|zsh)\b/.test(cmd) || /\b(curl|wget|iwr|irm)\b/.test(cmd);
|
|
53737
|
+
const isNpx = /\bnpx\b/.test(cmd);
|
|
53738
|
+
if (!remoteExec && !isNpx) continue;
|
|
53739
|
+
const unpinned = /@latest\b/.test(cmd) || isNpx && !/@\d/.test(cmd);
|
|
53740
|
+
const high = remoteExec || unpinned;
|
|
53515
53741
|
findings.push({
|
|
53516
53742
|
check: "CI-1",
|
|
53517
53743
|
dimension: "toolRules",
|
|
53518
|
-
severity:
|
|
53519
|
-
title:
|
|
53744
|
+
severity: high ? "high" : "medium",
|
|
53745
|
+
title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
|
|
53520
53746
|
file: path70,
|
|
53521
53747
|
signals: [
|
|
53522
53748
|
`hook command: \`${cmd.slice(0, 120)}\``,
|
|
53523
|
-
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"
|
|
53749
|
+
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"
|
|
53524
53750
|
],
|
|
53525
53751
|
fix: "Vendor the command as a committed local script, or pin an exact version and treat updates as security-reviewed."
|
|
53526
53752
|
});
|
|
@@ -53531,17 +53757,18 @@ function analyzeAgentConfig(path70, content) {
|
|
|
53531
53757
|
(a) => /^Bash$|^Bash\(\s*\*|^Bash\(git:|^Write\(\s*\*|^Write$|^Edit$/.test(a)
|
|
53532
53758
|
);
|
|
53533
53759
|
if (broad.length > 0) {
|
|
53760
|
+
const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
|
|
53534
53761
|
findings.push({
|
|
53535
53762
|
check: "CI-1",
|
|
53536
53763
|
dimension: "toolRules",
|
|
53537
|
-
severity: "medium",
|
|
53538
|
-
title: "Committed agent config pre-authorizes broad tools",
|
|
53764
|
+
severity: hasBackstop ? "medium" : "high",
|
|
53765
|
+
title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
|
|
53539
53766
|
file: path70,
|
|
53540
53767
|
signals: [
|
|
53541
53768
|
`broad allow(s): ${broad.slice(0, 5).join(", ")}`,
|
|
53542
|
-
|
|
53769
|
+
hasBackstop ? "a `deny` list backstops the broad allow" : "no `deny` entry covers Bash/Write/Edit \u2014 every contributor is pre-authorized for catastrophic tools"
|
|
53543
53770
|
],
|
|
53544
|
-
fix: "Scope the allow-list to specific read-only subcommands (e.g. `Bash(gh pr view:*)`); avoid bare `Bash`/`git:`/`Write
|
|
53771
|
+
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."
|
|
53545
53772
|
});
|
|
53546
53773
|
}
|
|
53547
53774
|
return findings;
|
|
@@ -53556,8 +53783,11 @@ function analyzeMcp(path70, content) {
|
|
|
53556
53783
|
} catch {
|
|
53557
53784
|
return [];
|
|
53558
53785
|
}
|
|
53786
|
+
return analyzeMcpServers(cfg.mcpServers ?? {}, path70);
|
|
53787
|
+
}
|
|
53788
|
+
function analyzeMcpServers(servers, path70) {
|
|
53559
53789
|
const findings = [];
|
|
53560
|
-
for (const [name, srv] of Object.entries(
|
|
53790
|
+
for (const [name, srv] of Object.entries(servers ?? {})) {
|
|
53561
53791
|
if (!srv || srv.disabled) continue;
|
|
53562
53792
|
const argv = [srv.command, ...Array.isArray(srv.args) ? srv.args.map(String) : []].join(" ");
|
|
53563
53793
|
if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
|
|
@@ -53592,8 +53822,63 @@ function analyzeMcp(path70, content) {
|
|
|
53592
53822
|
return findings;
|
|
53593
53823
|
}
|
|
53594
53824
|
|
|
53825
|
+
// src/ci-check/codex.ts
|
|
53826
|
+
import { parse as parseToml5 } from "smol-toml";
|
|
53827
|
+
function analyzeCodexConfig(path70, content) {
|
|
53828
|
+
let cfg;
|
|
53829
|
+
try {
|
|
53830
|
+
cfg = parseToml5(content);
|
|
53831
|
+
} catch {
|
|
53832
|
+
return [];
|
|
53833
|
+
}
|
|
53834
|
+
const findings = [];
|
|
53835
|
+
findings.push(...analyzeMcpServers(cfg.mcp_servers ?? {}, path70));
|
|
53836
|
+
const sandbox = typeof cfg.sandbox_mode === "string" ? cfg.sandbox_mode : "";
|
|
53837
|
+
const approval = typeof cfg.approval_policy === "string" ? cfg.approval_policy : "";
|
|
53838
|
+
const fullAccess = /danger-full-access/i.test(sandbox);
|
|
53839
|
+
const noApproval = /^never$/i.test(approval);
|
|
53840
|
+
if (fullAccess || noApproval) {
|
|
53841
|
+
const signals = [
|
|
53842
|
+
fullAccess ? 'sandbox_mode = "danger-full-access" \u2014 the agent runs arbitrary commands with full disk + network access' : null,
|
|
53843
|
+
noApproval ? 'approval_policy = "never" \u2014 no human approval for agent actions' : null
|
|
53844
|
+
].filter((s) => s !== null);
|
|
53845
|
+
findings.push({
|
|
53846
|
+
check: "CI-1",
|
|
53847
|
+
dimension: "toolRules",
|
|
53848
|
+
severity: fullAccess ? "high" : "medium",
|
|
53849
|
+
title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
|
|
53850
|
+
file: path70,
|
|
53851
|
+
signals,
|
|
53852
|
+
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.'
|
|
53853
|
+
});
|
|
53854
|
+
}
|
|
53855
|
+
return findings;
|
|
53856
|
+
}
|
|
53857
|
+
|
|
53595
53858
|
// src/ci-check/instructions.ts
|
|
53596
|
-
var
|
|
53859
|
+
var TAG_CHARS = /[\u{E0000}-\u{E007F}]/u;
|
|
53860
|
+
var BIDI_OVERRIDE = /[]/;
|
|
53861
|
+
var BIDI_EMBED_ISOLATE = /[--]/;
|
|
53862
|
+
function isZwLegitScript(cp) {
|
|
53863
|
+
if (cp === void 0) return false;
|
|
53864
|
+
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;
|
|
53865
|
+
}
|
|
53866
|
+
var isAsciiWordChar = (ch) => !!ch && /[A-Za-z0-9]/.test(ch);
|
|
53867
|
+
function suspiciousZeroWidth(text) {
|
|
53868
|
+
let n = 0;
|
|
53869
|
+
for (let i = 0; i < text.length; i++) {
|
|
53870
|
+
const c = text.charCodeAt(i);
|
|
53871
|
+
if (c !== 8203 && c !== 8288) continue;
|
|
53872
|
+
if (isZwLegitScript(text.codePointAt(i - 1)) || isZwLegitScript(text.codePointAt(i + 1)))
|
|
53873
|
+
continue;
|
|
53874
|
+
const prev = text[i - 1];
|
|
53875
|
+
const next = text[i + 1];
|
|
53876
|
+
if (!prev || !next || /\s/.test(prev) || /\s/.test(next)) continue;
|
|
53877
|
+
if (isAsciiWordChar(prev) && isAsciiWordChar(next)) n++;
|
|
53878
|
+
}
|
|
53879
|
+
return n;
|
|
53880
|
+
}
|
|
53881
|
+
var stripZeroWidth = (t) => t.replace(/[]/g, "");
|
|
53597
53882
|
var OVERRIDE_RE = /ignore\s+(all\s+)?(previous|prior|the\s+above)\s+(instructions|prompts?|rules)|disregard\s+(the\s+|your\s+)?(system\s+)?(prompt|instructions|rules)|forget\s+(everything|all\s+(previous|prior))|you\s+are\s+now\s+(a|an|the)\b|<\/?system>/i;
|
|
53598
53883
|
var FETCH_OBEY_RE = /\b(curl|wget|iwr|invoke-webrequest)\b[^\n|]*\|\s*(bash|sh|zsh|python3?|node|iex)\b|\b(curl|wget)\b[^\n]*&&[^\n]*\b(bash|sh)\b/i;
|
|
53599
53884
|
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;
|
|
@@ -53624,15 +53909,53 @@ function mk(severity, title, signals, fix, path70) {
|
|
|
53624
53909
|
function analyzeInstructionFile(path70, content) {
|
|
53625
53910
|
const findings = [];
|
|
53626
53911
|
const decoded = decodeSuspiciousBase64(content);
|
|
53627
|
-
if (
|
|
53912
|
+
if (TAG_CHARS.test(content))
|
|
53913
|
+
findings.push(
|
|
53914
|
+
mk(
|
|
53915
|
+
"critical",
|
|
53916
|
+
"Unicode tag characters in an agent instruction file",
|
|
53917
|
+
[
|
|
53918
|
+
"contains Unicode tag characters (U+E0000\u2013E007F) \u2014 an invisible instruction-smuggling channel with no legitimate use in text"
|
|
53919
|
+
],
|
|
53920
|
+
"Remove the tag characters. Instruction files must be plain, reviewable text.",
|
|
53921
|
+
path70
|
|
53922
|
+
)
|
|
53923
|
+
);
|
|
53924
|
+
if (BIDI_OVERRIDE.test(content))
|
|
53628
53925
|
findings.push(
|
|
53629
53926
|
mk(
|
|
53630
53927
|
"critical",
|
|
53631
|
-
"
|
|
53928
|
+
"Bidirectional override characters in an agent instruction file",
|
|
53929
|
+
[
|
|
53930
|
+
"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"
|
|
53931
|
+
],
|
|
53932
|
+
"Remove the bidi override characters.",
|
|
53933
|
+
path70
|
|
53934
|
+
)
|
|
53935
|
+
);
|
|
53936
|
+
else if (BIDI_EMBED_ISOLATE.test(content))
|
|
53937
|
+
findings.push(
|
|
53938
|
+
mk(
|
|
53939
|
+
"advisory",
|
|
53940
|
+
"Bidirectional formatting characters in an agent instruction file",
|
|
53941
|
+
[
|
|
53942
|
+
"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"
|
|
53943
|
+
],
|
|
53944
|
+
"Confirm the bidi marks are legitimate RTL formatting; remove otherwise.",
|
|
53945
|
+
path70
|
|
53946
|
+
)
|
|
53947
|
+
);
|
|
53948
|
+
const zw = suspiciousZeroWidth(content);
|
|
53949
|
+
if (zw > 0) {
|
|
53950
|
+
const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
|
|
53951
|
+
findings.push(
|
|
53952
|
+
mk(
|
|
53953
|
+
revealed ? "critical" : "medium",
|
|
53954
|
+
"Zero-width characters splitting text in an agent instruction file",
|
|
53632
53955
|
[
|
|
53633
|
-
"
|
|
53956
|
+
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)"
|
|
53634
53957
|
],
|
|
53635
|
-
"Remove the
|
|
53958
|
+
"Remove the zero-width characters. Instruction files must be plain, reviewable text.",
|
|
53636
53959
|
path70
|
|
53637
53960
|
)
|
|
53638
53961
|
);
|
|
@@ -53716,6 +54039,8 @@ function scanTree(tree) {
|
|
|
53716
54039
|
findings.push(...analyzeAgentConfig(file.path, file.content));
|
|
53717
54040
|
} else if (/\.mcp\.json$|\.cursor\/mcp\.json$/.test(file.path)) {
|
|
53718
54041
|
findings.push(...analyzeMcp(file.path, file.content));
|
|
54042
|
+
} else if (/(^|\/)\.codex\/config\.toml$/.test(file.path)) {
|
|
54043
|
+
findings.push(...analyzeCodexConfig(file.path, file.content));
|
|
53719
54044
|
} else if (/(^|\/)(CLAUDE|AGENTS|GEMINI)\.md$|(^|\/)\.cursorrules$|copilot-instructions\.md$|(^|\/)\.(windsurf|cline)rules$/.test(
|
|
53720
54045
|
file.path
|
|
53721
54046
|
)) {
|