kubeagent 0.1.36 → 0.1.43

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.
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { resolveApprovalDecision } from "./index.js";
3
+ const SAFE = new Set(["get_logs", "get_events", "describe_resource", "ask_user_question"]);
4
+ describe("resolveApprovalDecision", () => {
5
+ it("auto-executes a safe (read-only) action with no approver", async () => {
6
+ const decision = await resolveApprovalDecision("get_logs", {}, SAFE, undefined);
7
+ expect(decision.execute).toBe(true);
8
+ });
9
+ it("DENIES a non-safe mutating action when no approver exists (non-interactive mode)", async () => {
10
+ const decision = await resolveApprovalDecision("scale_deployment", { deployment: "web", replicas: 0 }, SAFE, undefined);
11
+ expect(decision.execute).toBe(false);
12
+ if (!decision.execute) {
13
+ expect(decision.reason).toMatch(/no approver is available/i);
14
+ }
15
+ });
16
+ it("never invokes an executor implicitly — caller must gate on execute=false", async () => {
17
+ // Simulates the tool loop: a denied decision must not run the executor.
18
+ const executor = vi.fn();
19
+ const decision = await resolveApprovalDecision("restart_pod", { pod: "x" }, SAFE, undefined);
20
+ if (decision.execute)
21
+ executor();
22
+ expect(executor).not.toHaveBeenCalled();
23
+ });
24
+ it("calls onApproval for non-safe actions and executes when approved", async () => {
25
+ const onApproval = vi.fn().mockResolvedValue(true);
26
+ const decision = await resolveApprovalDecision("scale_deployment", { deployment: "web", replicas: 3 }, SAFE, onApproval);
27
+ expect(onApproval).toHaveBeenCalledWith("scale_deployment", { deployment: "web", replicas: 3 });
28
+ expect(decision.execute).toBe(true);
29
+ });
30
+ it("denies with the user-denial message when onApproval returns false", async () => {
31
+ const onApproval = vi.fn().mockResolvedValue(false);
32
+ const decision = await resolveApprovalDecision("restart_pod", {}, SAFE, onApproval);
33
+ expect(decision.execute).toBe(false);
34
+ if (!decision.execute) {
35
+ expect(decision.reason).toMatch(/denied by user/i);
36
+ }
37
+ });
38
+ it("does not call onApproval for safe actions", async () => {
39
+ const onApproval = vi.fn().mockResolvedValue(false);
40
+ const decision = await resolveApprovalDecision("get_logs", {}, SAFE, onApproval);
41
+ expect(onApproval).not.toHaveBeenCalled();
42
+ expect(decision.execute).toBe(true);
43
+ });
44
+ it("treats user-configured safe_actions as auto-executable", async () => {
45
+ const safe = new Set([...SAFE, "rollout_restart"]);
46
+ const decision = await resolveApprovalDecision("rollout_restart", {}, safe, undefined);
47
+ expect(decision.execute).toBe(true);
48
+ });
49
+ });
@@ -1,4 +1,20 @@
1
1
  import type { Issue } from "../monitor/types.js";
2
+ export type ApprovalDecision = {
3
+ execute: true;
4
+ } | {
5
+ execute: false;
6
+ reason: string;
7
+ };
8
+ /**
9
+ * Decide whether a tool call may execute, given the set of auto-safe actions and
10
+ * the (optional) approval callback.
11
+ *
12
+ * Security-critical: a missing/undefined `onApproval` MUST mean DENY for any
13
+ * action that is not in `safeActions`. In non-interactive mode the CLI passes no
14
+ * (or a deny-all) approver, and destructive remediation (e.g. scale-to-0) must
15
+ * never run unattended.
16
+ */
17
+ export declare function resolveApprovalDecision(toolName: string, params: Record<string, unknown>, safeActions: Set<string>, onApproval?: (action: string, params: Record<string, unknown>) => Promise<boolean>): Promise<ApprovalDecision>;
2
18
  export interface DiagnosisResult {
3
19
  analysis: string;
4
20
  action?: {
@@ -19,6 +19,32 @@ function truncateOutput(output) {
19
19
  const half = Math.floor(MAX_TOOL_OUTPUT_CHARS / 2);
20
20
  return output.slice(0, half) + "\n\n... [truncated] ...\n\n" + output.slice(-half);
21
21
  }
22
+ /**
23
+ * Decide whether a tool call may execute, given the set of auto-safe actions and
24
+ * the (optional) approval callback.
25
+ *
26
+ * Security-critical: a missing/undefined `onApproval` MUST mean DENY for any
27
+ * action that is not in `safeActions`. In non-interactive mode the CLI passes no
28
+ * (or a deny-all) approver, and destructive remediation (e.g. scale-to-0) must
29
+ * never run unattended.
30
+ */
31
+ export async function resolveApprovalDecision(toolName, params, safeActions, onApproval) {
32
+ // Safe (read-only or explicitly allowlisted) actions auto-execute.
33
+ if (safeActions.has(toolName))
34
+ return { execute: true };
35
+ // Non-safe action requires approval regardless of whether a callback exists.
36
+ if (!onApproval) {
37
+ return {
38
+ execute: false,
39
+ reason: "Action requires approval but no approver is available (non-interactive mode); denied. Propose a safe alternative or escalate to a human.",
40
+ };
41
+ }
42
+ const approved = await onApproval(toolName, params);
43
+ if (!approved) {
44
+ return { execute: false, reason: "Action denied by user. Propose an alternative." };
45
+ }
46
+ return { execute: true };
47
+ }
22
48
  async function askUserQuestion(question, choices) {
23
49
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
24
50
  return new Promise((resolve) => {
@@ -184,17 +210,17 @@ If it requires a risky action (rollback, delete, scale to zero), propose it but
184
210
  });
185
211
  continue;
186
212
  }
187
- // Check if action requires approval
188
- if (!effectiveSafeActions.has(block.name) && options?.onApproval) {
189
- const approved = await options.onApproval(block.name, block.input);
190
- if (!approved) {
191
- toolResults.push({
192
- type: "tool_result",
193
- tool_use_id: block.id,
194
- content: "Action denied by user. Propose an alternative.",
195
- });
196
- continue;
197
- }
213
+ // Check if action requires approval. A missing onApproval callback
214
+ // (non-interactive mode) means DENY for any non-safe action — never
215
+ // execute destructive remediation unattended.
216
+ const decision = await resolveApprovalDecision(block.name, block.input, effectiveSafeActions, options?.onApproval);
217
+ if (!decision.execute) {
218
+ toolResults.push({
219
+ type: "tool_result",
220
+ tool_use_id: block.id,
221
+ content: decision.reason,
222
+ });
223
+ continue;
198
224
  }
199
225
  try {
200
226
  const result = await executor(block.input, { context: clusterContext });
@@ -8,8 +8,18 @@ export interface CheckSummary {
8
8
  nodeCount: number;
9
9
  namespaceCount: number;
10
10
  }
11
+ export interface RunChecksScope {
12
+ /**
13
+ * Namespaces to scope the scan to. When set, kubectl queries are issued
14
+ * per-namespace (e.g. `kubectl get pods -n foo`) instead of the
15
+ * cluster-wide `--all-namespaces` form. Cluster-scoped queries
16
+ * (`get nodes`, `get namespaces`) are still attempted but their failures
17
+ * are tolerated so namespace-scoped service accounts can use `kubeagent check`.
18
+ */
19
+ namespaces?: string[];
20
+ }
11
21
  export declare function runChecks(options: KubectlOptions): Promise<Issue[]>;
12
- export declare function runChecks(options: KubectlOptions, withSummary: true): Promise<CheckSummary>;
22
+ export declare function runChecks(options: KubectlOptions, withSummary: true, scope?: RunChecksScope): Promise<CheckSummary>;
13
23
  export declare function computeResolvedKeys(activeKeys: Set<string>, currentKeys: Set<string>): string[];
14
24
  export declare function updateActiveKeys(activeKeys: Set<string>, resolvedKeys: string[], currentKeys: Set<string>): void;
15
25
  export declare function startMonitor(options: KubectlOptions, intervalMs: number, onIssues: IssueCallback, onResolved?: ResolveCallback, onFirstCycle?: () => void): {
@@ -33,28 +33,59 @@ function deduplicateIssues(issues) {
33
33
  return true;
34
34
  });
35
35
  }
36
- export async function runChecks(options, withSummary) {
36
+ export async function runChecks(options, withSummary, scope) {
37
37
  const allIssues = [];
38
- // Get all pods across namespaces
39
- const podList = (await kubectlJson(["get", "pods", "--all-namespaces"], options));
40
- allIssues.push(...findPodIssues(podList));
41
- // Get nodes
42
- const nodeList = (await kubectlJson(["get", "nodes"], options));
38
+ const scoped = scope?.namespaces && scope.namespaces.length > 0 ? scope.namespaces : null;
39
+ // Pods: query per-namespace when scoped (so ns-bound service accounts
40
+ // don't need cluster-wide list-pods), otherwise all-namespaces.
41
+ let podCount = 0;
42
+ if (scoped) {
43
+ for (const ns of scoped) {
44
+ const list = (await kubectlJson(["get", "pods", "-n", ns], options).catch(() => ({ items: [] })));
45
+ allIssues.push(...findPodIssues(list));
46
+ podCount += list.items.length;
47
+ }
48
+ }
49
+ else {
50
+ const podList = (await kubectlJson(["get", "pods", "--all-namespaces"], options));
51
+ allIssues.push(...findPodIssues(podList));
52
+ podCount = podList.items.length;
53
+ }
54
+ // Nodes: cluster-scoped. Tolerate forbidden so ns-bound service accounts
55
+ // still get a useful pod-level report (node issues just don't apply to them).
56
+ const nodeList = (await kubectlJson(["get", "nodes"], options).catch(() => ({ items: [] })));
43
57
  allIssues.push(...findNodeIssues(nodeList));
44
- // Get failed jobs
45
- const jobList = (await kubectlJson(["get", "jobs", "--all-namespaces", "--field-selector=status.failed>0"], options).catch(() => ({ items: [] })));
46
- allIssues.push(...findJobIssues(jobList));
58
+ // Failed jobs: per-namespace when scoped, otherwise all-namespaces.
59
+ if (scoped) {
60
+ for (const ns of scoped) {
61
+ const list = (await kubectlJson(["get", "jobs", "-n", ns, "--field-selector=status.failed>0"], options).catch(() => ({ items: [] })));
62
+ allIssues.push(...findJobIssues(list));
63
+ }
64
+ }
65
+ else {
66
+ const jobList = (await kubectlJson(["get", "jobs", "--all-namespaces", "--field-selector=status.failed>0"], options).catch(() => ({ items: [] })));
67
+ allIssues.push(...findJobIssues(jobList));
68
+ }
47
69
  const issues = deduplicateIssues(allIssues);
48
70
  if (!withSummary)
49
71
  return issues;
50
- // Fetch namespace count (non-fatal)
51
- const nsList = await kubectlJson(["get", "namespaces"], options)
52
- .catch(() => ({ items: [] }));
72
+ // Namespace count: cluster-scoped, tolerate forbidden the same way as nodes.
73
+ // When the caller already specified namespaces, use that length so the
74
+ // summary reflects the requested scope, not the cluster's namespace total.
75
+ let namespaceCount;
76
+ if (scoped) {
77
+ namespaceCount = scoped.length;
78
+ }
79
+ else {
80
+ const nsList = await kubectlJson(["get", "namespaces"], options)
81
+ .catch(() => ({ items: [] }));
82
+ namespaceCount = nsList.items.length;
83
+ }
53
84
  return {
54
85
  issues,
55
- podCount: podList.items.length,
86
+ podCount,
56
87
  nodeCount: nodeList.items.length,
57
- namespaceCount: nsList.items.length,
88
+ namespaceCount,
58
89
  };
59
90
  }
60
91
  // How long a pod must be Pending before it's reported as an issue.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { runChecks } from "./index.js";
3
+ import * as kubectl from "../kubectl.js";
4
+ describe("runChecks namespace scope", () => {
5
+ beforeEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+ it("queries per-namespace when scope.namespaces is set", async () => {
9
+ const calls = [];
10
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
11
+ calls.push(args);
12
+ return { items: [] };
13
+ });
14
+ await runChecks({}, true, { namespaces: ["foo", "bar"] });
15
+ // Pods: one per requested namespace, never --all-namespaces
16
+ const podCalls = calls.filter((c) => c[0] === "get" && c[1] === "pods");
17
+ expect(podCalls).toHaveLength(2);
18
+ expect(podCalls[0]).toEqual(["get", "pods", "-n", "foo"]);
19
+ expect(podCalls[1]).toEqual(["get", "pods", "-n", "bar"]);
20
+ expect(calls.some((c) => c.includes("--all-namespaces") && c[1] === "pods")).toBe(false);
21
+ // Jobs: same per-namespace pattern
22
+ const jobCalls = calls.filter((c) => c[0] === "get" && c[1] === "jobs");
23
+ expect(jobCalls).toHaveLength(2);
24
+ expect(jobCalls[0]).toContain("foo");
25
+ expect(jobCalls[1]).toContain("bar");
26
+ expect(calls.some((c) => c.includes("--all-namespaces") && c[1] === "jobs")).toBe(false);
27
+ });
28
+ it("uses --all-namespaces when no scope is given", async () => {
29
+ const calls = [];
30
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
31
+ calls.push(args);
32
+ return { items: [] };
33
+ });
34
+ await runChecks({});
35
+ expect(calls.some((c) => c[0] === "get" && c[1] === "pods" && c.includes("--all-namespaces"))).toBe(true);
36
+ expect(calls.some((c) => c[0] === "get" && c[1] === "jobs" && c.includes("--all-namespaces"))).toBe(true);
37
+ });
38
+ it("tolerates forbidden errors on cluster-scoped node queries", async () => {
39
+ // Simulate a namespace-scoped service account: pods succeed, nodes 403
40
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
41
+ if (args[1] === "nodes") {
42
+ throw new Error("Error from server (Forbidden): nodes is forbidden");
43
+ }
44
+ return { items: [] };
45
+ });
46
+ const result = await runChecks({}, true, { namespaces: ["foo"] });
47
+ expect(result.issues).toEqual([]);
48
+ expect(result.nodeCount).toBe(0);
49
+ });
50
+ it("reports namespaceCount = scope length when scoped (skips cluster ns list)", async () => {
51
+ const calls = [];
52
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
53
+ calls.push(args);
54
+ return { items: [] };
55
+ });
56
+ const result = await runChecks({}, true, { namespaces: ["foo", "bar", "baz"] });
57
+ expect(result.namespaceCount).toBe(3);
58
+ // Should not have called get namespaces — that's cluster-scoped and may 403.
59
+ expect(calls.some((c) => c[0] === "get" && c[1] === "namespaces")).toBe(false);
60
+ });
61
+ it("accumulates pod counts across namespaces", async () => {
62
+ const okPod = (ns, name) => ({
63
+ metadata: { name, namespace: ns },
64
+ status: { phase: "Running", containerStatuses: [] },
65
+ });
66
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
67
+ if (args[1] === "pods" && args[3] === "foo") {
68
+ return { items: [okPod("foo", "a"), okPod("foo", "b"), okPod("foo", "c")] };
69
+ }
70
+ if (args[1] === "pods" && args[3] === "bar") {
71
+ return { items: [okPod("bar", "x"), okPod("bar", "y")] };
72
+ }
73
+ return { items: [] };
74
+ });
75
+ const result = await runChecks({}, true, { namespaces: ["foo", "bar"] });
76
+ expect(result.podCount).toBe(5);
77
+ });
78
+ it("empty scope.namespaces is treated as no scope (all-namespaces)", async () => {
79
+ const calls = [];
80
+ vi.spyOn(kubectl, "kubectlJson").mockImplementation(async (args) => {
81
+ calls.push(args);
82
+ return { items: [] };
83
+ });
84
+ await runChecks({}, true, { namespaces: [] });
85
+ expect(calls.some((c) => c[0] === "get" && c[1] === "pods" && c.includes("--all-namespaces"))).toBe(true);
86
+ });
87
+ });
@@ -1,3 +1,4 @@
1
+ import { isSafeWebhookUrl } from "./ssrf-guard.js";
1
2
  const CRITICAL_COLOR = 0xe74c3c; // red
2
3
  const WARNING_COLOR = 0xf39c12; // yellow
3
4
  const INFO_COLOR = 0x3498db; // blue
@@ -32,15 +33,9 @@ function formatDiscordPayload(issues, clusterContext) {
32
33
  };
33
34
  }
34
35
  async function postToDiscord(webhookUrl, payload) {
35
- try {
36
- const url = new URL(webhookUrl);
37
- if (!["https:", "http:"].includes(url.protocol)) {
38
- console.error("Discord: invalid URL protocol");
39
- return;
40
- }
41
- }
42
- catch {
43
- console.error("Discord: invalid webhook URL");
36
+ // SSRF guard: reject invalid URLs and hosts in private/loopback/metadata ranges.
37
+ if (!(await isSafeWebhookUrl(webhookUrl))) {
38
+ console.error("Discord: invalid or unsafe webhook URL (must be a public host)");
44
39
  return;
45
40
  }
46
41
  try {
@@ -48,6 +43,8 @@ async function postToDiscord(webhookUrl, payload) {
48
43
  method: "POST",
49
44
  headers: { "Content-Type": "application/json" },
50
45
  body: JSON.stringify(payload),
46
+ // Don't follow redirects to internal addresses (see ssrf-guard).
47
+ redirect: "error",
51
48
  signal: AbortSignal.timeout(10_000),
52
49
  });
53
50
  if (!res.ok)
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns true if the URL is safe to fetch (public host, valid scheme).
3
+ * Returns false for invalid URLs or hosts in any blocked range.
4
+ */
5
+ export declare function isSafeWebhookUrl(rawUrl: string): Promise<boolean>;
@@ -0,0 +1,150 @@
1
+ import { promises as dns } from "node:dns";
2
+ import { isIP } from "node:net";
3
+ /**
4
+ * Minimal SSRF guard for the CLI. The CLI is a separate package from the server
5
+ * (they do not share runtime code — see CLAUDE.md), so this duplicates the core
6
+ * of `server/src/lib/ssrf-guard.ts`: reject user-supplied webhook URLs that point
7
+ * at private/loopback/link-local/ULA/metadata/CGNAT ranges, resolving hostnames
8
+ * (all addresses) to defeat the common DNS-rebinding case.
9
+ */
10
+ function ipv4ToInt(ip) {
11
+ const parts = ip.split(".");
12
+ if (parts.length !== 4)
13
+ return null;
14
+ let value = 0;
15
+ for (const part of parts) {
16
+ if (!/^\d{1,3}$/.test(part))
17
+ return null;
18
+ const n = Number(part);
19
+ if (n > 255)
20
+ return null;
21
+ value = value * 256 + n;
22
+ }
23
+ return value >>> 0;
24
+ }
25
+ function inV4Range(ipInt, cidrBase, prefix) {
26
+ const base = ipv4ToInt(cidrBase);
27
+ if (base === null)
28
+ return false;
29
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
30
+ return (ipInt & mask) === (base & mask);
31
+ }
32
+ function isBlockedIPv4(ip) {
33
+ const n = ipv4ToInt(ip);
34
+ if (n === null)
35
+ return true;
36
+ return (inV4Range(n, "10.0.0.0", 8) ||
37
+ inV4Range(n, "172.16.0.0", 12) ||
38
+ inV4Range(n, "192.168.0.0", 16) ||
39
+ inV4Range(n, "127.0.0.0", 8) ||
40
+ inV4Range(n, "169.254.0.0", 16) ||
41
+ inV4Range(n, "100.64.0.0", 10) ||
42
+ inV4Range(n, "0.0.0.0", 8));
43
+ }
44
+ function ipv6ToGroups(ipRaw) {
45
+ let ip = ipRaw.toLowerCase();
46
+ const pct = ip.indexOf("%");
47
+ if (pct !== -1)
48
+ ip = ip.slice(0, pct);
49
+ let v4Tail = null;
50
+ const lastColon = ip.lastIndexOf(":");
51
+ const tail = lastColon === -1 ? "" : ip.slice(lastColon + 1);
52
+ if (tail.includes(".")) {
53
+ const v4 = ipv4ToInt(tail);
54
+ if (v4 === null)
55
+ return null;
56
+ v4Tail = [(v4 >>> 16) & 0xffff, v4 & 0xffff];
57
+ ip = ip.slice(0, lastColon + 1);
58
+ }
59
+ const halves = ip.split("::");
60
+ if (halves.length > 2)
61
+ return null;
62
+ const parseSide = (side) => {
63
+ if (side === "")
64
+ return [];
65
+ const out = [];
66
+ for (const h of side.split(":")) {
67
+ if (h === "" || !/^[0-9a-f]{1,4}$/.test(h))
68
+ return null;
69
+ out.push(parseInt(h, 16));
70
+ }
71
+ return out;
72
+ };
73
+ const left = parseSide(halves[0]);
74
+ if (left === null)
75
+ return null;
76
+ let groups;
77
+ if (halves.length === 2) {
78
+ const right = parseSide(halves[1]);
79
+ if (right === null)
80
+ return null;
81
+ const rightAll = v4Tail ? [...right, ...v4Tail] : right;
82
+ const fill = 8 - left.length - rightAll.length;
83
+ if (fill < 0)
84
+ return null;
85
+ groups = [...left, ...Array(fill).fill(0), ...rightAll];
86
+ }
87
+ else {
88
+ groups = v4Tail ? [...left, ...v4Tail] : left;
89
+ }
90
+ if (groups.length !== 8)
91
+ return null;
92
+ return groups;
93
+ }
94
+ function isBlockedIPv6(ip) {
95
+ const g = ipv6ToGroups(ip);
96
+ if (g === null)
97
+ return true;
98
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1)
99
+ return true; // ::1
100
+ if (g.every((x) => x === 0))
101
+ return true; // ::
102
+ if ((g[0] & 0xfe00) === 0xfc00)
103
+ return true; // fc00::/7
104
+ if ((g[0] & 0xffc0) === 0xfe80)
105
+ return true; // fe80::/10
106
+ const isMapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff;
107
+ const isCompat = g.slice(0, 6).every((x) => x === 0) && (g[6] !== 0 || g[7] > 1);
108
+ if (isMapped || isCompat) {
109
+ const v4 = `${(g[6] >> 8) & 0xff}.${g[6] & 0xff}.${(g[7] >> 8) & 0xff}.${g[7] & 0xff}`;
110
+ return isBlockedIPv4(v4);
111
+ }
112
+ return false;
113
+ }
114
+ function isBlockedIP(ip) {
115
+ const kind = isIP(ip);
116
+ if (kind === 4)
117
+ return isBlockedIPv4(ip);
118
+ if (kind === 6)
119
+ return isBlockedIPv6(ip);
120
+ return false;
121
+ }
122
+ /**
123
+ * Returns true if the URL is safe to fetch (public host, valid scheme).
124
+ * Returns false for invalid URLs or hosts in any blocked range.
125
+ */
126
+ export async function isSafeWebhookUrl(rawUrl) {
127
+ let url;
128
+ try {
129
+ url = new URL(rawUrl);
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ if (!["https:", "http:"].includes(url.protocol))
135
+ return false;
136
+ let host = url.hostname;
137
+ if (host.startsWith("[") && host.endsWith("]"))
138
+ host = host.slice(1, -1);
139
+ if (isIP(host) !== 0)
140
+ return !isBlockedIP(host);
141
+ try {
142
+ const records = await dns.lookup(host, { all: true });
143
+ if (records.length === 0)
144
+ return false;
145
+ return records.every((r) => !isBlockedIP(r.address));
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
@@ -1,3 +1,4 @@
1
+ import { isSafeWebhookUrl } from "./ssrf-guard.js";
1
2
  function formatTeamsPayload(issues, clusterContext) {
2
3
  const critical = issues.filter((i) => i.severity === "critical");
3
4
  const warning = issues.filter((i) => i.severity === "warning");
@@ -25,15 +26,9 @@ function formatTeamsPayload(issues, clusterContext) {
25
26
  };
26
27
  }
27
28
  async function postToTeams(webhookUrl, payload) {
28
- try {
29
- const url = new URL(webhookUrl);
30
- if (!["https:", "http:"].includes(url.protocol)) {
31
- console.error("Teams: invalid URL protocol");
32
- return;
33
- }
34
- }
35
- catch {
36
- console.error("Teams: invalid webhook URL");
29
+ // SSRF guard: reject invalid URLs and hosts in private/loopback/metadata ranges.
30
+ if (!(await isSafeWebhookUrl(webhookUrl))) {
31
+ console.error("Teams: invalid or unsafe webhook URL (must be a public host)");
37
32
  return;
38
33
  }
39
34
  try {
@@ -41,6 +36,8 @@ async function postToTeams(webhookUrl, payload) {
41
36
  method: "POST",
42
37
  headers: { "Content-Type": "application/json" },
43
38
  body: JSON.stringify(payload),
39
+ // Don't follow redirects to internal addresses (see ssrf-guard).
40
+ redirect: "error",
44
41
  signal: AbortSignal.timeout(10_000),
45
42
  });
46
43
  if (!res.ok)
@@ -1,13 +1,8 @@
1
+ import { isSafeWebhookUrl } from "./ssrf-guard.js";
1
2
  export async function sendWebhook(issues, channel, clusterContext) {
2
- try {
3
- const url = new URL(channel.url);
4
- if (!["https:", "http:"].includes(url.protocol)) {
5
- console.error("Webhook: invalid URL protocol");
6
- return;
7
- }
8
- }
9
- catch {
10
- console.error("Webhook: invalid URL");
3
+ // SSRF guard: reject invalid URLs and hosts in private/loopback/metadata ranges.
4
+ if (!(await isSafeWebhookUrl(channel.url))) {
5
+ console.error("Webhook: invalid or unsafe URL (must be a public https/http host)");
11
6
  return;
12
7
  }
13
8
  const payload = {
@@ -31,6 +26,9 @@ export async function sendWebhook(issues, channel, clusterContext) {
31
26
  method: "POST",
32
27
  headers,
33
28
  body: JSON.stringify(payload),
29
+ // Don't follow redirects: a public host could 30x to an internal address
30
+ // (metadata/localhost/RFC-1918) that the guard above never saw.
31
+ redirect: "error",
34
32
  signal: AbortSignal.timeout(10_000),
35
33
  });
36
34
  if (!res.ok)
@@ -1,12 +1,20 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  const mockFetch = vi.fn();
3
3
  vi.stubGlobal("fetch", mockFetch);
4
+ // Mock DNS so the SSRF guard resolves example.com to a public address without
5
+ // real network access, keeping tests hermetic.
6
+ const mockLookup = vi.fn();
7
+ vi.mock("node:dns", () => ({
8
+ promises: { lookup: (...args) => mockLookup(...args) },
9
+ }));
4
10
  const channel = { type: "webhook", url: "https://example.com/hook", secret: "testsecret", severity: "warning" };
5
11
  const issue = { kind: "pod_crashloop", severity: "critical", namespace: "prod", resource: "api-web", message: "Pod crash-looping", details: { restartCount: 3 }, timestamp: new Date("2026-01-01T00:00:00Z") };
6
12
  describe("sendWebhook", () => {
7
13
  beforeEach(() => {
8
14
  mockFetch.mockReset();
9
15
  mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
16
+ mockLookup.mockReset();
17
+ mockLookup.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
10
18
  });
11
19
  it("POSTs to the configured URL", async () => {
12
20
  const { sendWebhook } = await import("./webhook.js");
@@ -50,4 +58,17 @@ describe("sendWebhook", () => {
50
58
  await expect(sendWebhook([issue], badChannel)).resolves.not.toThrow();
51
59
  expect(mockFetch).not.toHaveBeenCalled();
52
60
  });
61
+ it("blocks SSRF: does not fetch a literal private/metadata IP", async () => {
62
+ const metaChannel = { ...channel, url: "http://169.254.169.254/latest/meta-data/" };
63
+ const { sendWebhook } = await import("./webhook.js");
64
+ await expect(sendWebhook([issue], metaChannel)).resolves.not.toThrow();
65
+ expect(mockFetch).not.toHaveBeenCalled();
66
+ });
67
+ it("blocks SSRF: does not fetch a hostname resolving to loopback", async () => {
68
+ mockLookup.mockResolvedValue([{ address: "127.0.0.1", family: 4 }]);
69
+ const rebindChannel = { ...channel, url: "https://internal.evil.example/hook" };
70
+ const { sendWebhook } = await import("./webhook.js");
71
+ await expect(sendWebhook([issue], rebindChannel)).resolves.not.toThrow();
72
+ expect(mockFetch).not.toHaveBeenCalled();
73
+ });
53
74
  });
@@ -140,7 +140,8 @@ export async function handleIssues(issues, config, clusterContext, noInteractive
140
140
  autoFix: config.remediation.auto_fix,
141
141
  safeActions: config.remediation.safe_actions,
142
142
  noInteractive,
143
- onApproval: noInteractive ? undefined : askApproval,
143
+ // Non-interactive mode must auto-deny every approval-gated (non-safe) action.
144
+ onApproval: noInteractive ? (async () => false) : askApproval,
144
145
  onQuestion: (question, choices) => broadcastQuestion(question, choices, config, clusterContext),
145
146
  });
146
147
  // ── Analysis ──────────────────────────────────────────────