@node9/proxy 2.23.3 → 2.24.1
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 +117 -12
- package/dist/cli.mjs +117 -12
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -60454,10 +60454,6 @@ function ifsAreGated(ifs, labelConfigured) {
|
|
|
60454
60454
|
const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
|
|
60455
60455
|
return gated || labelGated;
|
|
60456
60456
|
}
|
|
60457
|
-
function hasActorGate(wf, raw) {
|
|
60458
|
-
const ifs = [jobList(wf).map((j) => j.if), allSteps(wf).map((s) => s.step.if)].flat().map(str).join(" ");
|
|
60459
|
-
return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
|
|
60460
|
-
}
|
|
60461
60457
|
function hasStepMembershipGate(job) {
|
|
60462
60458
|
const steps = job.steps ?? [];
|
|
60463
60459
|
const gateIds = steps.filter((s) => s.id && MEMBERSHIP_CHECK_RE.test(str(s.run))).map((s) => s.id);
|
|
@@ -60475,6 +60471,54 @@ function jobActorGate(job, wf, raw) {
|
|
|
60475
60471
|
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
|
|
60476
60472
|
hasStepMembershipGate(job);
|
|
60477
60473
|
}
|
|
60474
|
+
var STATUS_FN_RE = /\b(success|failure|always|cancelled)\s*\(/i;
|
|
60475
|
+
var FAILING_STEP_RE = /\bexit\s+[1-9]|core\.setFailed|process\.exit\(\s*[1-9]/;
|
|
60476
|
+
function needsOf(job) {
|
|
60477
|
+
return (Array.isArray(job.needs) ? job.needs : job.needs ? [job.needs] : []).map(String);
|
|
60478
|
+
}
|
|
60479
|
+
function escapeRe(x) {
|
|
60480
|
+
return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
60481
|
+
}
|
|
60482
|
+
function upstreamJobGates(name, dep, wf, raw, readerIf) {
|
|
60483
|
+
const depIf = str(dep.if);
|
|
60484
|
+
const label2 = labelTypeConfigured(wf, raw);
|
|
60485
|
+
if (!/\|\|/.test(depIf) && (ifsAreGated(depIf, label2) || /assignee\.login\s*==|event\.assignee\b/i.test(depIf)))
|
|
60486
|
+
return true;
|
|
60487
|
+
const steps = dep.steps ?? [];
|
|
60488
|
+
const stepGated = ifsAreGated(steps.map((st) => str(st.if)).join(" "), label2) || hasStepMembershipGate(dep);
|
|
60489
|
+
if (!stepGated) return false;
|
|
60490
|
+
if (steps.some(
|
|
60491
|
+
(st) => FAILING_STEP_RE.test(str(st.run)) || FAILING_STEP_RE.test(str(st.with?.["script"]))
|
|
60492
|
+
))
|
|
60493
|
+
return true;
|
|
60494
|
+
return readerIf !== null && new RegExp(`needs\\.${escapeRe(name)}\\.outputs\\.`).test(readerIf);
|
|
60495
|
+
}
|
|
60496
|
+
function gatingAncestors(job, wf, raw) {
|
|
60497
|
+
const out = /* @__PURE__ */ new Set();
|
|
60498
|
+
const seen = /* @__PURE__ */ new Set();
|
|
60499
|
+
const visit = (j, readerIf) => {
|
|
60500
|
+
for (const name of needsOf(j)) {
|
|
60501
|
+
const dep = wf.jobs?.[name];
|
|
60502
|
+
if (!dep) continue;
|
|
60503
|
+
if (upstreamJobGates(name, dep, wf, raw, readerIf)) out.add(name);
|
|
60504
|
+
if (seen.has(name)) continue;
|
|
60505
|
+
seen.add(name);
|
|
60506
|
+
visit(dep, str(dep.if));
|
|
60507
|
+
}
|
|
60508
|
+
};
|
|
60509
|
+
visit(job, str(job.if));
|
|
60510
|
+
return out;
|
|
60511
|
+
}
|
|
60512
|
+
function jobGated(job, wf, raw) {
|
|
60513
|
+
if (jobActorGate(job, wf, raw)) return true;
|
|
60514
|
+
const gating = gatingAncestors(job, wf, raw);
|
|
60515
|
+
if (gating.size === 0) return false;
|
|
60516
|
+
const ownIf = str(job.if);
|
|
60517
|
+
if (!STATUS_FN_RE.test(ownIf)) return true;
|
|
60518
|
+
return needsOf(job).some(
|
|
60519
|
+
(n) => gating.has(n) && new RegExp(`needs\\.${escapeRe(n)}\\.result\\s*==\\s*['"]success['"]`).test(ownIf)
|
|
60520
|
+
);
|
|
60521
|
+
}
|
|
60478
60522
|
function bypassArmed(steps) {
|
|
60479
60523
|
return steps.some(
|
|
60480
60524
|
(s) => str(s.with?.["allowed_non_write_users"]) === "*" && (!!str(s.with?.["github_token"]) || !!str(s.env?.["OVERRIDE_GITHUB_TOKEN"]))
|
|
@@ -60490,8 +60534,7 @@ function injectableJobs(wf, raw, untrustedTrigger) {
|
|
|
60490
60534
|
const a = (job.steps ?? []).filter(isAgentStep);
|
|
60491
60535
|
if (!a.length) continue;
|
|
60492
60536
|
const jobStar = bypassArmed(a);
|
|
60493
|
-
if (
|
|
60494
|
-
continue;
|
|
60537
|
+
if (jobGated(job, wf, raw) || hasImplicitActorGate(a, jobStar && untrustedTrigger)) continue;
|
|
60495
60538
|
out.push(job);
|
|
60496
60539
|
}
|
|
60497
60540
|
return out;
|
|
@@ -60537,6 +60580,36 @@ function hasMetaWritePerm(wf, agentJobs) {
|
|
|
60537
60580
|
function hasEnvDeny(steps) {
|
|
60538
60581
|
return steps.some((s) => /"?mode"?\s*:\s*"?deny/i.test(str(s.with?.["settings"])));
|
|
60539
60582
|
}
|
|
60583
|
+
var CLAUDE_CODE_ACTION_RE = /anthropics\/claude-code-action@/i;
|
|
60584
|
+
var FULL_OUTPUT_ACTION_RE = /anthropics\/claude-code(-base)?-action@/i;
|
|
60585
|
+
function pinnedBelow(uses, min) {
|
|
60586
|
+
const m = /@v?(\d+)\.(\d+)\.(\d+)$/.exec(uses.trim());
|
|
60587
|
+
if (!m) return false;
|
|
60588
|
+
const v = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
60589
|
+
for (let i = 0; i < 3; i++) if (v[i] !== min[i]) return v[i] < min[i];
|
|
60590
|
+
return false;
|
|
60591
|
+
}
|
|
60592
|
+
var FULL_OUTPUT_MIN = [1, 0, 16];
|
|
60593
|
+
var ENV_SCRUB_MIN = [1, 0, 77];
|
|
60594
|
+
function showsFullOutput(steps) {
|
|
60595
|
+
return steps.some(
|
|
60596
|
+
(s) => FULL_OUTPUT_ACTION_RE.test(s.uses ?? "") && !pinnedBelow(s.uses ?? "", FULL_OUTPUT_MIN) && str(s.with?.["show_full_output"]) === "true"
|
|
60597
|
+
);
|
|
60598
|
+
}
|
|
60599
|
+
function envScrubOptedOut(pairs, wf) {
|
|
60600
|
+
const wfEnv = wf.env;
|
|
60601
|
+
const KEY = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB";
|
|
60602
|
+
return pairs.some(({ job, step }) => {
|
|
60603
|
+
const uses = step.uses ?? "";
|
|
60604
|
+
if (!CLAUDE_CODE_ACTION_RE.test(uses) || pinnedBelow(uses, ENV_SCRUB_MIN)) return false;
|
|
60605
|
+
if (!str(step.with?.["allowed_non_write_users"]).trim()) return false;
|
|
60606
|
+
const holder = [step.env, job.env, wfEnv].find((e) => e != null && KEY in e);
|
|
60607
|
+
if (!holder) return false;
|
|
60608
|
+
const v = str(holder[KEY]).trim();
|
|
60609
|
+
if (!v || /\$\{\{/.test(v)) return false;
|
|
60610
|
+
return !/^(1|true|yes|on)$/i.test(v);
|
|
60611
|
+
});
|
|
60612
|
+
}
|
|
60540
60613
|
function agentActionsPinned(steps) {
|
|
60541
60614
|
const agent = steps.filter(isAgentStep).filter((s) => s.uses);
|
|
60542
60615
|
if (agent.length === 0) return false;
|
|
@@ -60595,10 +60668,10 @@ function analyzeWorkflow(path80, content) {
|
|
|
60595
60668
|
const elevated = hasCodeWritePerm(wf, powerJobs) || hasIdTokenWrite(wf, powerJobs) && egressTool;
|
|
60596
60669
|
const pat = usesPatIn(powerJobs);
|
|
60597
60670
|
const power = (broadTools ? 2 : 0) + (bypassActive ? 1 : 0) + (elevated ? 1 : 0) + (pat ? 1 : 0);
|
|
60598
|
-
const explicitGate = hasActorGate(wf, raw);
|
|
60599
60671
|
const implicitGate = hasImplicitActorGate(agentSteps, bypassActive);
|
|
60600
60672
|
const membershipGated = injJobs.length === 0 && jobList(wf).some((j) => (j.steps ?? []).some(isAgentStep) && hasStepMembershipGate(j));
|
|
60601
|
-
const gate =
|
|
60673
|
+
const gate = injJobs.length === 0;
|
|
60674
|
+
const gatedSiblings = agentJobs.filter((j) => !injJobs.includes(j)).length;
|
|
60602
60675
|
const envDeny = hasEnvDeny(agentSteps);
|
|
60603
60676
|
const pinned = agentActionsPinned(agentSteps);
|
|
60604
60677
|
let score = reach + power;
|
|
@@ -60656,16 +60729,42 @@ function analyzeWorkflow(path80, content) {
|
|
|
60656
60729
|
signals.push('allowed_non_write_users: "*" with github_token \u2014 any user can trigger the agent');
|
|
60657
60730
|
if (elevated) signals.push("elevated permissions (contents/id-token: write)");
|
|
60658
60731
|
if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
|
|
60732
|
+
const scopedPairs = allSteps(wf).filter(
|
|
60733
|
+
(p) => isAgentStep(p.step) && (injJobs.length === 0 || injJobs.includes(p.job))
|
|
60734
|
+
);
|
|
60735
|
+
const fullOutput = showsFullOutput(scopedPairs.map((p) => p.step));
|
|
60736
|
+
const scrubOff = envScrubOptedOut(scopedPairs, wf);
|
|
60737
|
+
if (fullOutput)
|
|
60738
|
+
signals.push(
|
|
60739
|
+
"`show_full_output: true` writes every agent command and its output to the Actions log, public on a public repo; GitHub masks known secret values there, but an agent told to encode one first can publish it without any network tool"
|
|
60740
|
+
);
|
|
60741
|
+
if (scrubOff)
|
|
60742
|
+
signals.push(
|
|
60743
|
+
"`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` is switched off, so the action no longer scrubs the Anthropic key, cloud credentials and Actions runtime tokens from the agent's shell"
|
|
60744
|
+
);
|
|
60659
60745
|
if (!gate && reach > 0) signals.push("no effective actor gate");
|
|
60746
|
+
if (!gate && reach > 0 && gatedSiblings > 0) {
|
|
60747
|
+
const siblings = agentJobs.filter((j) => !injJobs.includes(j));
|
|
60748
|
+
const explicit = siblings.filter((j) => jobGated(j, wf, raw)).length;
|
|
60749
|
+
const byDefault = siblings.length - explicit;
|
|
60750
|
+
const n = (k) => `${k} other agent job${k === 1 ? "" : "s"}`;
|
|
60751
|
+
const parts = [
|
|
60752
|
+
explicit ? `${n(explicit)} ${explicit === 1 ? "is" : "are"} actor-gated` : "",
|
|
60753
|
+
byDefault ? `${n(byDefault)} ${byDefault === 1 ? "is" : "are"} held by claude-code-action's default write-access gate` : ""
|
|
60754
|
+
].filter(Boolean);
|
|
60755
|
+
signals.push(
|
|
60756
|
+
`${parts.join("; ")} in this workflow; this finding is about the ungated one${injJobs.length === 1 ? "" : "s"}`
|
|
60757
|
+
);
|
|
60758
|
+
}
|
|
60660
60759
|
const noExplicitPerms = wf.permissions == null && jobList(wf).every((j) => j.permissions == null);
|
|
60661
60760
|
if (noExplicitPerms && reach > 0 && (broadTools || githubWriteTool))
|
|
60662
60761
|
signals.push(
|
|
60663
60762
|
"no explicit `permissions:` \u2014 the token defaults to the repo/org setting, which may grant write; set it explicitly to read-only"
|
|
60664
60763
|
);
|
|
60665
60764
|
const mitigations = [];
|
|
60666
|
-
if (
|
|
60765
|
+
if (gate && (membershipGated || agentJobs.some((j) => jobGated(j, wf, raw))))
|
|
60667
60766
|
mitigations.push("actor-gated (maintainer/label/write-user required)");
|
|
60668
|
-
else if (implicitGate)
|
|
60767
|
+
else if (gate && implicitGate)
|
|
60669
60768
|
mitigations.push("claude-code-action gates the agent to write-access users by default");
|
|
60670
60769
|
if (head === "subdir") mitigations.push("untrusted head isolated in a subdir, not root");
|
|
60671
60770
|
if (envDeny) mitigations.push("secrets env-denied from the agent subprocess");
|
|
@@ -60676,7 +60775,7 @@ function analyzeWorkflow(path80, content) {
|
|
|
60676
60775
|
'allowed_non_write_users: "*" is set without github_token, so the action keeps its default write gate'
|
|
60677
60776
|
);
|
|
60678
60777
|
const title = severity === "critical" || severity === "high" ? "Injectable agent workflow \u2014 untrusted input reaches a tool-using agent with secrets" : severity === "medium" ? "Agent workflow with a risky pattern (partially mitigated)" : "Agent workflow on a privileged trigger \u2014 review the actor gate";
|
|
60679
|
-
|
|
60778
|
+
const finding = {
|
|
60680
60779
|
check: "CI-2",
|
|
60681
60780
|
// One verdict per workflow file — the finding IS the file's reachability score.
|
|
60682
60781
|
rule: "CI-2.injectable-workflow",
|
|
@@ -60688,6 +60787,12 @@ function analyzeWorkflow(path80, content) {
|
|
|
60688
60787
|
mitigations: mitigations.length ? mitigations : void 0,
|
|
60689
60788
|
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."
|
|
60690
60789
|
};
|
|
60790
|
+
const extraFix = [
|
|
60791
|
+
fullOutput ? "Remove `show_full_output: true` (the action documents it for debugging only)." : "",
|
|
60792
|
+
scrubOff ? "Remove the `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` opt-out." : ""
|
|
60793
|
+
].filter(Boolean).join(" ");
|
|
60794
|
+
if (extraFix) finding.fix = `${finding.fix} ${extraFix}`;
|
|
60795
|
+
return finding;
|
|
60691
60796
|
}
|
|
60692
60797
|
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;
|
|
60693
60798
|
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;
|
|
@@ -60742,7 +60847,7 @@ function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
|
|
|
60742
60847
|
promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
|
|
60743
60848
|
bypassActive ? 2 : 0
|
|
60744
60849
|
);
|
|
60745
|
-
const gate =
|
|
60850
|
+
const gate = jobGated(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
|
|
60746
60851
|
const injectable = untrustedTrigger && !gate && reach > 0;
|
|
60747
60852
|
const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
|
|
60748
60853
|
const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
|
package/dist/cli.mjs
CHANGED
|
@@ -60443,10 +60443,6 @@ function ifsAreGated(ifs, labelConfigured) {
|
|
|
60443
60443
|
const labelGated = labelConfigured && /event\.label|label\.name/i.test(ifs);
|
|
60444
60444
|
return gated || labelGated;
|
|
60445
60445
|
}
|
|
60446
|
-
function hasActorGate(wf, raw) {
|
|
60447
|
-
const ifs = [jobList(wf).map((j) => j.if), allSteps(wf).map((s) => s.step.if)].flat().map(str).join(" ");
|
|
60448
|
-
return ifsAreGated(ifs, labelTypeConfigured(wf, raw));
|
|
60449
|
-
}
|
|
60450
60446
|
function hasStepMembershipGate(job) {
|
|
60451
60447
|
const steps = job.steps ?? [];
|
|
60452
60448
|
const gateIds = steps.filter((s) => s.id && MEMBERSHIP_CHECK_RE.test(str(s.run))).map((s) => s.id);
|
|
@@ -60464,6 +60460,54 @@ function jobActorGate(job, wf, raw) {
|
|
|
60464
60460
|
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
|
|
60465
60461
|
hasStepMembershipGate(job);
|
|
60466
60462
|
}
|
|
60463
|
+
var STATUS_FN_RE = /\b(success|failure|always|cancelled)\s*\(/i;
|
|
60464
|
+
var FAILING_STEP_RE = /\bexit\s+[1-9]|core\.setFailed|process\.exit\(\s*[1-9]/;
|
|
60465
|
+
function needsOf(job) {
|
|
60466
|
+
return (Array.isArray(job.needs) ? job.needs : job.needs ? [job.needs] : []).map(String);
|
|
60467
|
+
}
|
|
60468
|
+
function escapeRe(x) {
|
|
60469
|
+
return x.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
60470
|
+
}
|
|
60471
|
+
function upstreamJobGates(name, dep, wf, raw, readerIf) {
|
|
60472
|
+
const depIf = str(dep.if);
|
|
60473
|
+
const label2 = labelTypeConfigured(wf, raw);
|
|
60474
|
+
if (!/\|\|/.test(depIf) && (ifsAreGated(depIf, label2) || /assignee\.login\s*==|event\.assignee\b/i.test(depIf)))
|
|
60475
|
+
return true;
|
|
60476
|
+
const steps = dep.steps ?? [];
|
|
60477
|
+
const stepGated = ifsAreGated(steps.map((st) => str(st.if)).join(" "), label2) || hasStepMembershipGate(dep);
|
|
60478
|
+
if (!stepGated) return false;
|
|
60479
|
+
if (steps.some(
|
|
60480
|
+
(st) => FAILING_STEP_RE.test(str(st.run)) || FAILING_STEP_RE.test(str(st.with?.["script"]))
|
|
60481
|
+
))
|
|
60482
|
+
return true;
|
|
60483
|
+
return readerIf !== null && new RegExp(`needs\\.${escapeRe(name)}\\.outputs\\.`).test(readerIf);
|
|
60484
|
+
}
|
|
60485
|
+
function gatingAncestors(job, wf, raw) {
|
|
60486
|
+
const out = /* @__PURE__ */ new Set();
|
|
60487
|
+
const seen = /* @__PURE__ */ new Set();
|
|
60488
|
+
const visit = (j, readerIf) => {
|
|
60489
|
+
for (const name of needsOf(j)) {
|
|
60490
|
+
const dep = wf.jobs?.[name];
|
|
60491
|
+
if (!dep) continue;
|
|
60492
|
+
if (upstreamJobGates(name, dep, wf, raw, readerIf)) out.add(name);
|
|
60493
|
+
if (seen.has(name)) continue;
|
|
60494
|
+
seen.add(name);
|
|
60495
|
+
visit(dep, str(dep.if));
|
|
60496
|
+
}
|
|
60497
|
+
};
|
|
60498
|
+
visit(job, str(job.if));
|
|
60499
|
+
return out;
|
|
60500
|
+
}
|
|
60501
|
+
function jobGated(job, wf, raw) {
|
|
60502
|
+
if (jobActorGate(job, wf, raw)) return true;
|
|
60503
|
+
const gating = gatingAncestors(job, wf, raw);
|
|
60504
|
+
if (gating.size === 0) return false;
|
|
60505
|
+
const ownIf = str(job.if);
|
|
60506
|
+
if (!STATUS_FN_RE.test(ownIf)) return true;
|
|
60507
|
+
return needsOf(job).some(
|
|
60508
|
+
(n) => gating.has(n) && new RegExp(`needs\\.${escapeRe(n)}\\.result\\s*==\\s*['"]success['"]`).test(ownIf)
|
|
60509
|
+
);
|
|
60510
|
+
}
|
|
60467
60511
|
function bypassArmed(steps) {
|
|
60468
60512
|
return steps.some(
|
|
60469
60513
|
(s) => str(s.with?.["allowed_non_write_users"]) === "*" && (!!str(s.with?.["github_token"]) || !!str(s.env?.["OVERRIDE_GITHUB_TOKEN"]))
|
|
@@ -60479,8 +60523,7 @@ function injectableJobs(wf, raw, untrustedTrigger) {
|
|
|
60479
60523
|
const a = (job.steps ?? []).filter(isAgentStep);
|
|
60480
60524
|
if (!a.length) continue;
|
|
60481
60525
|
const jobStar = bypassArmed(a);
|
|
60482
|
-
if (
|
|
60483
|
-
continue;
|
|
60526
|
+
if (jobGated(job, wf, raw) || hasImplicitActorGate(a, jobStar && untrustedTrigger)) continue;
|
|
60484
60527
|
out.push(job);
|
|
60485
60528
|
}
|
|
60486
60529
|
return out;
|
|
@@ -60526,6 +60569,36 @@ function hasMetaWritePerm(wf, agentJobs) {
|
|
|
60526
60569
|
function hasEnvDeny(steps) {
|
|
60527
60570
|
return steps.some((s) => /"?mode"?\s*:\s*"?deny/i.test(str(s.with?.["settings"])));
|
|
60528
60571
|
}
|
|
60572
|
+
var CLAUDE_CODE_ACTION_RE = /anthropics\/claude-code-action@/i;
|
|
60573
|
+
var FULL_OUTPUT_ACTION_RE = /anthropics\/claude-code(-base)?-action@/i;
|
|
60574
|
+
function pinnedBelow(uses, min) {
|
|
60575
|
+
const m = /@v?(\d+)\.(\d+)\.(\d+)$/.exec(uses.trim());
|
|
60576
|
+
if (!m) return false;
|
|
60577
|
+
const v = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
60578
|
+
for (let i = 0; i < 3; i++) if (v[i] !== min[i]) return v[i] < min[i];
|
|
60579
|
+
return false;
|
|
60580
|
+
}
|
|
60581
|
+
var FULL_OUTPUT_MIN = [1, 0, 16];
|
|
60582
|
+
var ENV_SCRUB_MIN = [1, 0, 77];
|
|
60583
|
+
function showsFullOutput(steps) {
|
|
60584
|
+
return steps.some(
|
|
60585
|
+
(s) => FULL_OUTPUT_ACTION_RE.test(s.uses ?? "") && !pinnedBelow(s.uses ?? "", FULL_OUTPUT_MIN) && str(s.with?.["show_full_output"]) === "true"
|
|
60586
|
+
);
|
|
60587
|
+
}
|
|
60588
|
+
function envScrubOptedOut(pairs, wf) {
|
|
60589
|
+
const wfEnv = wf.env;
|
|
60590
|
+
const KEY = "CLAUDE_CODE_SUBPROCESS_ENV_SCRUB";
|
|
60591
|
+
return pairs.some(({ job, step }) => {
|
|
60592
|
+
const uses = step.uses ?? "";
|
|
60593
|
+
if (!CLAUDE_CODE_ACTION_RE.test(uses) || pinnedBelow(uses, ENV_SCRUB_MIN)) return false;
|
|
60594
|
+
if (!str(step.with?.["allowed_non_write_users"]).trim()) return false;
|
|
60595
|
+
const holder = [step.env, job.env, wfEnv].find((e) => e != null && KEY in e);
|
|
60596
|
+
if (!holder) return false;
|
|
60597
|
+
const v = str(holder[KEY]).trim();
|
|
60598
|
+
if (!v || /\$\{\{/.test(v)) return false;
|
|
60599
|
+
return !/^(1|true|yes|on)$/i.test(v);
|
|
60600
|
+
});
|
|
60601
|
+
}
|
|
60529
60602
|
function agentActionsPinned(steps) {
|
|
60530
60603
|
const agent = steps.filter(isAgentStep).filter((s) => s.uses);
|
|
60531
60604
|
if (agent.length === 0) return false;
|
|
@@ -60584,10 +60657,10 @@ function analyzeWorkflow(path80, content) {
|
|
|
60584
60657
|
const elevated = hasCodeWritePerm(wf, powerJobs) || hasIdTokenWrite(wf, powerJobs) && egressTool;
|
|
60585
60658
|
const pat = usesPatIn(powerJobs);
|
|
60586
60659
|
const power = (broadTools ? 2 : 0) + (bypassActive ? 1 : 0) + (elevated ? 1 : 0) + (pat ? 1 : 0);
|
|
60587
|
-
const explicitGate = hasActorGate(wf, raw);
|
|
60588
60660
|
const implicitGate = hasImplicitActorGate(agentSteps, bypassActive);
|
|
60589
60661
|
const membershipGated = injJobs.length === 0 && jobList(wf).some((j) => (j.steps ?? []).some(isAgentStep) && hasStepMembershipGate(j));
|
|
60590
|
-
const gate =
|
|
60662
|
+
const gate = injJobs.length === 0;
|
|
60663
|
+
const gatedSiblings = agentJobs.filter((j) => !injJobs.includes(j)).length;
|
|
60591
60664
|
const envDeny = hasEnvDeny(agentSteps);
|
|
60592
60665
|
const pinned = agentActionsPinned(agentSteps);
|
|
60593
60666
|
let score = reach + power;
|
|
@@ -60645,16 +60718,42 @@ function analyzeWorkflow(path80, content) {
|
|
|
60645
60718
|
signals.push('allowed_non_write_users: "*" with github_token \u2014 any user can trigger the agent');
|
|
60646
60719
|
if (elevated) signals.push("elevated permissions (contents/id-token: write)");
|
|
60647
60720
|
if (pat) signals.push("a static PAT is exposed to the agent (recoverable via injection)");
|
|
60721
|
+
const scopedPairs = allSteps(wf).filter(
|
|
60722
|
+
(p) => isAgentStep(p.step) && (injJobs.length === 0 || injJobs.includes(p.job))
|
|
60723
|
+
);
|
|
60724
|
+
const fullOutput = showsFullOutput(scopedPairs.map((p) => p.step));
|
|
60725
|
+
const scrubOff = envScrubOptedOut(scopedPairs, wf);
|
|
60726
|
+
if (fullOutput)
|
|
60727
|
+
signals.push(
|
|
60728
|
+
"`show_full_output: true` writes every agent command and its output to the Actions log, public on a public repo; GitHub masks known secret values there, but an agent told to encode one first can publish it without any network tool"
|
|
60729
|
+
);
|
|
60730
|
+
if (scrubOff)
|
|
60731
|
+
signals.push(
|
|
60732
|
+
"`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` is switched off, so the action no longer scrubs the Anthropic key, cloud credentials and Actions runtime tokens from the agent's shell"
|
|
60733
|
+
);
|
|
60648
60734
|
if (!gate && reach > 0) signals.push("no effective actor gate");
|
|
60735
|
+
if (!gate && reach > 0 && gatedSiblings > 0) {
|
|
60736
|
+
const siblings = agentJobs.filter((j) => !injJobs.includes(j));
|
|
60737
|
+
const explicit = siblings.filter((j) => jobGated(j, wf, raw)).length;
|
|
60738
|
+
const byDefault = siblings.length - explicit;
|
|
60739
|
+
const n = (k) => `${k} other agent job${k === 1 ? "" : "s"}`;
|
|
60740
|
+
const parts = [
|
|
60741
|
+
explicit ? `${n(explicit)} ${explicit === 1 ? "is" : "are"} actor-gated` : "",
|
|
60742
|
+
byDefault ? `${n(byDefault)} ${byDefault === 1 ? "is" : "are"} held by claude-code-action's default write-access gate` : ""
|
|
60743
|
+
].filter(Boolean);
|
|
60744
|
+
signals.push(
|
|
60745
|
+
`${parts.join("; ")} in this workflow; this finding is about the ungated one${injJobs.length === 1 ? "" : "s"}`
|
|
60746
|
+
);
|
|
60747
|
+
}
|
|
60649
60748
|
const noExplicitPerms = wf.permissions == null && jobList(wf).every((j) => j.permissions == null);
|
|
60650
60749
|
if (noExplicitPerms && reach > 0 && (broadTools || githubWriteTool))
|
|
60651
60750
|
signals.push(
|
|
60652
60751
|
"no explicit `permissions:` \u2014 the token defaults to the repo/org setting, which may grant write; set it explicitly to read-only"
|
|
60653
60752
|
);
|
|
60654
60753
|
const mitigations = [];
|
|
60655
|
-
if (
|
|
60754
|
+
if (gate && (membershipGated || agentJobs.some((j) => jobGated(j, wf, raw))))
|
|
60656
60755
|
mitigations.push("actor-gated (maintainer/label/write-user required)");
|
|
60657
|
-
else if (implicitGate)
|
|
60756
|
+
else if (gate && implicitGate)
|
|
60658
60757
|
mitigations.push("claude-code-action gates the agent to write-access users by default");
|
|
60659
60758
|
if (head === "subdir") mitigations.push("untrusted head isolated in a subdir, not root");
|
|
60660
60759
|
if (envDeny) mitigations.push("secrets env-denied from the agent subprocess");
|
|
@@ -60665,7 +60764,7 @@ function analyzeWorkflow(path80, content) {
|
|
|
60665
60764
|
'allowed_non_write_users: "*" is set without github_token, so the action keeps its default write gate'
|
|
60666
60765
|
);
|
|
60667
60766
|
const title = severity === "critical" || severity === "high" ? "Injectable agent workflow \u2014 untrusted input reaches a tool-using agent with secrets" : severity === "medium" ? "Agent workflow with a risky pattern (partially mitigated)" : "Agent workflow on a privileged trigger \u2014 review the actor gate";
|
|
60668
|
-
|
|
60767
|
+
const finding = {
|
|
60669
60768
|
check: "CI-2",
|
|
60670
60769
|
// One verdict per workflow file — the finding IS the file's reachability score.
|
|
60671
60770
|
rule: "CI-2.injectable-workflow",
|
|
@@ -60677,6 +60776,12 @@ function analyzeWorkflow(path80, content) {
|
|
|
60677
60776
|
mitigations: mitigations.length ? mitigations : void 0,
|
|
60678
60777
|
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."
|
|
60679
60778
|
};
|
|
60779
|
+
const extraFix = [
|
|
60780
|
+
fullOutput ? "Remove `show_full_output: true` (the action documents it for debugging only)." : "",
|
|
60781
|
+
scrubOff ? "Remove the `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB` opt-out." : ""
|
|
60782
|
+
].filter(Boolean).join(" ");
|
|
60783
|
+
if (extraFix) finding.fix = `${finding.fix} ${extraFix}`;
|
|
60784
|
+
return finding;
|
|
60680
60785
|
}
|
|
60681
60786
|
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;
|
|
60682
60787
|
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;
|
|
@@ -60731,7 +60836,7 @@ function evalAgentJob(job, wf, raw, untrustedTrigger, reusable) {
|
|
|
60731
60836
|
promptTakesUntrusted(jobAgentSteps) ? 2 : 0,
|
|
60732
60837
|
bypassActive ? 2 : 0
|
|
60733
60838
|
);
|
|
60734
|
-
const gate =
|
|
60839
|
+
const gate = jobGated(job, wf, raw) || hasImplicitActorGate(jobAgentSteps, bypassActive);
|
|
60735
60840
|
const injectable = untrustedTrigger && !gate && reach > 0;
|
|
60736
60841
|
const canReadEnv = EXFIL_RCE_RE.test(collectTools(jobAgentSteps));
|
|
60737
60842
|
const realSecrets = secrets.filter((s) => s.kind !== "cloud-oidc");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@node9/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.24.1",
|
|
4
4
|
"description": "IAM for your AI agents. Set what Claude Code, Codex, Gemini, Cursor and any MCP server are allowed to do, review risky actions before they run, and keep every action on the record.",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|