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.
- package/README.md +32 -0
- package/dist/auth.js +15 -0
- package/dist/check/index.d.ts +34 -0
- package/dist/check/index.js +77 -0
- package/dist/check/index.test.d.ts +1 -0
- package/dist/check/index.test.js +202 -0
- package/dist/check/run.d.ts +10 -0
- package/dist/check/run.js +114 -0
- package/dist/cli.js +25 -2
- package/dist/detector/sources.d.ts +13 -0
- package/dist/detector/sources.js +1 -1
- package/dist/detector/sources.test.d.ts +1 -0
- package/dist/detector/sources.test.js +36 -0
- package/dist/diagnoser/approval.test.d.ts +1 -0
- package/dist/diagnoser/approval.test.js +49 -0
- package/dist/diagnoser/index.d.ts +16 -0
- package/dist/diagnoser/index.js +37 -11
- package/dist/monitor/index.d.ts +11 -1
- package/dist/monitor/index.js +45 -14
- package/dist/monitor/scope.test.d.ts +1 -0
- package/dist/monitor/scope.test.js +87 -0
- package/dist/notify/discord.js +6 -9
- package/dist/notify/ssrf-guard.d.ts +5 -0
- package/dist/notify/ssrf-guard.js +150 -0
- package/dist/notify/teams.js +6 -9
- package/dist/notify/webhook.js +7 -9
- package/dist/notify/webhook.test.js +21 -0
- package/dist/orchestrator.js +2 -1
- package/dist/telemetry.js +27 -1
- package/package.json +10 -1
|
@@ -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?: {
|
package/dist/diagnoser/index.js
CHANGED
|
@@ -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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 });
|
package/dist/monitor/index.d.ts
CHANGED
|
@@ -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): {
|
package/dist/monitor/index.js
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
86
|
+
podCount,
|
|
56
87
|
nodeCount: nodeList.items.length,
|
|
57
|
-
namespaceCount
|
|
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
|
+
});
|
package/dist/notify/discord.js
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,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
|
+
}
|
package/dist/notify/teams.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|
package/dist/notify/webhook.js
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
+
import { isSafeWebhookUrl } from "./ssrf-guard.js";
|
|
1
2
|
export async function sendWebhook(issues, channel, clusterContext) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
});
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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 ──────────────────────────────────────────────
|