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