kubeagent 0.1.36 → 0.1.44

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.
Files changed (80) hide show
  1. package/README.md +32 -0
  2. package/dist/auth.js +15 -0
  3. package/dist/check/index.js +77 -0
  4. package/dist/check/run.js +114 -0
  5. package/dist/cli.js +25 -2
  6. package/dist/detector/sources.js +1 -1
  7. package/dist/diagnoser/index.js +37 -11
  8. package/dist/monitor/index.js +45 -14
  9. package/dist/notify/discord.js +6 -9
  10. package/dist/notify/ssrf-guard.js +150 -0
  11. package/dist/notify/teams.js +6 -9
  12. package/dist/notify/webhook.js +7 -9
  13. package/dist/orchestrator.js +2 -1
  14. package/dist/telemetry.js +27 -1
  15. package/package.json +10 -1
  16. package/dist/auth.d.ts +0 -29
  17. package/dist/cli.d.ts +0 -2
  18. package/dist/config.d.ts +0 -75
  19. package/dist/debug.d.ts +0 -10
  20. package/dist/detector/cache.d.ts +0 -6
  21. package/dist/detector/cache.test.d.ts +0 -1
  22. package/dist/detector/cache.test.js +0 -54
  23. package/dist/detector/catalog.d.ts +0 -17
  24. package/dist/detector/catalog.test.d.ts +0 -1
  25. package/dist/detector/catalog.test.js +0 -33
  26. package/dist/detector/eol.d.ts +0 -13
  27. package/dist/detector/eol.test.d.ts +0 -1
  28. package/dist/detector/eol.test.js +0 -78
  29. package/dist/detector/image-parser.d.ts +0 -11
  30. package/dist/detector/image-parser.test.d.ts +0 -1
  31. package/dist/detector/image-parser.test.js +0 -87
  32. package/dist/detector/index.d.ts +0 -33
  33. package/dist/detector/index.test.d.ts +0 -1
  34. package/dist/detector/index.test.js +0 -210
  35. package/dist/detector/osv.d.ts +0 -12
  36. package/dist/detector/osv.test.d.ts +0 -1
  37. package/dist/detector/osv.test.js +0 -86
  38. package/dist/detector/report.d.ts +0 -6
  39. package/dist/detector/sources.d.ts +0 -42
  40. package/dist/diagnoser/index.d.ts +0 -17
  41. package/dist/diagnoser/tools.d.ts +0 -119
  42. package/dist/kb/loader.d.ts +0 -1
  43. package/dist/kb/writer.d.ts +0 -12
  44. package/dist/kubectl-config.d.ts +0 -7
  45. package/dist/kubectl.d.ts +0 -13
  46. package/dist/monitor/checks.d.ts +0 -71
  47. package/dist/monitor/index.d.ts +0 -17
  48. package/dist/monitor/resolve.test.d.ts +0 -1
  49. package/dist/monitor/resolve.test.js +0 -21
  50. package/dist/monitor/types.d.ts +0 -11
  51. package/dist/notify/discord.d.ts +0 -4
  52. package/dist/notify/index.d.ts +0 -6
  53. package/dist/notify/pagerduty.d.ts +0 -9
  54. package/dist/notify/pagerduty.test.d.ts +0 -1
  55. package/dist/notify/pagerduty.test.js +0 -97
  56. package/dist/notify/setup.d.ts +0 -9
  57. package/dist/notify/slack.d.ts +0 -4
  58. package/dist/notify/teams.d.ts +0 -3
  59. package/dist/notify/telegram.d.ts +0 -9
  60. package/dist/notify/webhook.d.ts +0 -3
  61. package/dist/notify/webhook.test.d.ts +0 -1
  62. package/dist/notify/webhook.test.js +0 -53
  63. package/dist/onboard/cluster-scan.d.ts +0 -42
  64. package/dist/onboard/code-scan.d.ts +0 -9
  65. package/dist/onboard/index.d.ts +0 -3
  66. package/dist/onboard/interview.d.ts +0 -12
  67. package/dist/onboard/project-matcher.d.ts +0 -25
  68. package/dist/orchestrator.d.ts +0 -3
  69. package/dist/proxy-client.d.ts +0 -27
  70. package/dist/render.d.ts +0 -5
  71. package/dist/telemetry.d.ts +0 -1
  72. package/dist/telemetry.test.d.ts +0 -1
  73. package/dist/telemetry.test.js +0 -18
  74. package/dist/update-notifier.d.ts +0 -1
  75. package/dist/update-notifier.test.d.ts +0 -1
  76. package/dist/update-notifier.test.js +0 -50
  77. package/dist/verifier.d.ts +0 -9
  78. package/dist/version-check.d.ts +0 -14
  79. package/dist/version-check.test.d.ts +0 -1
  80. package/dist/version-check.test.js +0 -179
package/README.md CHANGED
@@ -27,6 +27,7 @@ kubeagent watch
27
27
  | `status` | Quick cluster health check (no LLM) |
28
28
  | `onboard` | Scan cluster + codebases, generate knowledge base |
29
29
  | `watch` | Continuous monitoring with auto-remediation |
30
+ | `check` | One-shot cluster health check for CI pipelines (read-only by default, JSON output, exit codes) |
30
31
  | `diagnose <resource>` | One-shot diagnosis of a pod/deployment/service |
31
32
  | `scan <directory>` | Match local project directories to cluster deployments |
32
33
  | `scan-apps` | Detect OSS apps in the cluster, list affected workloads + ingress domains, check for CVEs / EOL (`--report` for AI-prioritized remediation) |
@@ -43,6 +44,30 @@ kubeagent watch
43
44
  - `-i, --interval <seconds>` — Check interval for `watch` (default: 300)
44
45
  - `--no-interactive` — Auto-deny all approvals, skip questions (for `watch` in background/CI)
45
46
 
47
+ ## CI Usage
48
+
49
+ `kubeagent check` is the one-shot mode for CI pipelines. It scans the cluster, prints a report, and exits with a code your build can gate on — no daemon, no prompts, no onboarding required.
50
+
51
+ ```bash
52
+ # After deploying: confirm pods reached Ready
53
+ kubectl rollout status deployment/my-app -n my-ns --timeout=5m
54
+
55
+ # Then confirm the system is actually healthy
56
+ kubeagent check -n my-ns
57
+ ```
58
+
59
+ The two steps are complementary: `rollout status` answers *"did Kubernetes accept the deploy?"*, `kubeagent check` answers *"is the system actually healthy?"* — catching crashloops, evicted pods, and failed jobs that surface seconds after pods reach Ready.
60
+
61
+ **Exit codes:** `0` = no issues at/above threshold · `1` = issues found · `2` = kubeagent error (unreachable cluster, bad flag, timeout).
62
+
63
+ **Common flags:**
64
+ - `-n, --namespace <ns>` — scope to one or more namespaces (repeatable)
65
+ - `--fail-on <severity>` — fail-threshold: `info`, `warning`, or `critical` (default: `critical`)
66
+ - `--format json` — emit a versioned JSON report instead of human output
67
+ - `--diagnose` — run LLM root-cause analysis on findings (requires `KUBEAGENT_API_KEY` env var)
68
+
69
+ See [docs/CI.md](docs/CI.md) for full reference, GitHub Actions and GitLab CI snippets, and the JSON schema.
70
+
46
71
  ## Knowledge Base
47
72
 
48
73
  KubeAgent builds a per-cluster knowledge base at `~/.kubeagent/clusters/<context>/` during onboarding:
@@ -124,6 +149,13 @@ Safe actions (pod restarts, rollout restarts, scaling up) are applied automatica
124
149
  **Where is my API key stored?**
125
150
  Credentials are stored locally at `~/.kubeagent/auth.json` and are never sent anywhere except the KubeAgent API.
126
151
 
152
+ ## Links
153
+
154
+ - [Homepage](https://kubeagent.net)
155
+ - [Blog](https://kubeagent.net/blog/) — incident walkthroughs, agentic-diagnosis patterns, on-call economics ([RSS](https://kubeagent.net/blog/rss.xml))
156
+ - [Docs](https://kubeagent.net/docs)
157
+ - [Sign up](https://app.kubeagent.net/register)
158
+
127
159
  ## License
128
160
 
129
161
  Proprietary — see [LICENSE](./LICENSE) for details. Commercial use requires a paid plan at [kubeagent.net](https://kubeagent.net).
package/dist/auth.js CHANGED
@@ -7,7 +7,22 @@ import { dbg } from "./debug.js";
7
7
  function authPath() {
8
8
  return join(configDir(), "auth.json");
9
9
  }
10
+ const DEFAULT_SERVER_URL = "https://api.kubeagent.net";
11
+ const DEFAULT_APP_URL = "https://app.kubeagent.net";
10
12
  export function loadAuth() {
13
+ // Env-var path for CI: KUBEAGENT_API_KEY takes precedence so machines can
14
+ // run without a local auth.json. KUBEAGENT_SERVER_URL / KUBEAGENT_APP_URL
15
+ // are optional overrides for self-hosted setups.
16
+ const envKey = process.env.KUBEAGENT_API_KEY;
17
+ if (envKey) {
18
+ dbg("auth", "loaded from env", { keyPrefix: envKey.slice(0, 12) + "..." });
19
+ return {
20
+ serverUrl: process.env.KUBEAGENT_SERVER_URL ?? DEFAULT_SERVER_URL,
21
+ appUrl: process.env.KUBEAGENT_APP_URL ?? DEFAULT_APP_URL,
22
+ token: envKey,
23
+ apiKey: envKey,
24
+ };
25
+ }
11
26
  const path = authPath();
12
27
  if (!existsSync(path)) {
13
28
  dbg("auth", "no auth file found", { path });
@@ -0,0 +1,77 @@
1
+ import chalk from "chalk";
2
+ export const SEVERITY_RANK = {
3
+ info: 0,
4
+ warning: 1,
5
+ critical: 2,
6
+ };
7
+ export function isValidSeverity(s) {
8
+ return s === "info" || s === "warning" || s === "critical";
9
+ }
10
+ export function filterIssuesByNamespace(issues, namespaces) {
11
+ if (namespaces === "all" || namespaces.length === 0)
12
+ return issues;
13
+ const set = new Set(namespaces);
14
+ return issues.filter((i) => set.has(i.namespace));
15
+ }
16
+ export function summarizeIssues(issues, podCount, nodeCount) {
17
+ return {
18
+ critical: issues.filter((i) => i.severity === "critical").length,
19
+ warning: issues.filter((i) => i.severity === "warning").length,
20
+ info: issues.filter((i) => i.severity === "info").length,
21
+ podCount,
22
+ nodeCount,
23
+ };
24
+ }
25
+ export function computeExitCode(summary, failOn) {
26
+ const threshold = SEVERITY_RANK[failOn];
27
+ const triggered = [];
28
+ if (SEVERITY_RANK.critical >= threshold && summary.critical > 0)
29
+ triggered.push("critical");
30
+ if (SEVERITY_RANK.warning >= threshold && summary.warning > 0)
31
+ triggered.push("warning");
32
+ if (SEVERITY_RANK.info >= threshold && summary.info > 0)
33
+ triggered.push("info");
34
+ if (triggered.length === 0) {
35
+ return { code: 0, reason: `no issues at or above fail-on=${failOn}` };
36
+ }
37
+ return { code: 1, reason: `fail-on=${failOn} threshold met (${triggered.join(", ")})` };
38
+ }
39
+ export function renderJson(report) {
40
+ return JSON.stringify({
41
+ ...report,
42
+ issues: report.issues.map((i) => ({
43
+ kind: i.kind,
44
+ severity: i.severity,
45
+ namespace: i.namespace,
46
+ resource: i.resource,
47
+ message: i.message,
48
+ details: i.details,
49
+ })),
50
+ }, null, 2);
51
+ }
52
+ export function renderHuman(report) {
53
+ const lines = [];
54
+ const nsLabel = report.namespaces === "all" ? "all" : report.namespaces.join(", ");
55
+ lines.push(`KubeAgent check — context: ${chalk.cyan(report.context)} namespace: ${chalk.cyan(nsLabel)}`);
56
+ const { summary, durationMs } = report;
57
+ const headlineIcon = summary.critical > 0 ? chalk.red("✖") : summary.warning > 0 ? chalk.yellow("⚠") : chalk.green("✔");
58
+ const headlineMsg = summary.critical + summary.warning + summary.info === 0
59
+ ? "all clear"
60
+ : `${summary.critical} critical, ${summary.warning} warnings, ${summary.info} info`;
61
+ lines.push(`${headlineIcon} ${headlineMsg} ${chalk.dim(`(scanned ${summary.podCount} pods, ${summary.nodeCount} nodes in ${(durationMs / 1000).toFixed(1)}s)`)}`);
62
+ if (report.issues.length > 0) {
63
+ lines.push("");
64
+ for (const issue of report.issues) {
65
+ const sevColor = issue.severity === "critical" ? chalk.red : issue.severity === "warning" ? chalk.yellow : chalk.dim;
66
+ lines.push(` ${sevColor(issue.severity.padEnd(8))} ${issue.namespace} ${chalk.dim(issue.kind.padEnd(16))} ${issue.resource} ${issue.message}`);
67
+ }
68
+ }
69
+ if (report.diagnosis) {
70
+ lines.push("");
71
+ lines.push(chalk.bold("Diagnosis:"));
72
+ lines.push(report.diagnosis.analysis);
73
+ }
74
+ lines.push("");
75
+ lines.push(chalk.dim(`Exit: ${report.exit.code} (${report.exit.reason})`));
76
+ return lines.join("\n");
77
+ }
@@ -0,0 +1,114 @@
1
+ import chalk from "chalk";
2
+ import { kubectl } from "../kubectl.js";
3
+ import { runChecks } from "../monitor/index.js";
4
+ import { computeExitCode, isValidSeverity, renderHuman, renderJson, summarizeIssues, } from "./index.js";
5
+ class CheckError extends Error {
6
+ hint;
7
+ constructor(message, hint) {
8
+ super(message);
9
+ this.hint = hint;
10
+ }
11
+ }
12
+ function emitError(message, hint) {
13
+ process.stderr.write(chalk.red(`✖ ${message}\n`));
14
+ if (hint)
15
+ process.stderr.write(chalk.dim(` ${hint}\n`));
16
+ process.exit(2);
17
+ }
18
+ export async function runCheck(opts) {
19
+ // --- flag validation ---
20
+ if (!isValidSeverity(opts.failOn)) {
21
+ emitError(`invalid --fail-on value "${opts.failOn}"`, "expected one of: info, warning, critical");
22
+ }
23
+ if (opts.format !== "human" && opts.format !== "json") {
24
+ emitError(`invalid --format value "${opts.format}"`, "expected one of: human, json");
25
+ }
26
+ const timeoutSec = Number.parseInt(opts.timeout, 10);
27
+ if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
28
+ emitError(`invalid --timeout value "${opts.timeout}"`, "expected a positive integer (seconds)");
29
+ }
30
+ if (opts.diagnose && !process.env.KUBEAGENT_API_KEY) {
31
+ emitError("--diagnose requires KUBEAGENT_API_KEY env var", "set it as a CI secret, e.g. in GitHub Actions: env: { KUBEAGENT_API_KEY: ${{ secrets.KUBEAGENT_API_KEY }} }");
32
+ }
33
+ const failOn = opts.failOn;
34
+ const namespaces = opts.allNamespaces || !opts.namespace || opts.namespace.length === 0
35
+ ? "all"
36
+ : opts.namespace;
37
+ // --- run with timeout wrapper ---
38
+ const startedAt = Date.now();
39
+ let timeoutHandle;
40
+ const timeoutPromise = new Promise((_, reject) => {
41
+ timeoutHandle = setTimeout(() => reject(new CheckError(`check timed out after ${timeoutSec}s`)), timeoutSec * 1000);
42
+ });
43
+ try {
44
+ const report = await Promise.race([
45
+ doCheck({ failOn, namespaces, format: opts.format, context: opts.context, diagnose: !!opts.diagnose, startedAt }),
46
+ timeoutPromise,
47
+ ]);
48
+ if (timeoutHandle)
49
+ clearTimeout(timeoutHandle);
50
+ const output = opts.format === "json" ? renderJson(report) : renderHuman(report);
51
+ process.stdout.write(output + "\n");
52
+ process.exit(report.exit.code);
53
+ }
54
+ catch (err) {
55
+ if (timeoutHandle)
56
+ clearTimeout(timeoutHandle);
57
+ const message = err.message || "check failed";
58
+ if (err instanceof CheckError) {
59
+ emitError(message, err.hint);
60
+ }
61
+ emitError(message);
62
+ }
63
+ }
64
+ async function doCheck(args) {
65
+ const { failOn, namespaces, context, diagnose: shouldDiagnose, startedAt } = args;
66
+ const kubectlOpts = context ? { context } : {};
67
+ // Preflight: confirm the cluster is reachable so we fail fast with exit 2
68
+ // rather than a confusing kubectl error mid-scan.
69
+ try {
70
+ await kubectl(["cluster-info", "--request-timeout=5s"], kubectlOpts);
71
+ }
72
+ catch (err) {
73
+ throw new CheckError(`cannot reach cluster: ${err.message}`, "check your kubeconfig and --context flag");
74
+ }
75
+ // Resolve effective context name for the report (kubectl current-context
76
+ // is fine when --context not passed).
77
+ let effectiveContext = context ?? "(current-context)";
78
+ if (!context) {
79
+ try {
80
+ effectiveContext = (await kubectl(["config", "current-context"])).trim();
81
+ }
82
+ catch {
83
+ // non-fatal — keep the placeholder
84
+ }
85
+ }
86
+ // Scope kubectl queries to the requested namespaces so namespace-bound
87
+ // CI service accounts don't need cluster-wide list permissions.
88
+ const scope = namespaces === "all" ? undefined : { namespaces };
89
+ const summaryResult = await runChecks(kubectlOpts, true, scope);
90
+ const filteredIssues = summaryResult.issues;
91
+ const summary = summarizeIssues(filteredIssues, summaryResult.podCount, summaryResult.nodeCount);
92
+ let diagnosis = null;
93
+ if (shouldDiagnose && filteredIssues.length > 0) {
94
+ const { diagnose } = await import("../diagnoser/index.js");
95
+ const result = await diagnose(filteredIssues, "", context, {
96
+ autoFix: false,
97
+ noInteractive: true,
98
+ onApproval: async () => false,
99
+ });
100
+ diagnosis = { analysis: result.analysis };
101
+ }
102
+ const exit = computeExitCode(summary, failOn);
103
+ return {
104
+ version: 1,
105
+ context: effectiveContext,
106
+ namespaces,
107
+ scannedAt: new Date(startedAt).toISOString(),
108
+ durationMs: Date.now() - startedAt,
109
+ summary,
110
+ issues: filteredIssues,
111
+ diagnosis,
112
+ exit,
113
+ };
114
+ }
package/dist/cli.js CHANGED
@@ -170,6 +170,20 @@ program
170
170
  // Keep process alive
171
171
  await new Promise(() => { });
172
172
  });
173
+ program
174
+ .command("check")
175
+ .description("One-shot cluster health check (CI-friendly, read-only by default)")
176
+ .option("-c, --context <context>", "Kubernetes context (defaults to kubeconfig current-context)")
177
+ .option("-n, --namespace <namespace>", "Limit scope to namespace (repeatable)", (val, prev) => [...(prev ?? []), val], undefined)
178
+ .option("--all-namespaces", "Scan all namespaces (default; overrides -n if both set)")
179
+ .option("--fail-on <severity>", "Exit non-zero on issues >= severity (info|warning|critical)", "critical")
180
+ .option("--format <fmt>", "Output format: human|json", "human")
181
+ .option("--diagnose", "Run LLM analysis on findings (requires KUBEAGENT_API_KEY)")
182
+ .option("--timeout <seconds>", "Max time for the check itself", "60")
183
+ .action(async (opts) => {
184
+ const { runCheck } = await import("./check/run.js");
185
+ await runCheck(opts);
186
+ });
173
187
  program
174
188
  .command("diagnose <resource>")
175
189
  .description("One-shot diagnosis of a specific pod/deployment/service")
@@ -338,12 +352,21 @@ program
338
352
  .action(async (opts) => {
339
353
  const { detectApplications, formatApplicationsMarkdown } = await import("./detector/index.js");
340
354
  const { writeApplicationsKb, ensureKbDir } = await import("./kb/writer.js");
341
- const contextName = opts.context ?? "default";
355
+ const { kubectl } = await import("./kubectl.js");
356
+ let contextName = opts.context;
357
+ if (!contextName) {
358
+ try {
359
+ contextName = (await kubectl(["config", "current-context"])).trim();
360
+ }
361
+ catch {
362
+ contextName = "default";
363
+ }
364
+ }
342
365
  const spinner = ora("Scanning cluster for OSS applications...").start();
343
366
  let result;
344
367
  try {
345
368
  result = await detectApplications({
346
- context: opts.context,
369
+ context: contextName,
347
370
  noCache: opts.cache === false,
348
371
  onProgress: (step) => {
349
372
  spinner.text = `Scanning cluster for OSS applications — ${step}...`;
@@ -51,7 +51,7 @@ export async function fetchWorkloadCandidates(options) {
51
51
  all.push(...extractFromWorkload("DaemonSet", w));
52
52
  return all;
53
53
  }
54
- function decodeHelmRelease(b64) {
54
+ export function decodeHelmRelease(b64) {
55
55
  try {
56
56
  // Helm v3 stores releases as base64(gzip(json)). When fetched via
57
57
  // `kubectl get secrets -o json`, the API server applies its own base64
@@ -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 });
@@ -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.
@@ -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,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
+ }