kubeagent 0.1.35 → 0.1.36
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 +2 -0
- package/dist/cli.js +108 -0
- package/dist/detector/cache.d.ts +6 -0
- package/dist/detector/cache.js +44 -0
- package/dist/detector/cache.test.d.ts +1 -0
- package/dist/detector/cache.test.js +54 -0
- package/dist/detector/catalog.d.ts +17 -0
- package/dist/detector/catalog.js +88 -0
- package/dist/detector/catalog.test.d.ts +1 -0
- package/dist/detector/catalog.test.js +33 -0
- package/dist/detector/eol.d.ts +13 -0
- package/dist/detector/eol.js +64 -0
- package/dist/detector/eol.test.d.ts +1 -0
- package/dist/detector/eol.test.js +78 -0
- package/dist/detector/image-parser.d.ts +11 -0
- package/dist/detector/image-parser.js +59 -0
- package/dist/detector/image-parser.test.d.ts +1 -0
- package/dist/detector/image-parser.test.js +87 -0
- package/dist/detector/index.d.ts +33 -0
- package/dist/detector/index.js +217 -0
- package/dist/detector/index.test.d.ts +1 -0
- package/dist/detector/index.test.js +210 -0
- package/dist/detector/osv.d.ts +12 -0
- package/dist/detector/osv.js +50 -0
- package/dist/detector/osv.test.d.ts +1 -0
- package/dist/detector/osv.test.js +86 -0
- package/dist/detector/report.d.ts +6 -0
- package/dist/detector/report.js +91 -0
- package/dist/detector/sources.d.ts +42 -0
- package/dist/detector/sources.js +218 -0
- package/dist/diagnoser/tools.d.ts +2 -2
- package/dist/kb/loader.js +6 -0
- package/dist/kb/writer.d.ts +1 -0
- package/dist/kb/writer.js +4 -0
- package/dist/onboard/index.js +23 -1
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
vi.mock("./cache.js", () => ({
|
|
3
|
+
readCache: vi.fn(),
|
|
4
|
+
readStaleCache: vi.fn(),
|
|
5
|
+
writeCache: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
import { readCache, readStaleCache, writeCache } from "./cache.js";
|
|
8
|
+
import { queryOsv } from "./osv.js";
|
|
9
|
+
const mockFetch = vi.fn();
|
|
10
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
11
|
+
const cacheRead = vi.mocked(readCache);
|
|
12
|
+
const cacheStale = vi.mocked(readStaleCache);
|
|
13
|
+
const cacheWrite = vi.mocked(writeCache);
|
|
14
|
+
describe("queryOsv", () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockFetch.mockReset();
|
|
17
|
+
cacheRead.mockReset();
|
|
18
|
+
cacheStale.mockReset();
|
|
19
|
+
cacheWrite.mockReset();
|
|
20
|
+
cacheRead.mockReturnValue(null);
|
|
21
|
+
cacheStale.mockReturnValue(null);
|
|
22
|
+
});
|
|
23
|
+
it("posts the package + version to api.osv.dev and returns mapped findings", async () => {
|
|
24
|
+
mockFetch.mockResolvedValue({
|
|
25
|
+
ok: true,
|
|
26
|
+
json: async () => ({
|
|
27
|
+
vulns: [
|
|
28
|
+
{
|
|
29
|
+
id: "CVE-2024-1",
|
|
30
|
+
summary: "Test vuln",
|
|
31
|
+
database_specific: { severity: "high" },
|
|
32
|
+
affected: [{ ranges: [{ events: [{ introduced: "0" }, { fixed: "7.0.12" }] }] }],
|
|
33
|
+
references: [{ type: "ADVISORY", url: "https://example.com/advisory" }],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
const findings = await queryOsv({ name: "redis" }, "7.0.5", { noCache: true });
|
|
39
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
40
|
+
expect(mockFetch.mock.calls[0][0]).toBe("https://api.osv.dev/v1/query");
|
|
41
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
42
|
+
expect(body).toEqual({ package: { name: "redis" }, version: "7.0.5" });
|
|
43
|
+
expect(findings).toHaveLength(1);
|
|
44
|
+
expect(findings[0]).toMatchObject({
|
|
45
|
+
id: "CVE-2024-1",
|
|
46
|
+
severity: "high",
|
|
47
|
+
fixed: "7.0.12",
|
|
48
|
+
reference: "https://example.com/advisory",
|
|
49
|
+
});
|
|
50
|
+
expect(cacheWrite).toHaveBeenCalledOnce();
|
|
51
|
+
});
|
|
52
|
+
it("includes ecosystem in the OSV body when provided", async () => {
|
|
53
|
+
mockFetch.mockResolvedValue({ ok: true, json: async () => ({}) });
|
|
54
|
+
await queryOsv({ name: "postgresql", ecosystem: "Debian" }, "15.2", { noCache: true });
|
|
55
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
56
|
+
expect(body.package).toEqual({ name: "postgresql", ecosystem: "Debian" });
|
|
57
|
+
});
|
|
58
|
+
it("returns empty when version is empty", async () => {
|
|
59
|
+
const findings = await queryOsv({ name: "redis" }, "", { noCache: true });
|
|
60
|
+
expect(findings).toEqual([]);
|
|
61
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
it("returns [] on network error when no stale cache is present", async () => {
|
|
64
|
+
mockFetch.mockRejectedValue(new Error("network down"));
|
|
65
|
+
const findings = await queryOsv({ name: "redis" }, "7.0.99", { noCache: true });
|
|
66
|
+
expect(findings).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
it("serves a fresh cached entry without hitting the network", async () => {
|
|
69
|
+
cacheRead.mockReturnValue([{ id: "CVE-cached", summary: "from cache" }]);
|
|
70
|
+
const findings = await queryOsv({ name: "redis" }, "7.0.5");
|
|
71
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
72
|
+
expect(findings[0].id).toBe("CVE-cached");
|
|
73
|
+
});
|
|
74
|
+
it("falls back to stale cache on network error", async () => {
|
|
75
|
+
cacheStale.mockReturnValue({ data: [{ id: "CVE-stale", summary: "from cache" }], fetchedAt: 1 });
|
|
76
|
+
mockFetch.mockRejectedValue(new Error("offline"));
|
|
77
|
+
const findings = await queryOsv({ name: "redis" }, "7.0.5", { noCache: true });
|
|
78
|
+
expect(findings[0].id).toBe("CVE-stale");
|
|
79
|
+
});
|
|
80
|
+
it("falls back to stale cache on non-2xx response", async () => {
|
|
81
|
+
cacheStale.mockReturnValue({ data: [{ id: "CVE-stale", summary: "x" }], fetchedAt: 1 });
|
|
82
|
+
mockFetch.mockResolvedValue({ ok: false, status: 500 });
|
|
83
|
+
const findings = await queryOsv({ name: "redis" }, "7.0.5", { noCache: true });
|
|
84
|
+
expect(findings[0].id).toBe("CVE-stale");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AuthState } from "../auth.js";
|
|
2
|
+
import type { DetectionResult } from "./index.js";
|
|
3
|
+
export interface ReportOptions {
|
|
4
|
+
onProgress?: (step: string) => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function generateReport(result: DetectionResult, auth: AuthState, opts?: ReportOptions): Promise<string>;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { proxyRequest } from "../proxy-client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Trim the detection result to a compact JSON payload the model can reason
|
|
4
|
+
* over without blowing the context window. We cap CVEs per app and drop
|
|
5
|
+
* fields the model doesn't need (catalogMatch, sources, etc.).
|
|
6
|
+
*/
|
|
7
|
+
function buildPayload(result) {
|
|
8
|
+
const slimWorkload = (w) => ({
|
|
9
|
+
kind: w.kind,
|
|
10
|
+
namespace: w.namespace,
|
|
11
|
+
name: w.name,
|
|
12
|
+
...(w.hosts && w.hosts.length > 0 ? { hosts: w.hosts } : {}),
|
|
13
|
+
});
|
|
14
|
+
const slimApp = (a) => ({
|
|
15
|
+
name: a.display,
|
|
16
|
+
version: a.version,
|
|
17
|
+
eol: a.eol?.eol
|
|
18
|
+
? {
|
|
19
|
+
eolDate: a.eol.eolDate,
|
|
20
|
+
latestInCycle: a.eol.latest,
|
|
21
|
+
}
|
|
22
|
+
: null,
|
|
23
|
+
cveCount: a.cves.length,
|
|
24
|
+
cves: a.cves.slice(0, 10).map((c) => ({
|
|
25
|
+
id: c.id,
|
|
26
|
+
severity: c.severity ?? null,
|
|
27
|
+
fixed: c.fixed ?? null,
|
|
28
|
+
summary: c.summary ? c.summary.slice(0, 200) : null,
|
|
29
|
+
})),
|
|
30
|
+
workloads: a.workloads.map(slimWorkload),
|
|
31
|
+
publicFacing: a.workloads.some((w) => w.hosts && w.hosts.length > 0),
|
|
32
|
+
});
|
|
33
|
+
return {
|
|
34
|
+
apps: result.apps.map(slimApp),
|
|
35
|
+
unknownCount: result.unknowns.length,
|
|
36
|
+
scanned: result.scanned,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const INSTRUCTIONS = `You are producing a remediation report for a Kubernetes cluster scan.
|
|
40
|
+
|
|
41
|
+
STRICT RULES — these are absolute, no exceptions:
|
|
42
|
+
1. ONLY use facts from the JSON payload below. Do NOT invent CVE IDs, version numbers, EOL dates, or workload names.
|
|
43
|
+
2. If a "latestInCycle" version is given, you may recommend upgrading to it. Do NOT recommend versions not present in the payload unless you explicitly mark them as "(verify upstream)".
|
|
44
|
+
3. Do not speculate about CVE exploitability beyond what the summary says.
|
|
45
|
+
4. Public-facing = workload has \`hosts\` field populated. Internal-only = no hosts.
|
|
46
|
+
|
|
47
|
+
Produce a Markdown report with these sections:
|
|
48
|
+
|
|
49
|
+
## Executive Summary
|
|
50
|
+
2-3 sentences. Total findings, how many are critical (EOL or public-facing with CVEs), the single biggest risk.
|
|
51
|
+
|
|
52
|
+
## Prioritized Fix List
|
|
53
|
+
A numbered list, highest-risk first. For each item:
|
|
54
|
+
- **App + version** (workloads affected)
|
|
55
|
+
- **Why now** (1 sentence: EOL? public-facing? high CVE count?)
|
|
56
|
+
- **Suggested next step** (which version to move to, or "investigate" if unclear)
|
|
57
|
+
|
|
58
|
+
Prioritization order:
|
|
59
|
+
1. EOL AND public-facing (has hosts)
|
|
60
|
+
2. EOL internal-only
|
|
61
|
+
3. Many CVEs AND public-facing
|
|
62
|
+
4. Many CVEs internal-only
|
|
63
|
+
5. Healthy
|
|
64
|
+
|
|
65
|
+
## Notes
|
|
66
|
+
Anything notable: duplicated infra (e.g. 5 Redis instances on same EOL version), version sprawl (same app at 3 different versions), missing-info gaps.
|
|
67
|
+
|
|
68
|
+
Be concise. No padding. No emojis. No fabricated specifics.`;
|
|
69
|
+
export async function generateReport(result, auth, opts = {}) {
|
|
70
|
+
opts.onProgress?.("preparing payload");
|
|
71
|
+
const payload = buildPayload(result);
|
|
72
|
+
const userMessage = `${INSTRUCTIONS}
|
|
73
|
+
|
|
74
|
+
\`\`\`json
|
|
75
|
+
${JSON.stringify(payload, null, 2)}
|
|
76
|
+
\`\`\``;
|
|
77
|
+
opts.onProgress?.("calling model");
|
|
78
|
+
const response = await proxyRequest(auth, {
|
|
79
|
+
max_tokens: 8000,
|
|
80
|
+
messages: [{ role: "user", content: userMessage }],
|
|
81
|
+
});
|
|
82
|
+
const text = response.content
|
|
83
|
+
.filter((b) => b.type === "text")
|
|
84
|
+
.map((b) => b.text)
|
|
85
|
+
.join("\n")
|
|
86
|
+
.trim();
|
|
87
|
+
if (!text) {
|
|
88
|
+
throw new Error("Model returned no text content");
|
|
89
|
+
}
|
|
90
|
+
return text;
|
|
91
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type KubectlOptions } from "../kubectl.js";
|
|
2
|
+
export type WorkloadKind = "Deployment" | "StatefulSet" | "DaemonSet";
|
|
3
|
+
export interface WorkloadRef {
|
|
4
|
+
kind: WorkloadKind;
|
|
5
|
+
namespace: string;
|
|
6
|
+
name: string;
|
|
7
|
+
hosts?: string[];
|
|
8
|
+
}
|
|
9
|
+
export declare function workloadKey(ref: {
|
|
10
|
+
kind: string;
|
|
11
|
+
namespace: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}): string;
|
|
14
|
+
export interface ImageCandidate {
|
|
15
|
+
source: "image";
|
|
16
|
+
image: string;
|
|
17
|
+
workload: WorkloadRef;
|
|
18
|
+
container: string;
|
|
19
|
+
}
|
|
20
|
+
export interface LabelCandidate {
|
|
21
|
+
source: "label";
|
|
22
|
+
appName: string;
|
|
23
|
+
appVersion: string;
|
|
24
|
+
workload: WorkloadRef;
|
|
25
|
+
}
|
|
26
|
+
export interface HelmCandidate {
|
|
27
|
+
source: "helm";
|
|
28
|
+
chart: string;
|
|
29
|
+
chartVersion: string;
|
|
30
|
+
appVersion?: string;
|
|
31
|
+
namespace: string;
|
|
32
|
+
releaseName: string;
|
|
33
|
+
}
|
|
34
|
+
export type Candidate = ImageCandidate | LabelCandidate | HelmCandidate;
|
|
35
|
+
export declare function fetchWorkloadCandidates(options: KubectlOptions): Promise<Candidate[]>;
|
|
36
|
+
export declare function fetchHelmCandidates(options: KubectlOptions): Promise<HelmCandidate[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Build a map of workload key (Kind/namespace/name) → ingress hostnames.
|
|
39
|
+
* Joins Ingresses → Services → workload pod-template labels best-effort.
|
|
40
|
+
* Returns an empty map on failure (ingress visibility is non-essential).
|
|
41
|
+
*/
|
|
42
|
+
export declare function fetchIngressHostMap(options: KubectlOptions): Promise<Map<string, string[]>>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { kubectlJson } from "../kubectl.js";
|
|
4
|
+
import { dbg } from "../debug.js";
|
|
5
|
+
export function workloadKey(ref) {
|
|
6
|
+
return `${ref.kind}/${ref.namespace}/${ref.name}`;
|
|
7
|
+
}
|
|
8
|
+
const LABEL_NAME = "app.kubernetes.io/name";
|
|
9
|
+
const LABEL_VERSION = "app.kubernetes.io/version";
|
|
10
|
+
async function fetchWorkload(kind, resource, options) {
|
|
11
|
+
return (await kubectlJson(["get", resource, "--all-namespaces"], options));
|
|
12
|
+
}
|
|
13
|
+
function extractFromWorkload(kind, w) {
|
|
14
|
+
const out = [];
|
|
15
|
+
const ref = { kind, namespace: w.metadata.namespace, name: w.metadata.name };
|
|
16
|
+
const containers = [
|
|
17
|
+
...(w.spec?.template?.spec?.containers ?? []),
|
|
18
|
+
...(w.spec?.template?.spec?.initContainers ?? []),
|
|
19
|
+
];
|
|
20
|
+
for (const c of containers) {
|
|
21
|
+
if (c.image) {
|
|
22
|
+
out.push({ source: "image", image: c.image, workload: ref, container: c.name });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const labels = {
|
|
26
|
+
...(w.metadata.labels ?? {}),
|
|
27
|
+
...(w.spec?.template?.metadata?.labels ?? {}),
|
|
28
|
+
};
|
|
29
|
+
if (labels[LABEL_NAME] && labels[LABEL_VERSION]) {
|
|
30
|
+
out.push({
|
|
31
|
+
source: "label",
|
|
32
|
+
appName: labels[LABEL_NAME],
|
|
33
|
+
appVersion: labels[LABEL_VERSION],
|
|
34
|
+
workload: ref,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export async function fetchWorkloadCandidates(options) {
|
|
40
|
+
const [deploys, statefulsets, daemonsets] = await Promise.all([
|
|
41
|
+
fetchWorkload("Deployment", "deployments", options),
|
|
42
|
+
fetchWorkload("StatefulSet", "statefulsets", options),
|
|
43
|
+
fetchWorkload("DaemonSet", "daemonsets", options),
|
|
44
|
+
]);
|
|
45
|
+
const all = [];
|
|
46
|
+
for (const w of deploys.items)
|
|
47
|
+
all.push(...extractFromWorkload("Deployment", w));
|
|
48
|
+
for (const w of statefulsets.items)
|
|
49
|
+
all.push(...extractFromWorkload("StatefulSet", w));
|
|
50
|
+
for (const w of daemonsets.items)
|
|
51
|
+
all.push(...extractFromWorkload("DaemonSet", w));
|
|
52
|
+
return all;
|
|
53
|
+
}
|
|
54
|
+
function decodeHelmRelease(b64) {
|
|
55
|
+
try {
|
|
56
|
+
// Helm v3 stores releases as base64(gzip(json)). When fetched via
|
|
57
|
+
// `kubectl get secrets -o json`, the API server applies its own base64
|
|
58
|
+
// layer on top of `data.release`, so we decode twice before gunzip.
|
|
59
|
+
const outer = Buffer.from(b64, "base64");
|
|
60
|
+
const inner = Buffer.from(outer.toString("utf-8"), "base64");
|
|
61
|
+
const json = gunzipSync(inner).toString("utf-8");
|
|
62
|
+
return JSON.parse(json);
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
dbg("detector", `helm release decode failed: ${err.message}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function fetchHelmCandidates(options) {
|
|
70
|
+
let list;
|
|
71
|
+
try {
|
|
72
|
+
list = (await kubectlJson(["get", "secrets", "--all-namespaces", "-l", "owner=helm,status=deployed"], options));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
dbg("detector", `helm secret listing failed: ${err.message}`);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const s of list.items) {
|
|
81
|
+
const release = s.data?.release;
|
|
82
|
+
if (!release)
|
|
83
|
+
continue;
|
|
84
|
+
const payload = decodeHelmRelease(release);
|
|
85
|
+
if (!payload)
|
|
86
|
+
continue;
|
|
87
|
+
const chart = payload.chart?.metadata?.name;
|
|
88
|
+
const version = payload.chart?.metadata?.version;
|
|
89
|
+
if (!chart || !version)
|
|
90
|
+
continue;
|
|
91
|
+
const key = `${s.metadata.namespace}/${payload.name}/${version}`;
|
|
92
|
+
if (seen.has(key))
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(key);
|
|
95
|
+
out.push({
|
|
96
|
+
source: "helm",
|
|
97
|
+
chart,
|
|
98
|
+
chartVersion: version,
|
|
99
|
+
appVersion: payload.chart?.metadata?.appVersion,
|
|
100
|
+
namespace: s.metadata.namespace,
|
|
101
|
+
releaseName: payload.name,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function selectorMatches(selector, labels) {
|
|
107
|
+
const keys = Object.keys(selector);
|
|
108
|
+
if (keys.length === 0)
|
|
109
|
+
return false;
|
|
110
|
+
for (const k of keys) {
|
|
111
|
+
if (labels[k] !== selector[k])
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build a map of workload key (Kind/namespace/name) → ingress hostnames.
|
|
118
|
+
* Joins Ingresses → Services → workload pod-template labels best-effort.
|
|
119
|
+
* Returns an empty map on failure (ingress visibility is non-essential).
|
|
120
|
+
*/
|
|
121
|
+
export async function fetchIngressHostMap(options) {
|
|
122
|
+
const out = new Map();
|
|
123
|
+
let services;
|
|
124
|
+
let ingresses;
|
|
125
|
+
let workloadLists;
|
|
126
|
+
try {
|
|
127
|
+
[services, ingresses, ...workloadLists] = await Promise.all([
|
|
128
|
+
kubectlJson(["get", "services", "--all-namespaces"], options),
|
|
129
|
+
kubectlJson(["get", "ingresses", "--all-namespaces"], options),
|
|
130
|
+
fetchWorkload("Deployment", "deployments", options),
|
|
131
|
+
fetchWorkload("StatefulSet", "statefulsets", options),
|
|
132
|
+
fetchWorkload("DaemonSet", "daemonsets", options),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
dbg("detector", `ingress map fetch failed: ${err.message}`);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// service ns/name → selector
|
|
140
|
+
const serviceSelectors = new Map();
|
|
141
|
+
for (const svc of services.items) {
|
|
142
|
+
const sel = svc.spec?.selector;
|
|
143
|
+
if (!sel || Object.keys(sel).length === 0)
|
|
144
|
+
continue;
|
|
145
|
+
serviceSelectors.set(`${svc.metadata.namespace}/${svc.metadata.name}`, sel);
|
|
146
|
+
}
|
|
147
|
+
// service ns/name → set of hosts
|
|
148
|
+
const serviceHosts = new Map();
|
|
149
|
+
const addServiceHost = (ns, svc, host) => {
|
|
150
|
+
if (!svc || !host)
|
|
151
|
+
return;
|
|
152
|
+
const key = `${ns}/${svc}`;
|
|
153
|
+
let s = serviceHosts.get(key);
|
|
154
|
+
if (!s) {
|
|
155
|
+
s = new Set();
|
|
156
|
+
serviceHosts.set(key, s);
|
|
157
|
+
}
|
|
158
|
+
s.add(host);
|
|
159
|
+
};
|
|
160
|
+
for (const ing of ingresses.items) {
|
|
161
|
+
const ns = ing.metadata.namespace;
|
|
162
|
+
const tlsHosts = new Set();
|
|
163
|
+
for (const t of ing.spec?.tls ?? []) {
|
|
164
|
+
for (const h of t.hosts ?? [])
|
|
165
|
+
tlsHosts.add(h);
|
|
166
|
+
}
|
|
167
|
+
for (const rule of ing.spec?.rules ?? []) {
|
|
168
|
+
// A rule with no `host` matches all inbound hosts per the Ingress
|
|
169
|
+
// spec — the backend is still publicly reachable, so record it as `*`
|
|
170
|
+
// rather than dropping it (which would mis-classify as internal-only).
|
|
171
|
+
const host = rule.host ?? "*";
|
|
172
|
+
for (const p of rule.http?.paths ?? []) {
|
|
173
|
+
const svc = p.backend?.service?.name ?? p.backend?.serviceName;
|
|
174
|
+
addServiceHost(ns, svc, host);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const defSvc = ing.spec?.defaultBackend?.service?.name ?? ing.spec?.defaultBackend?.serviceName;
|
|
178
|
+
if (defSvc) {
|
|
179
|
+
// defaultBackend catches anything not matched by a rule — also wildcard
|
|
180
|
+
// exposure. Prefer explicit TLS hosts when present for better display.
|
|
181
|
+
if (tlsHosts.size > 0) {
|
|
182
|
+
for (const h of tlsHosts)
|
|
183
|
+
addServiceHost(ns, defSvc, h);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
addServiceHost(ns, defSvc, "*");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const allWorkloads = [];
|
|
191
|
+
const kinds = ["Deployment", "StatefulSet", "DaemonSet"];
|
|
192
|
+
workloadLists.forEach((list, idx) => {
|
|
193
|
+
for (const item of list.items)
|
|
194
|
+
allWorkloads.push({ kind: kinds[idx], item });
|
|
195
|
+
});
|
|
196
|
+
for (const { kind, item } of allWorkloads) {
|
|
197
|
+
const ns = item.metadata.namespace;
|
|
198
|
+
const podLabels = item.spec?.template?.metadata?.labels ?? {};
|
|
199
|
+
if (Object.keys(podLabels).length === 0)
|
|
200
|
+
continue;
|
|
201
|
+
const hosts = new Set();
|
|
202
|
+
for (const [svcKey, sel] of serviceSelectors) {
|
|
203
|
+
if (!svcKey.startsWith(`${ns}/`))
|
|
204
|
+
continue;
|
|
205
|
+
if (!selectorMatches(sel, podLabels))
|
|
206
|
+
continue;
|
|
207
|
+
const hs = serviceHosts.get(svcKey);
|
|
208
|
+
if (!hs)
|
|
209
|
+
continue;
|
|
210
|
+
for (const h of hs)
|
|
211
|
+
hosts.add(h);
|
|
212
|
+
}
|
|
213
|
+
if (hosts.size > 0) {
|
|
214
|
+
out.set(workloadKey({ kind, namespace: ns, name: item.metadata.name }), [...hosts].sort());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
@@ -25,11 +25,11 @@ export declare const kubectlDescribeSchema: z.ZodObject<{
|
|
|
25
25
|
}, "strip", z.ZodTypeAny, {
|
|
26
26
|
name: string;
|
|
27
27
|
namespace: string;
|
|
28
|
-
resource_type: "
|
|
28
|
+
resource_type: "service" | "node" | "pod" | "deployment" | "statefulset" | "daemonset" | "replicaset" | "job" | "cronjob" | "ingress" | "persistentvolumeclaim" | "configmap" | "endpoints";
|
|
29
29
|
}, {
|
|
30
30
|
name: string;
|
|
31
31
|
namespace: string;
|
|
32
|
-
resource_type: "
|
|
32
|
+
resource_type: "service" | "node" | "pod" | "deployment" | "statefulset" | "daemonset" | "replicaset" | "job" | "cronjob" | "ingress" | "persistentvolumeclaim" | "configmap" | "endpoints";
|
|
33
33
|
}>;
|
|
34
34
|
export declare const restartPodSchema: z.ZodObject<{
|
|
35
35
|
namespace: z.ZodString;
|
package/dist/kb/loader.js
CHANGED
|
@@ -13,6 +13,12 @@ export function buildSystemPrompt(kbDir) {
|
|
|
13
13
|
sections.push(readFileSync(clusterPath, "utf-8"));
|
|
14
14
|
sections.push("");
|
|
15
15
|
}
|
|
16
|
+
// Load applications.md (OSS app inventory with CVE/EOL status)
|
|
17
|
+
const applicationsPath = join(kbDir, "applications.md");
|
|
18
|
+
if (existsSync(applicationsPath)) {
|
|
19
|
+
sections.push(readFileSync(applicationsPath, "utf-8"));
|
|
20
|
+
sections.push("");
|
|
21
|
+
}
|
|
16
22
|
// Load project files
|
|
17
23
|
const projectsDir = join(kbDir, "projects");
|
|
18
24
|
if (existsSync(projectsDir)) {
|
package/dist/kb/writer.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export declare function ensureKbDir(kbDir: string): void;
|
|
2
2
|
export declare function writeClusterKb(kbDir: string, content: string): void;
|
|
3
|
+
export declare function writeApplicationsKb(kbDir: string, content: string): void;
|
|
3
4
|
export declare function writeProjectKb(kbDir: string, projectName: string, content: string): void;
|
|
4
5
|
export declare function writeRunbook(kbDir: string, name: string, content: string): void;
|
|
5
6
|
/**
|
package/dist/kb/writer.js
CHANGED
|
@@ -12,6 +12,10 @@ export function writeClusterKb(kbDir, content) {
|
|
|
12
12
|
ensureKbDir(kbDir);
|
|
13
13
|
writeFileSync(join(kbDir, "cluster.md"), content);
|
|
14
14
|
}
|
|
15
|
+
export function writeApplicationsKb(kbDir, content) {
|
|
16
|
+
ensureKbDir(kbDir);
|
|
17
|
+
writeFileSync(join(kbDir, "applications.md"), content);
|
|
18
|
+
}
|
|
15
19
|
export function writeProjectKb(kbDir, projectName, content) {
|
|
16
20
|
ensureKbDir(kbDir);
|
|
17
21
|
writeFileSync(join(kbDir, "projects", `${projectName}.md`), content);
|
package/dist/onboard/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import { scanCluster, formatClusterMarkdown } from "./cluster-scan.js";
|
|
|
4
4
|
import { detectTechStack, formatProjectMarkdown } from "./code-scan.js";
|
|
5
5
|
import { scanProjectDirectory, matchProjectsToWorkloads, bestMatches } from "./project-matcher.js";
|
|
6
6
|
import { runInterview } from "./interview.js";
|
|
7
|
-
import { writeClusterKb, writeProjectKb, ensureKbDir } from "../kb/writer.js";
|
|
7
|
+
import { writeClusterKb, writeProjectKb, writeApplicationsKb, ensureKbDir } from "../kb/writer.js";
|
|
8
|
+
import { detectApplications, formatApplicationsMarkdown } from "../detector/index.js";
|
|
8
9
|
import { saveConfig, loadConfig, configDir, ALL_ACTIONS, DEFAULT_SAFE_ACTIONS } from "../config.js";
|
|
9
10
|
import { interactiveAddChannel } from "../notify/setup.js";
|
|
10
11
|
import { pickContext } from "../kubectl-config.js";
|
|
@@ -25,6 +26,25 @@ function pingOnboardComplete() {
|
|
|
25
26
|
function expandPath(p) {
|
|
26
27
|
return p.startsWith("~/") ? homedir() + p.slice(1) : p;
|
|
27
28
|
}
|
|
29
|
+
async function runAppDetection(kubectlOpts, kbDir) {
|
|
30
|
+
const spinner = ora("Detecting OSS applications...").start();
|
|
31
|
+
try {
|
|
32
|
+
const result = await detectApplications({
|
|
33
|
+
context: kubectlOpts.context,
|
|
34
|
+
onProgress: (step) => {
|
|
35
|
+
spinner.text = `Detecting OSS applications — ${step}...`;
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
writeApplicationsKb(kbDir, formatApplicationsMarkdown(result));
|
|
39
|
+
const eolCount = result.apps.filter((a) => a.eol?.eol).length;
|
|
40
|
+
const cveCount = result.apps.filter((a) => a.cves.length > 0).length;
|
|
41
|
+
spinner.succeed(`Inventoried ${result.apps.length} OSS app${result.apps.length === 1 ? "" : "s"} ` +
|
|
42
|
+
`(${eolCount} EOL, ${cveCount} with CVEs)`);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
spinner.warn(`OSS detection skipped: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
28
48
|
async function ask(question) {
|
|
29
49
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
50
|
return new Promise((resolve) => {
|
|
@@ -130,6 +150,7 @@ export async function onboard(opts = {}) {
|
|
|
130
150
|
writeProjectKb(kbDir, "_notes", `# Onboarding Notes\n\n${Array.from(notes.entries()).map(([q, a]) => `**${q}**\n${a}`).join("\n\n")}\n`);
|
|
131
151
|
}
|
|
132
152
|
earlyKbSpinner.succeed(`Knowledge base written (${totalItems} file${totalItems !== 1 ? "s" : ""})`);
|
|
153
|
+
await runAppDetection(kubectlOpts, kbDir);
|
|
133
154
|
const currentSafeActions = existingConfig.remediation?.safe_actions ?? DEFAULT_SAFE_ACTIONS;
|
|
134
155
|
const safeActions = await configureSafeActions(currentSafeActions);
|
|
135
156
|
const existingChannels = existingConfig.notifications?.channels ?? [];
|
|
@@ -312,6 +333,7 @@ export async function onboard(opts = {}) {
|
|
|
312
333
|
writeProjectKb(kbDir, "_notes", `# Onboarding Notes\n\n${generalNotes}\n`);
|
|
313
334
|
}
|
|
314
335
|
kbSpinner.succeed(`Knowledge base written (${totalKbItems} file${totalKbItems !== 1 ? "s" : ""})`);
|
|
336
|
+
await runAppDetection(kubectlOpts, kbDir);
|
|
315
337
|
// Step 6b: Configure safe actions
|
|
316
338
|
const currentSafeActions = existingConfig.remediation?.safe_actions ?? DEFAULT_SAFE_ACTIONS;
|
|
317
339
|
const safeActions = await configureSafeActions(currentSafeActions);
|