@node9/proxy 2.16.2 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -331
- package/dist/cli.js +188 -70
- package/dist/cli.mjs +188 -70
- package/dist/dashboard.mjs +4 -2
- package/dist/index.js +90 -29
- package/dist/index.mjs +90 -29
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2251,32 +2251,61 @@ function classifySsrf(host) {
|
|
|
2251
2251
|
return null;
|
|
2252
2252
|
}
|
|
2253
2253
|
}
|
|
2254
|
+
function ssrfExemptMatches(entries, normalized) {
|
|
2255
|
+
if (!entries?.length || !normalized) return false;
|
|
2256
|
+
const target = bitsOf(normalized);
|
|
2257
|
+
for (const raw of entries) {
|
|
2258
|
+
const entry = raw.trim().toLowerCase();
|
|
2259
|
+
if (!entry) continue;
|
|
2260
|
+
const slash = entry.indexOf("/");
|
|
2261
|
+
if (slash === -1) {
|
|
2262
|
+
if ((normalizeIpLiteral(entry) ?? entry) === normalized) return true;
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
if (!target) continue;
|
|
2266
|
+
const base = bitsOf(normalizeIpLiteral(entry.slice(0, slash)) ?? "");
|
|
2267
|
+
const prefixText = entry.slice(slash + 1);
|
|
2268
|
+
const prefix = /^\d+$/.test(prefixText) ? Number(prefixText) : NaN;
|
|
2269
|
+
if (!base || !Number.isInteger(prefix) || prefix < 0 || // A v4 range never matches a v6 address, and the reverse: the widths
|
|
2270
|
+
// differ, so `0.0.0.0/0` does not release `::1`.
|
|
2271
|
+
base.length !== target.length || prefix > base.length) {
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
if (base.slice(0, prefix) === target.slice(0, prefix)) return true;
|
|
2275
|
+
}
|
|
2276
|
+
return false;
|
|
2277
|
+
}
|
|
2278
|
+
function bitsOf(normalized) {
|
|
2279
|
+
const o = v4Octets(normalized);
|
|
2280
|
+
if (o) {
|
|
2281
|
+
return o.every((n) => Number.isInteger(n) && n >= 0 && n <= 255) ? o.map((n) => n.toString(2).padStart(8, "0")).join("") : null;
|
|
2282
|
+
}
|
|
2283
|
+
const g = expandIpv6(normalized);
|
|
2284
|
+
return g ? g.map((n) => n.toString(2).padStart(16, "0")).join("") : null;
|
|
2285
|
+
}
|
|
2254
2286
|
function ssrfFloor(tokens, opts = {}) {
|
|
2255
|
-
const exempt2 = new Set(
|
|
2256
|
-
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
2257
|
-
);
|
|
2258
2287
|
for (const { token, binary } of tokens) {
|
|
2259
2288
|
const m = classifySsrf(token);
|
|
2260
2289
|
if (!m) continue;
|
|
2261
2290
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2262
|
-
if (m.overridable &&
|
|
2291
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2263
2292
|
return { ...m, host: token, binary, reason: ssrfReason(m, token) };
|
|
2264
2293
|
}
|
|
2265
2294
|
return null;
|
|
2266
2295
|
}
|
|
2267
2296
|
function hostMatches(host, pattern) {
|
|
2268
|
-
const
|
|
2269
|
-
const p = pattern.toLowerCase().trim();
|
|
2297
|
+
const p = pattern.trim().toLowerCase();
|
|
2270
2298
|
if (!p) return false;
|
|
2271
2299
|
if (p === "*") return true;
|
|
2300
|
+
const h = canonicalHost(host);
|
|
2272
2301
|
if (p.startsWith("*.")) {
|
|
2273
|
-
const suffix = p.slice(2);
|
|
2302
|
+
const suffix = canonicalHost(p.slice(2));
|
|
2274
2303
|
return h === suffix || h.endsWith("." + suffix);
|
|
2275
2304
|
}
|
|
2276
|
-
return h === p;
|
|
2305
|
+
return h === canonicalHost(p);
|
|
2277
2306
|
}
|
|
2278
2307
|
function matchesAny(host, patterns) {
|
|
2279
|
-
for (const p of patterns) if (hostMatches(host, p)) return true;
|
|
2308
|
+
for (const p of patterns ?? []) if (hostMatches(host, p)) return true;
|
|
2280
2309
|
return false;
|
|
2281
2310
|
}
|
|
2282
2311
|
function isUniqueLocalV6(host) {
|
|
@@ -2286,13 +2315,17 @@ function isUniqueLocalV6(host) {
|
|
|
2286
2315
|
return g !== null && (g[0] & 65024) === 64512;
|
|
2287
2316
|
}
|
|
2288
2317
|
function isPrivateHost(host) {
|
|
2289
|
-
const h = host.trim().toLowerCase();
|
|
2318
|
+
const h = host.trim().toLowerCase().replace(/\.$/, "");
|
|
2290
2319
|
const m = classifySsrf(h);
|
|
2291
2320
|
if (m) return m.kind === "address" && (m.tier === "private" || m.tier === "unspecified");
|
|
2292
2321
|
if (h === "localhost") return true;
|
|
2293
2322
|
if (PRIVATE_HOST_SUFFIXES.some((s) => h.endsWith(s))) return true;
|
|
2294
2323
|
return isUniqueLocalV6(h);
|
|
2295
2324
|
}
|
|
2325
|
+
function canonicalHost(host) {
|
|
2326
|
+
const ip = normalizeIpLiteral(host);
|
|
2327
|
+
return ip ?? host.trim().toLowerCase().replace(/\.$/, "");
|
|
2328
|
+
}
|
|
2296
2329
|
function evaluateEgress(dests, policy) {
|
|
2297
2330
|
if (!policy.enabled) return null;
|
|
2298
2331
|
let review = null;
|
|
@@ -2544,11 +2577,26 @@ function hostOf(value) {
|
|
|
2544
2577
|
return null;
|
|
2545
2578
|
}
|
|
2546
2579
|
}
|
|
2580
|
+
function extractToolDestinations(toolName, args) {
|
|
2581
|
+
try {
|
|
2582
|
+
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2583
|
+
if (!paths) return [];
|
|
2584
|
+
const out = [];
|
|
2585
|
+
for (const path78 of paths) {
|
|
2586
|
+
for (const value of valuesAt(args, path78)) {
|
|
2587
|
+
const host = hostOf(value);
|
|
2588
|
+
if (host) out.push({ host, binary: toolName, raw: value });
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
return out;
|
|
2592
|
+
} catch {
|
|
2593
|
+
return [];
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2547
2596
|
function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
2548
2597
|
try {
|
|
2549
2598
|
const paths = DESTINATION_ARGS.get(bareToolName(toolName));
|
|
2550
2599
|
if (!paths) return null;
|
|
2551
|
-
const exempt2 = new Set((opts.ssrfAllow ?? []).map((a) => classifySsrf(a)?.normalized ?? a));
|
|
2552
2600
|
for (const path78 of paths) {
|
|
2553
2601
|
for (const value of valuesAt(args, path78)) {
|
|
2554
2602
|
const host = hostOf(value);
|
|
@@ -2556,7 +2604,7 @@ function ssrfDestinationFloor(toolName, args, opts = {}) {
|
|
|
2556
2604
|
const m = classifySsrf(host);
|
|
2557
2605
|
if (!m) continue;
|
|
2558
2606
|
if (isStrictGatedTier(m.tier) && !opts.ssrfStrict) continue;
|
|
2559
|
-
if (m.overridable &&
|
|
2607
|
+
if (m.overridable && ssrfExemptMatches(opts.ssrfAllow, m.normalized)) continue;
|
|
2560
2608
|
return { ...m, argPath: path78, host, reason: ssrfReason(m, host) };
|
|
2561
2609
|
}
|
|
2562
2610
|
}
|
|
@@ -2645,6 +2693,16 @@ function pipeChainVerdict(command, isTrustedHost2, highAction = "review") {
|
|
|
2645
2693
|
tier: 3
|
|
2646
2694
|
};
|
|
2647
2695
|
}
|
|
2696
|
+
function egressPolicyVerdict(eg) {
|
|
2697
|
+
return {
|
|
2698
|
+
decision: eg.verdict,
|
|
2699
|
+
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
2700
|
+
reason: eg.reason,
|
|
2701
|
+
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
2702
|
+
ruleDescription: eg.reason,
|
|
2703
|
+
tier: eg.verdict === "block" ? 3 : 4
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2648
2706
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
2649
2707
|
const { agent, cwd, activeEnvironment } = context;
|
|
2650
2708
|
const { checkProvenance: checkProvenance2, isTrustedHost: isTrustedHost2 } = hooks;
|
|
@@ -2679,7 +2737,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2679
2737
|
};
|
|
2680
2738
|
}
|
|
2681
2739
|
}
|
|
2682
|
-
|
|
2740
|
+
const pendingToolEgress = config.policy.egress?.enabled ? (() => {
|
|
2741
|
+
const dests = extractToolDestinations(toolName, args);
|
|
2742
|
+
const eg = dests.length > 0 ? evaluateEgress(dests, config.policy.egress) : null;
|
|
2743
|
+
return eg ? egressPolicyVerdict(eg) : void 0;
|
|
2744
|
+
})() : void 0;
|
|
2745
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) {
|
|
2746
|
+
return pendingToolEgress ?? { decision: "allow" };
|
|
2747
|
+
}
|
|
2683
2748
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
2684
2749
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
2685
2750
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
@@ -2768,6 +2833,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2768
2833
|
}
|
|
2769
2834
|
let allTokens = [];
|
|
2770
2835
|
let pathTokens = [];
|
|
2836
|
+
if (pendingToolEgress) return pendingToolEgress;
|
|
2771
2837
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
2772
2838
|
if (shellCommand) {
|
|
2773
2839
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
@@ -2833,16 +2899,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2833
2899
|
const dests = extractShellDestinations(shellCommand);
|
|
2834
2900
|
if (dests.length > 0) {
|
|
2835
2901
|
const eg = evaluateEgress(dests, config.policy.egress);
|
|
2836
|
-
if (eg)
|
|
2837
|
-
return {
|
|
2838
|
-
decision: eg.verdict,
|
|
2839
|
-
blockedByLabel: eg.verdict === "block" ? "\u{1F310} Node9 Egress (Blocked)" : "\u{1F310} Node9 Egress (Review)",
|
|
2840
|
-
reason: eg.reason,
|
|
2841
|
-
ruleName: `egress:${eg.binary}:${eg.host}`,
|
|
2842
|
-
ruleDescription: eg.reason,
|
|
2843
|
-
tier: eg.verdict === "block" ? 3 : 4
|
|
2844
|
-
};
|
|
2845
|
-
}
|
|
2902
|
+
if (eg) return egressPolicyVerdict(eg);
|
|
2846
2903
|
}
|
|
2847
2904
|
}
|
|
2848
2905
|
const firstToken = analyzed.actions[0] ?? "";
|
|
@@ -6770,10 +6827,12 @@ var init_api_url = __esm({
|
|
|
6770
6827
|
function sanitizeSsrfAllow(entries, source) {
|
|
6771
6828
|
const kept = [];
|
|
6772
6829
|
for (const entry of entries) {
|
|
6773
|
-
const
|
|
6830
|
+
const base = entry.includes("/") ? entry.slice(0, entry.indexOf("/")).trim() : entry;
|
|
6831
|
+
const m = classifySsrf(base);
|
|
6774
6832
|
if (m && !m.overridable) {
|
|
6833
|
+
const what = entry.includes("/") ? "covers only protected addresses" : "is a protected address";
|
|
6775
6834
|
process.emitWarning(
|
|
6776
|
-
`[node9] ${source} ssrfAllow entry "${entry}"
|
|
6835
|
+
`[node9] ${source} ssrfAllow entry "${entry}" ${what} (${m.tier}) and cannot be exempted; ignoring it.`
|
|
6777
6836
|
);
|
|
6778
6837
|
continue;
|
|
6779
6838
|
}
|
|
@@ -9945,8 +10004,10 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
9945
10004
|
};
|
|
9946
10005
|
}
|
|
9947
10006
|
}
|
|
10007
|
+
const declaredEgress = config.policy.egress?.enabled === true && extractToolDestinations(toolName, args).length > 0;
|
|
10008
|
+
const judge = !isIgnoredTool2(toolName) || declaredEgress;
|
|
9948
10009
|
if (isObserveMode) {
|
|
9949
|
-
if (
|
|
10010
|
+
if (judge) {
|
|
9950
10011
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
9951
10012
|
const wouldBlock = policyResult.decision === "block";
|
|
9952
10013
|
if (!isManual)
|
|
@@ -9971,7 +10032,7 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
9971
10032
|
return { approved: true, checkedBy: "audit" };
|
|
9972
10033
|
}
|
|
9973
10034
|
if (config.settings.mode === "audit") {
|
|
9974
|
-
if (
|
|
10035
|
+
if (judge) {
|
|
9975
10036
|
const policyResult = await evaluatePolicy2(toolName, args, meta?.agent, options?.cwd);
|
|
9976
10037
|
if (policyResult.decision === "review") {
|
|
9977
10038
|
appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
|
|
@@ -10010,9 +10071,9 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
10010
10071
|
appPermReviewTool = bareTool;
|
|
10011
10072
|
}
|
|
10012
10073
|
}
|
|
10013
|
-
if (!taintWarning &&
|
|
10074
|
+
if (!taintWarning && judge) {
|
|
10014
10075
|
const ld = config.policy.loopDetection;
|
|
10015
|
-
if (ld.enabled && !appPermReview) {
|
|
10076
|
+
if (ld.enabled && !appPermReview && !isIgnoredTool2(toolName)) {
|
|
10016
10077
|
const loopResult = recordAndCheck(toolName, args, ld.threshold, ld.windowSeconds * 1e3);
|
|
10017
10078
|
if (loopResult.looping) {
|
|
10018
10079
|
const reason = `It looks like you've called "${toolName}" ${loopResult.count} times with identical arguments in the last ${ld.windowSeconds}s. Are you stuck? Step back and reconsider your approach \u2014 what are you actually trying to accomplish, and is there a different way to get there?`;
|
|
@@ -20013,19 +20074,19 @@ function evaluateEgressConfig(egress) {
|
|
|
20013
20074
|
function checkEgressFloor(egress) {
|
|
20014
20075
|
const detail = [
|
|
20015
20076
|
"the cloud instance-metadata endpoint",
|
|
20016
|
-
"link-local
|
|
20017
|
-
egress.ssrfStrict ? "the strict tier is on: loopback
|
|
20077
|
+
"link-local and multicast addresses",
|
|
20078
|
+
egress.ssrfStrict ? "the strict tier is on: loopback, the private ranges and CGNAT are blocked too" : `the strict tier is off: loopback, the private ranges and CGNAT (100.64/10) stay reachable (${egress.policySource === "workspace" ? "turn it on in the dashboard, Enforcement \u2192 Network" : "`node9 egress strict on`"})`
|
|
20018
20079
|
];
|
|
20019
20080
|
if (egress.ssrfAllow.length) {
|
|
20020
20081
|
detail.push(`you exempted: ${egress.ssrfAllow.join(", ")}`);
|
|
20021
20082
|
}
|
|
20022
|
-
detail.push("not covered:
|
|
20083
|
+
detail.push("not covered: an interpreter one-liner (node -e, python3 -c) hides its destination");
|
|
20023
20084
|
return [
|
|
20024
20085
|
{
|
|
20025
20086
|
category: "Egress",
|
|
20026
20087
|
severity: "advisory",
|
|
20027
|
-
title: "The cloud metadata endpoint is blocked
|
|
20028
|
-
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh)
|
|
20088
|
+
title: "The cloud metadata endpoint is blocked",
|
|
20089
|
+
what: "node9 blocks it before any egress policy is consulted, and no setting releases it. It sees shell commands (curl, wget, ssh) and tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate); an interpreter one-liner does not reach it.",
|
|
20029
20090
|
why: "One request to that address returns this machine's cloud credentials, to anyone who can make the agent send it.",
|
|
20030
20091
|
who: "An agent talked into fetching that address hands over the keys and cannot, here.",
|
|
20031
20092
|
owner: "node9",
|
|
@@ -57072,10 +57133,11 @@ function addEgressHost(list, host) {
|
|
|
57072
57133
|
writeEgressRawConfig(config);
|
|
57073
57134
|
}
|
|
57074
57135
|
function addSsrfExemption(address) {
|
|
57075
|
-
const
|
|
57136
|
+
const slash = address.indexOf("/");
|
|
57137
|
+
const m = classifySsrf(slash === -1 ? address : address.slice(0, slash));
|
|
57076
57138
|
if (m && !m.overridable) {
|
|
57077
57139
|
throw new Error(
|
|
57078
|
-
`${address} is a protected address (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57140
|
+
`${address} ${slash === -1 ? "is a protected address" : "covers only protected addresses"} (${m.tier}) and cannot be exempted by anyone. This is the one part of the floor no setting releases.`
|
|
57079
57141
|
);
|
|
57080
57142
|
}
|
|
57081
57143
|
const config = readEgressRawConfig();
|
|
@@ -57347,7 +57409,7 @@ var TOOLS = [
|
|
|
57347
57409
|
},
|
|
57348
57410
|
{
|
|
57349
57411
|
name: "node9_egress_status",
|
|
57350
|
-
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked
|
|
57412
|
+
description: "Show egress (outbound network) control: whether it is enabled, the mode (off / review / block), and your allow + deny host lists. Common dev/LLM hosts (github, npm, pypi, anthropic, \u2026) are always allowed by a built-in list. Also reports the SSRF floor: the addresses blocked before any policy is consulted (cloud metadata, link-local, multicast), the carriers it covers, whether the strict tier (loopback, private ranges, CGNAT) is on, and the exemptions in force. Read-only.",
|
|
57351
57413
|
inputSchema: { type: "object", properties: {}, required: [] }
|
|
57352
57414
|
},
|
|
57353
57415
|
{
|
|
@@ -57507,11 +57569,12 @@ function handleEgressStatus() {
|
|
|
57507
57569
|
// The SSRF floor. Without these lines an agent reading this answer
|
|
57508
57570
|
// concludes that internal addresses are reachable, because nothing said
|
|
57509
57571
|
// otherwise. The LIMITS are here for the same reason and matter more on
|
|
57510
|
-
// this surface than on any other: an agent treats this as ground truth
|
|
57511
|
-
//
|
|
57512
|
-
// shell
|
|
57513
|
-
|
|
57514
|
-
"
|
|
57572
|
+
// this surface than on any other: an agent treats this as ground truth.
|
|
57573
|
+
// The first version said the floor was absolute; the correction said it
|
|
57574
|
+
// was shell-only; both were wrong by the time they were read. What it
|
|
57575
|
+
// actually covers is measured in egress.integration.test.ts.
|
|
57576
|
+
"Protected addresses: cloud metadata, link-local and multicast are blocked before any of the above is consulted, in shell commands AND in tools that declare a URL (WebFetch, an MCP fetch tool, browser navigate). No setting releases them, though `node9 pause` suspends all enforcement.",
|
|
57577
|
+
"NOT covered by that: an interpreter one-liner (node -e, python3 -c) carries its destination inside a program and does not reach this gate. CGNAT (100.64/10) is NOT in the always-blocked set \u2014 it is reachable until the strict tier is on, and an exemption can release it.",
|
|
57515
57578
|
`Internal addresses: ${e.ssrfStrict ? "on" : "off"} \u2014 loopback and the private ranges are ${e.ssrfStrict ? "blocked too" : "reachable"}.`,
|
|
57516
57579
|
`Floor exemptions: ${e.ssrfAllow?.length ? e.ssrfAllow.join(", ") : "(none)"}`
|
|
57517
57580
|
];
|
|
@@ -60270,26 +60333,76 @@ function exempt(address) {
|
|
|
60270
60333
|
if (!cliGuardPolicyWrite(`egress exempt ${address}`)) return false;
|
|
60271
60334
|
return guard(() => addSsrfExemption(address));
|
|
60272
60335
|
}
|
|
60336
|
+
var INTERNAL_FIELDS = {
|
|
60337
|
+
allowed: { ssrfStrict: false, allowPrivate: true },
|
|
60338
|
+
listed: { ssrfStrict: false, allowPrivate: false },
|
|
60339
|
+
blocked: { ssrfStrict: true, allowPrivate: false }
|
|
60340
|
+
};
|
|
60341
|
+
var INTERNAL_SAID = {
|
|
60342
|
+
allowed: "reachable without listing them",
|
|
60343
|
+
listed: "reachable only if they are on your allowlist",
|
|
60344
|
+
blocked: "blocked"
|
|
60345
|
+
};
|
|
60346
|
+
function readInternalState(e) {
|
|
60347
|
+
if (e.ssrfStrict === true) return "blocked";
|
|
60348
|
+
return e.allowPrivate === false ? "listed" : "allowed";
|
|
60349
|
+
}
|
|
60350
|
+
function setInternal(value) {
|
|
60351
|
+
const state = value.trim().toLowerCase();
|
|
60352
|
+
if (!(state in INTERNAL_FIELDS)) {
|
|
60353
|
+
console.error(
|
|
60354
|
+
import_chalk34.default.red(`
|
|
60355
|
+
\u2717 Expected "allowed", "listed" or "blocked", got "${value}".`) + import_chalk34.default.gray(
|
|
60356
|
+
"\n allowed loopback, 10/172.16/192.168 and CGNAT are reachable (default)\n listed they are reachable only if you allowlist them\n blocked they are blocked at the floor\n"
|
|
60357
|
+
)
|
|
60358
|
+
);
|
|
60359
|
+
process.exitCode = 1;
|
|
60360
|
+
return;
|
|
60361
|
+
}
|
|
60362
|
+
if (!mutate(`egress internal ${state}`, INTERNAL_FIELDS[state])) return;
|
|
60363
|
+
_resetConfigCache();
|
|
60364
|
+
const effective = readInternalState(getConfig().policy.egress);
|
|
60365
|
+
if (effective !== state) {
|
|
60366
|
+
console.log(
|
|
60367
|
+
import_chalk34.default.yellow(
|
|
60368
|
+
`
|
|
60369
|
+
\u26A0 Saved, but not in effect: your workspace sets internal addresses to ${effective.toUpperCase()} and that governs this machine.
|
|
60370
|
+
Change it in the dashboard, Enforcement \u2192 Network.
|
|
60371
|
+
`
|
|
60372
|
+
)
|
|
60373
|
+
);
|
|
60374
|
+
return;
|
|
60375
|
+
}
|
|
60376
|
+
const line = `
|
|
60377
|
+
\u2713 Internal addresses: ${state} \u2014 loopback, the private ranges and CGNAT are ${INTERNAL_SAID[state]}.
|
|
60378
|
+
`;
|
|
60379
|
+
console.log(state === "allowed" ? import_chalk34.default.yellow(line) : import_chalk34.default.green(line));
|
|
60380
|
+
}
|
|
60273
60381
|
function showFloor(e, ssrfStrictSource) {
|
|
60274
|
-
console.log(
|
|
60382
|
+
console.log(
|
|
60383
|
+
import_chalk34.default.gray("\n Protected addresses") + import_chalk34.default.gray(" \u2014 in shell commands and declared URLs")
|
|
60384
|
+
);
|
|
60275
60385
|
console.log(
|
|
60276
60386
|
import_chalk34.default.gray(
|
|
60277
|
-
" always blocked: cloud metadata, link-local, multicast
|
|
60387
|
+
" always blocked: cloud metadata, link-local, multicast\n no setting releases these, though `node9 pause` suspends all enforcement"
|
|
60278
60388
|
)
|
|
60279
60389
|
);
|
|
60280
|
-
const
|
|
60390
|
+
const state = readInternalState(e);
|
|
60281
60391
|
const by = ssrfStrictSource === "workspace" ? "workspace (app.node9.ai)" : ssrfStrictSource === "local" ? "this machine (config.json)" : "the shipped default";
|
|
60392
|
+
const STATE_LABEL = {
|
|
60393
|
+
allowed: "allowed",
|
|
60394
|
+
listed: "allowlist only",
|
|
60395
|
+
blocked: "blocked"
|
|
60396
|
+
};
|
|
60282
60397
|
console.log(
|
|
60283
|
-
` Internal addresses: ${
|
|
60284
|
-
strict ? " loopback and 10/172.16/192.168 are blocked too" : " loopback and 10/172.16/192.168 are reachable"
|
|
60285
|
-
)
|
|
60398
|
+
` Internal addresses: ${state === "blocked" ? import_chalk34.default.green(STATE_LABEL[state]) : import_chalk34.default.yellow(STATE_LABEL[state])}` + import_chalk34.default.gray(` loopback, 10/172.16/192.168 and CGNAT are ${INTERNAL_SAID[state]}`)
|
|
60286
60399
|
);
|
|
60287
60400
|
console.log(import_chalk34.default.gray(` set by: ${by}`));
|
|
60288
60401
|
const exemptions = e.ssrfAllow ?? [];
|
|
60289
60402
|
console.log(import_chalk34.default.gray(` Exemptions: ${exemptions.length ? exemptions.join(", ") : "none"}`));
|
|
60290
60403
|
console.log(
|
|
60291
60404
|
import_chalk34.default.gray(
|
|
60292
|
-
"
|
|
60405
|
+
" Covered: shell commands, and tools that declare a URL (WebFetch, an MCP\n fetch tool, browser navigate).\n Not covered: an interpreter one-liner (node -e, python3 -c) hides its\n destination inside a program and does not reach this gate.\n CGNAT (100.64/10) is NOT in the always-blocked set: it is reachable\n until Internal addresses is on, and an exemption can release it."
|
|
60293
60406
|
)
|
|
60294
60407
|
);
|
|
60295
60408
|
}
|
|
@@ -60354,7 +60467,10 @@ function registerEgressCommand(program2) {
|
|
|
60354
60467
|
import_chalk34.default.yellow("\n\u2713 Egress control is off \u2014 the agent can reach any host again.\n")
|
|
60355
60468
|
);
|
|
60356
60469
|
});
|
|
60357
|
-
egress.command("
|
|
60470
|
+
egress.command("internal <allowed|listed|blocked>").description(
|
|
60471
|
+
"How loopback, private ranges and CGNAT are treated: allowed (default), listed (must be on the allowlist), or blocked"
|
|
60472
|
+
).action((value) => setInternal(value));
|
|
60473
|
+
egress.command("strict <on|off>").description("Deprecated alias for `egress internal blocked|allowed`").action((value) => {
|
|
60358
60474
|
const v = value.trim().toLowerCase();
|
|
60359
60475
|
if (v !== "on" && v !== "off") {
|
|
60360
60476
|
console.error(import_chalk34.default.red(`
|
|
@@ -60363,38 +60479,40 @@ function registerEgressCommand(program2) {
|
|
|
60363
60479
|
process.exitCode = 1;
|
|
60364
60480
|
return;
|
|
60365
60481
|
}
|
|
60366
|
-
|
|
60367
|
-
|
|
60368
|
-
|
|
60369
|
-
|
|
60370
|
-
|
|
60371
|
-
|
|
60372
|
-
|
|
60373
|
-
|
|
60374
|
-
|
|
60375
|
-
|
|
60482
|
+
const state = v === "on" ? "blocked" : getConfig().policy.egress.allowPrivate === false ? "listed" : "allowed";
|
|
60483
|
+
console.log(import_chalk34.default.gray(`
|
|
60484
|
+
(\`egress strict ${v}\` is now \`egress internal ${state}\`)`));
|
|
60485
|
+
setInternal(state);
|
|
60486
|
+
});
|
|
60487
|
+
egress.command("exempt <address>").description("Let an address or a CIDR range through the floor (e.g. 100.64.0.0/10)").action((address) => {
|
|
60488
|
+
const a = normalizeEgressHost(address);
|
|
60489
|
+
const slash = a.indexOf("/");
|
|
60490
|
+
const base = slash === -1 ? a : a.slice(0, slash);
|
|
60491
|
+
const prefixText = slash === -1 ? null : a.slice(slash + 1);
|
|
60492
|
+
if (!normalizeIpLiteral(base) || prefixText !== null && !/^\d{1,3}$/.test(prefixText)) {
|
|
60493
|
+
console.error(
|
|
60494
|
+
import_chalk34.default.red(`
|
|
60495
|
+
\u2717 "${address}" is not an address or a range.`) + import_chalk34.default.gray(
|
|
60496
|
+
"\n Exemptions are matched as an address (10.0.0.5) or a CIDR\n range (100.64.0.0/10), never a name.\n"
|
|
60376
60497
|
)
|
|
60377
60498
|
);
|
|
60499
|
+
process.exitCode = 1;
|
|
60378
60500
|
return;
|
|
60379
60501
|
}
|
|
60380
|
-
|
|
60381
|
-
|
|
60382
|
-
);
|
|
60383
|
-
});
|
|
60384
|
-
egress.command("exempt <address>").description("Let ONE address through the floor (exact address, not a range)").action((address) => {
|
|
60385
|
-
const a = normalizeEgressHost(address);
|
|
60386
|
-
if (!normalizeIpLiteral(a)) {
|
|
60502
|
+
const m = classifySsrf(base);
|
|
60503
|
+
if (m && !m.overridable) {
|
|
60387
60504
|
console.error(
|
|
60388
60505
|
import_chalk34.default.red(`
|
|
60389
|
-
\u2717
|
|
60390
|
-
|
|
60506
|
+
\u2717 ${a} cannot be exempted.`) + import_chalk34.default.gray(
|
|
60507
|
+
`
|
|
60508
|
+
${slash === -1 ? "That address is" : "That range covers only"} protected addresses (${m.tier}), which no setting releases.
|
|
60509
|
+
`
|
|
60391
60510
|
)
|
|
60392
60511
|
);
|
|
60393
60512
|
process.exitCode = 1;
|
|
60394
60513
|
return;
|
|
60395
60514
|
}
|
|
60396
60515
|
if (!exempt(a)) return;
|
|
60397
|
-
const m = classifySsrf(a);
|
|
60398
60516
|
console.log(import_chalk34.default.green(`
|
|
60399
60517
|
\u2713 ${a} is exempt from the floor.`));
|
|
60400
60518
|
if (!m)
|