kubeagent 0.1.35 → 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 +34 -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 +131 -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 +55 -0
- package/dist/detector/sources.js +218 -0
- 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/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/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/onboard/index.js +23 -1
- package/dist/orchestrator.js +2 -1
- package/dist/telemetry.js +27 -1
- package/package.json +10 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { fetchWorkloadCandidates, fetchHelmCandidates, fetchIngressHostMap, workloadKey, } from "./sources.js";
|
|
2
|
+
import { parseImage, normalizeVersion } from "./image-parser.js";
|
|
3
|
+
import { lookupCatalog, lookupChart } from "./catalog.js";
|
|
4
|
+
import { queryOsv } from "./osv.js";
|
|
5
|
+
import { queryEol } from "./eol.js";
|
|
6
|
+
function recordWorkload(map, ref, hostsByWorkload) {
|
|
7
|
+
const key = workloadKey(ref);
|
|
8
|
+
if (!map.has(key)) {
|
|
9
|
+
const hosts = hostsByWorkload?.get(key);
|
|
10
|
+
map.set(key, hosts && hosts.length > 0 ? { ...ref, hosts } : ref);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function helmWorkloadRef(c) {
|
|
14
|
+
return { kind: "Deployment", namespace: c.namespace, name: c.releaseName };
|
|
15
|
+
}
|
|
16
|
+
export async function detectApplications(opts = {}) {
|
|
17
|
+
const { onProgress, noCache } = opts;
|
|
18
|
+
const kubectlOpts = {
|
|
19
|
+
context: opts.context,
|
|
20
|
+
namespace: opts.namespace,
|
|
21
|
+
timeout: opts.timeout,
|
|
22
|
+
};
|
|
23
|
+
onProgress?.("workloads");
|
|
24
|
+
const [workloadCandidates, ingressMap] = await Promise.all([
|
|
25
|
+
fetchWorkloadCandidates(kubectlOpts),
|
|
26
|
+
fetchIngressHostMap(kubectlOpts).catch(() => new Map()),
|
|
27
|
+
]);
|
|
28
|
+
onProgress?.("helm");
|
|
29
|
+
const helmCandidates = await fetchHelmCandidates(kubectlOpts);
|
|
30
|
+
const known = new Map();
|
|
31
|
+
const unknowns = new Map();
|
|
32
|
+
for (const c of workloadCandidates) {
|
|
33
|
+
if (c.source === "image") {
|
|
34
|
+
const parsed = parseImage(c.image);
|
|
35
|
+
const version = normalizeVersion(parsed.tag);
|
|
36
|
+
const match = lookupCatalog(parsed.repository, parsed.name);
|
|
37
|
+
if (match && version) {
|
|
38
|
+
const key = `${match.canonical}@${version}`;
|
|
39
|
+
let det = known.get(key);
|
|
40
|
+
if (!det) {
|
|
41
|
+
det = {
|
|
42
|
+
canonical: match.canonical,
|
|
43
|
+
display: match.entry.display,
|
|
44
|
+
version,
|
|
45
|
+
entry: match.entry,
|
|
46
|
+
sources: new Set(),
|
|
47
|
+
workloads: new Map(),
|
|
48
|
+
};
|
|
49
|
+
known.set(key, det);
|
|
50
|
+
}
|
|
51
|
+
det.sources.add("image");
|
|
52
|
+
recordWorkload(det.workloads, c.workload, ingressMap);
|
|
53
|
+
}
|
|
54
|
+
else if (version) {
|
|
55
|
+
const key = `${parsed.name}@${version}`;
|
|
56
|
+
let u = unknowns.get(key);
|
|
57
|
+
if (!u) {
|
|
58
|
+
u = { name: parsed.name, version, workloads: [] };
|
|
59
|
+
unknowns.set(key, u);
|
|
60
|
+
}
|
|
61
|
+
if (!u.workloads.some((w) => w.kind === c.workload.kind && w.namespace === c.workload.namespace && w.name === c.workload.name)) {
|
|
62
|
+
const hosts = ingressMap.get(workloadKey(c.workload));
|
|
63
|
+
u.workloads.push(hosts && hosts.length > 0 ? { ...c.workload, hosts } : c.workload);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else if (c.source === "label") {
|
|
68
|
+
const match = lookupCatalog(c.appName, c.appName);
|
|
69
|
+
const version = normalizeVersion(c.appVersion);
|
|
70
|
+
if (match && version) {
|
|
71
|
+
const key = `${match.canonical}@${version}`;
|
|
72
|
+
let det = known.get(key);
|
|
73
|
+
if (!det) {
|
|
74
|
+
det = {
|
|
75
|
+
canonical: match.canonical,
|
|
76
|
+
display: match.entry.display,
|
|
77
|
+
version,
|
|
78
|
+
entry: match.entry,
|
|
79
|
+
sources: new Set(),
|
|
80
|
+
workloads: new Map(),
|
|
81
|
+
};
|
|
82
|
+
known.set(key, det);
|
|
83
|
+
}
|
|
84
|
+
det.sources.add("label");
|
|
85
|
+
recordWorkload(det.workloads, c.workload, ingressMap);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const c of helmCandidates) {
|
|
90
|
+
const match = lookupChart(c.chart);
|
|
91
|
+
const version = normalizeVersion(c.appVersion ?? c.chartVersion);
|
|
92
|
+
if (match && version) {
|
|
93
|
+
const key = `${match.canonical}@${version}`;
|
|
94
|
+
let det = known.get(key);
|
|
95
|
+
if (!det) {
|
|
96
|
+
det = {
|
|
97
|
+
canonical: match.canonical,
|
|
98
|
+
display: match.entry.display,
|
|
99
|
+
version,
|
|
100
|
+
entry: match.entry,
|
|
101
|
+
sources: new Set(),
|
|
102
|
+
workloads: new Map(),
|
|
103
|
+
};
|
|
104
|
+
known.set(key, det);
|
|
105
|
+
}
|
|
106
|
+
det.sources.add("helm");
|
|
107
|
+
recordWorkload(det.workloads, helmWorkloadRef(c), ingressMap);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
onProgress?.("cve");
|
|
111
|
+
const apps = [];
|
|
112
|
+
for (const det of known.values()) {
|
|
113
|
+
const cves = det.entry.osv
|
|
114
|
+
? await queryOsv(det.entry.osv, det.version, { noCache })
|
|
115
|
+
: [];
|
|
116
|
+
const eol = det.entry.eolProduct
|
|
117
|
+
? await queryEol(det.entry.eolProduct, det.version, { noCache })
|
|
118
|
+
: null;
|
|
119
|
+
apps.push({
|
|
120
|
+
canonical: det.canonical,
|
|
121
|
+
display: det.display,
|
|
122
|
+
version: det.version,
|
|
123
|
+
sources: [...det.sources],
|
|
124
|
+
workloads: [...det.workloads.values()],
|
|
125
|
+
cves,
|
|
126
|
+
eol,
|
|
127
|
+
catalogMatch: true,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
apps.sort((a, b) => {
|
|
131
|
+
const sev = (x) => {
|
|
132
|
+
if (x.eol?.eol)
|
|
133
|
+
return 0;
|
|
134
|
+
if (x.cves.length > 0)
|
|
135
|
+
return 1;
|
|
136
|
+
return 2;
|
|
137
|
+
};
|
|
138
|
+
const s = sev(a) - sev(b);
|
|
139
|
+
return s !== 0 ? s : a.display.localeCompare(b.display);
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
apps,
|
|
143
|
+
unknowns: [...unknowns.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
144
|
+
scanned: {
|
|
145
|
+
workloads: workloadCandidates.length,
|
|
146
|
+
helmReleases: helmCandidates.length,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function workloadLabel(w) {
|
|
151
|
+
const base = `${w.kind}/${w.namespace}/${w.name}`;
|
|
152
|
+
return w.hosts && w.hosts.length > 0 ? `${base} (${w.hosts.join(", ")})` : base;
|
|
153
|
+
}
|
|
154
|
+
function escapeMd(s) {
|
|
155
|
+
return s.replace(/\|/g, "\\|");
|
|
156
|
+
}
|
|
157
|
+
export function formatApplicationsMarkdown(result) {
|
|
158
|
+
const lines = [];
|
|
159
|
+
lines.push("# Detected Open-Source Applications", "");
|
|
160
|
+
lines.push(`_Scanned ${result.scanned.workloads} workload container(s) and ${result.scanned.helmReleases} Helm release(s)._`, "");
|
|
161
|
+
const eolApps = result.apps.filter((a) => a.eol?.eol);
|
|
162
|
+
const cveApps = result.apps.filter((a) => !a.eol?.eol && a.cves.length > 0);
|
|
163
|
+
const healthy = result.apps.filter((a) => !a.eol?.eol && a.cves.length === 0);
|
|
164
|
+
if (eolApps.length > 0) {
|
|
165
|
+
lines.push("## End-of-Life", "");
|
|
166
|
+
lines.push("| App | Version | EOL since | Latest in cycle | Workloads |");
|
|
167
|
+
lines.push("|-----|---------|-----------|-----------------|-----------|");
|
|
168
|
+
for (const a of eolApps) {
|
|
169
|
+
const eolStr = a.eol?.eolDate ?? "yes";
|
|
170
|
+
const latest = a.eol?.latest ?? "—";
|
|
171
|
+
const wls = a.workloads.map(workloadLabel).join(", ");
|
|
172
|
+
lines.push(`| ${escapeMd(a.display)} | \`${a.version}\` | ${eolStr} | \`${latest}\` | ${escapeMd(wls)} |`);
|
|
173
|
+
}
|
|
174
|
+
lines.push("");
|
|
175
|
+
}
|
|
176
|
+
if (cveApps.length > 0) {
|
|
177
|
+
lines.push("## Known Vulnerabilities", "");
|
|
178
|
+
for (const a of cveApps) {
|
|
179
|
+
lines.push(`### ${a.display} \`${a.version}\``);
|
|
180
|
+
lines.push(`Workloads: ${a.workloads.map(workloadLabel).join(", ")}`);
|
|
181
|
+
lines.push("");
|
|
182
|
+
lines.push("| CVE | Severity | Fixed in | Summary |");
|
|
183
|
+
lines.push("|-----|----------|----------|---------|");
|
|
184
|
+
for (const c of a.cves.slice(0, 20)) {
|
|
185
|
+
const ref = c.reference ? `[${c.id}](${c.reference})` : c.id;
|
|
186
|
+
const fixed = c.fixed ? `\`${c.fixed}\`` : "—";
|
|
187
|
+
const sev = c.severity ?? "—";
|
|
188
|
+
lines.push(`| ${ref} | ${sev} | ${fixed} | ${escapeMd(c.summary || "")} |`);
|
|
189
|
+
}
|
|
190
|
+
if (a.cves.length > 20) {
|
|
191
|
+
lines.push(`| _… and ${a.cves.length - 20} more_ | | | |`);
|
|
192
|
+
}
|
|
193
|
+
lines.push("");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (healthy.length > 0) {
|
|
197
|
+
lines.push("## Up-to-date", "");
|
|
198
|
+
lines.push("| App | Version | Workloads |");
|
|
199
|
+
lines.push("|-----|---------|-----------|");
|
|
200
|
+
for (const a of healthy) {
|
|
201
|
+
const wls = a.workloads.map(workloadLabel).join(", ");
|
|
202
|
+
lines.push(`| ${escapeMd(a.display)} | \`${a.version}\` | ${escapeMd(wls)} |`);
|
|
203
|
+
}
|
|
204
|
+
lines.push("");
|
|
205
|
+
}
|
|
206
|
+
if (result.unknowns.length > 0) {
|
|
207
|
+
lines.push("## Other / not in catalog", "");
|
|
208
|
+
lines.push("Images that look like OSS apps but aren't in the CVE/EOL catalog yet:");
|
|
209
|
+
lines.push("");
|
|
210
|
+
for (const u of result.unknowns) {
|
|
211
|
+
const wls = u.workloads.map(workloadLabel).join(", ");
|
|
212
|
+
lines.push(`- \`${u.name}:${u.version}\` — ${wls}`);
|
|
213
|
+
}
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
vi.mock("./sources.js", () => ({
|
|
3
|
+
fetchWorkloadCandidates: vi.fn(),
|
|
4
|
+
fetchHelmCandidates: vi.fn(),
|
|
5
|
+
fetchIngressHostMap: vi.fn(),
|
|
6
|
+
workloadKey: (ref) => `${ref.kind}/${ref.namespace}/${ref.name}`,
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("./osv.js", () => ({
|
|
9
|
+
queryOsv: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock("./eol.js", () => ({
|
|
12
|
+
queryEol: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
import { fetchWorkloadCandidates, fetchHelmCandidates, fetchIngressHostMap } from "./sources.js";
|
|
15
|
+
import { queryOsv } from "./osv.js";
|
|
16
|
+
import { queryEol } from "./eol.js";
|
|
17
|
+
import { detectApplications, formatApplicationsMarkdown } from "./index.js";
|
|
18
|
+
const fetchWorkloads = vi.mocked(fetchWorkloadCandidates);
|
|
19
|
+
const fetchHelm = vi.mocked(fetchHelmCandidates);
|
|
20
|
+
const fetchIngress = vi.mocked(fetchIngressHostMap);
|
|
21
|
+
const osv = vi.mocked(queryOsv);
|
|
22
|
+
const eol = vi.mocked(queryEol);
|
|
23
|
+
describe("detectApplications", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
fetchWorkloads.mockReset();
|
|
26
|
+
fetchHelm.mockReset();
|
|
27
|
+
fetchIngress.mockReset();
|
|
28
|
+
osv.mockReset();
|
|
29
|
+
eol.mockReset();
|
|
30
|
+
fetchIngress.mockResolvedValue(new Map());
|
|
31
|
+
osv.mockResolvedValue([]);
|
|
32
|
+
eol.mockResolvedValue(null);
|
|
33
|
+
});
|
|
34
|
+
it("merges image and label candidates pointing at the same app/version", async () => {
|
|
35
|
+
fetchWorkloads.mockResolvedValue([
|
|
36
|
+
{
|
|
37
|
+
source: "image",
|
|
38
|
+
image: "postgres:15.2",
|
|
39
|
+
container: "db",
|
|
40
|
+
workload: { kind: "StatefulSet", namespace: "prod", name: "db" },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
source: "label",
|
|
44
|
+
appName: "postgres",
|
|
45
|
+
appVersion: "15.2",
|
|
46
|
+
workload: { kind: "StatefulSet", namespace: "prod", name: "db" },
|
|
47
|
+
},
|
|
48
|
+
]);
|
|
49
|
+
fetchHelm.mockResolvedValue([]);
|
|
50
|
+
const result = await detectApplications({ noCache: true });
|
|
51
|
+
expect(result.apps).toHaveLength(1);
|
|
52
|
+
expect(result.apps[0].canonical).toBe("postgres");
|
|
53
|
+
expect(result.apps[0].version).toBe("15.2");
|
|
54
|
+
expect(result.apps[0].sources.sort()).toEqual(["image", "label"]);
|
|
55
|
+
expect(result.apps[0].workloads).toHaveLength(1);
|
|
56
|
+
});
|
|
57
|
+
it("tracks images not in the catalog as unknowns", async () => {
|
|
58
|
+
fetchWorkloads.mockResolvedValue([
|
|
59
|
+
{
|
|
60
|
+
source: "image",
|
|
61
|
+
image: "acme/internal-app:1.2.3",
|
|
62
|
+
container: "app",
|
|
63
|
+
workload: { kind: "Deployment", namespace: "team-x", name: "foo" },
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
fetchHelm.mockResolvedValue([]);
|
|
67
|
+
const result = await detectApplications({ noCache: true });
|
|
68
|
+
expect(result.apps).toHaveLength(0);
|
|
69
|
+
expect(result.unknowns).toHaveLength(1);
|
|
70
|
+
expect(result.unknowns[0]).toMatchObject({ name: "internal-app", version: "1.2.3" });
|
|
71
|
+
});
|
|
72
|
+
it("flags EOL apps via the eol lookup", async () => {
|
|
73
|
+
fetchWorkloads.mockResolvedValue([
|
|
74
|
+
{
|
|
75
|
+
source: "image",
|
|
76
|
+
image: "postgres:11.18",
|
|
77
|
+
container: "db",
|
|
78
|
+
workload: { kind: "StatefulSet", namespace: "prod", name: "db" },
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
fetchHelm.mockResolvedValue([]);
|
|
82
|
+
eol.mockResolvedValue({
|
|
83
|
+
product: "postgres",
|
|
84
|
+
cycle: "11",
|
|
85
|
+
eol: true,
|
|
86
|
+
eolDate: "2023-11-09",
|
|
87
|
+
latest: "11.22",
|
|
88
|
+
lts: false,
|
|
89
|
+
});
|
|
90
|
+
const result = await detectApplications({ noCache: true });
|
|
91
|
+
expect(result.apps[0].eol?.eol).toBe(true);
|
|
92
|
+
expect(result.apps[0].eol?.eolDate).toBe("2023-11-09");
|
|
93
|
+
});
|
|
94
|
+
it("attaches CVE findings from OSV", async () => {
|
|
95
|
+
fetchWorkloads.mockResolvedValue([
|
|
96
|
+
{
|
|
97
|
+
source: "image",
|
|
98
|
+
image: "redis:7.0.5",
|
|
99
|
+
container: "cache",
|
|
100
|
+
workload: { kind: "Deployment", namespace: "infra", name: "redis" },
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
fetchHelm.mockResolvedValue([]);
|
|
104
|
+
osv.mockResolvedValue([
|
|
105
|
+
{ id: "CVE-2023-9999", summary: "Boom.", severity: "high", fixed: "7.0.12" },
|
|
106
|
+
]);
|
|
107
|
+
const result = await detectApplications({ noCache: true });
|
|
108
|
+
expect(result.apps[0].cves).toHaveLength(1);
|
|
109
|
+
expect(result.apps[0].cves[0].id).toBe("CVE-2023-9999");
|
|
110
|
+
});
|
|
111
|
+
it("orders apps by severity (EOL → CVEs → healthy)", async () => {
|
|
112
|
+
fetchWorkloads.mockResolvedValue([
|
|
113
|
+
{
|
|
114
|
+
source: "image",
|
|
115
|
+
image: "nginx:1.25.3",
|
|
116
|
+
container: "web",
|
|
117
|
+
workload: { kind: "Deployment", namespace: "edge", name: "nginx" },
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
source: "image",
|
|
121
|
+
image: "postgres:11.18",
|
|
122
|
+
container: "db",
|
|
123
|
+
workload: { kind: "StatefulSet", namespace: "prod", name: "db" },
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
source: "image",
|
|
127
|
+
image: "redis:7.0.5",
|
|
128
|
+
container: "cache",
|
|
129
|
+
workload: { kind: "Deployment", namespace: "infra", name: "redis" },
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
fetchHelm.mockResolvedValue([]);
|
|
133
|
+
eol.mockImplementation(async (product) => product === "postgres"
|
|
134
|
+
? { product: "postgres", cycle: "11", eol: true, eolDate: "2023-11-09", lts: false }
|
|
135
|
+
: null);
|
|
136
|
+
osv.mockImplementation(async (lookup) => lookup.name === "redis"
|
|
137
|
+
? [{ id: "CVE-2023-1", summary: "x", severity: "medium" }]
|
|
138
|
+
: []);
|
|
139
|
+
const result = await detectApplications({ noCache: true });
|
|
140
|
+
expect(result.apps.map((a) => a.canonical)).toEqual(["postgres", "redis", "nginx"]);
|
|
141
|
+
});
|
|
142
|
+
it("uses Helm chart appVersion when available", async () => {
|
|
143
|
+
fetchWorkloads.mockResolvedValue([]);
|
|
144
|
+
fetchHelm.mockResolvedValue([
|
|
145
|
+
{
|
|
146
|
+
source: "helm",
|
|
147
|
+
chart: "postgresql",
|
|
148
|
+
chartVersion: "12.0.0",
|
|
149
|
+
appVersion: "15.2.0",
|
|
150
|
+
namespace: "data",
|
|
151
|
+
releaseName: "primary",
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
const result = await detectApplications({ noCache: true });
|
|
155
|
+
expect(result.apps).toHaveLength(1);
|
|
156
|
+
expect(result.apps[0].canonical).toBe("postgres");
|
|
157
|
+
expect(result.apps[0].version).toBe("15.2.0");
|
|
158
|
+
expect(result.apps[0].sources).toContain("helm");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe("formatApplicationsMarkdown", () => {
|
|
162
|
+
it("produces sections for EOL, CVEs, healthy, and unknowns", () => {
|
|
163
|
+
const md = formatApplicationsMarkdown({
|
|
164
|
+
apps: [
|
|
165
|
+
{
|
|
166
|
+
canonical: "postgres",
|
|
167
|
+
display: "PostgreSQL",
|
|
168
|
+
version: "11.18",
|
|
169
|
+
sources: ["image"],
|
|
170
|
+
workloads: [{ kind: "StatefulSet", namespace: "prod", name: "db" }],
|
|
171
|
+
cves: [],
|
|
172
|
+
eol: { product: "postgres", cycle: "11", eol: true, eolDate: "2023-11-09", latest: "11.22", lts: false },
|
|
173
|
+
catalogMatch: true,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
canonical: "redis",
|
|
177
|
+
display: "Redis",
|
|
178
|
+
version: "7.0.5",
|
|
179
|
+
sources: ["image"],
|
|
180
|
+
workloads: [{ kind: "Deployment", namespace: "infra", name: "redis" }],
|
|
181
|
+
cves: [{ id: "CVE-2023-1", summary: "boom", severity: "high", fixed: "7.0.12" }],
|
|
182
|
+
eol: null,
|
|
183
|
+
catalogMatch: true,
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
canonical: "nginx",
|
|
187
|
+
display: "nginx",
|
|
188
|
+
version: "1.25.3",
|
|
189
|
+
sources: ["image"],
|
|
190
|
+
workloads: [{ kind: "Deployment", namespace: "edge", name: "nginx" }],
|
|
191
|
+
cves: [],
|
|
192
|
+
eol: null,
|
|
193
|
+
catalogMatch: true,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
unknowns: [
|
|
197
|
+
{ name: "internal-app", version: "1.2.3", workloads: [{ kind: "Deployment", namespace: "team-x", name: "foo" }] },
|
|
198
|
+
],
|
|
199
|
+
scanned: { workloads: 4, helmReleases: 0 },
|
|
200
|
+
});
|
|
201
|
+
expect(md).toContain("## End-of-Life");
|
|
202
|
+
expect(md).toContain("PostgreSQL");
|
|
203
|
+
expect(md).toContain("## Known Vulnerabilities");
|
|
204
|
+
expect(md).toContain("CVE-2023-1");
|
|
205
|
+
expect(md).toContain("## Up-to-date");
|
|
206
|
+
expect(md).toContain("nginx");
|
|
207
|
+
expect(md).toContain("## Other / not in catalog");
|
|
208
|
+
expect(md).toContain("internal-app");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { OsvLookup } from "./catalog.js";
|
|
2
|
+
export interface CveFinding {
|
|
3
|
+
id: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
severity?: string;
|
|
6
|
+
fixed?: string;
|
|
7
|
+
reference?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function queryOsv(lookup: OsvLookup, version: string, opts?: {
|
|
10
|
+
noCache?: boolean;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
}): Promise<CveFinding[]>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readCache, readStaleCache, writeCache } from "./cache.js";
|
|
2
|
+
const OSV_URL = "https://api.osv.dev/v1/query";
|
|
3
|
+
function summarize(v) {
|
|
4
|
+
const fixed = v.affected?.[0]?.ranges?.[0]?.events?.find((e) => e.fixed)?.fixed;
|
|
5
|
+
const reference = v.references?.find((r) => r.type === "ADVISORY")?.url ??
|
|
6
|
+
v.references?.[0]?.url;
|
|
7
|
+
return {
|
|
8
|
+
id: v.id,
|
|
9
|
+
summary: (v.summary ?? v.details ?? "").slice(0, 200),
|
|
10
|
+
severity: v.database_specific?.severity,
|
|
11
|
+
fixed,
|
|
12
|
+
reference,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export async function queryOsv(lookup, version, opts = {}) {
|
|
16
|
+
if (!version)
|
|
17
|
+
return [];
|
|
18
|
+
const cacheKey = `${lookup.ecosystem ?? "any"}-${lookup.name}@${version}`;
|
|
19
|
+
if (!opts.noCache) {
|
|
20
|
+
const cached = readCache("osv", cacheKey);
|
|
21
|
+
if (cached)
|
|
22
|
+
return cached;
|
|
23
|
+
}
|
|
24
|
+
const body = {
|
|
25
|
+
package: lookup.ecosystem
|
|
26
|
+
? { name: lookup.name, ecosystem: lookup.ecosystem }
|
|
27
|
+
: { name: lookup.name },
|
|
28
|
+
version,
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(OSV_URL, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 8000),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const stale = readStaleCache("osv", cacheKey);
|
|
39
|
+
return stale?.data ?? [];
|
|
40
|
+
}
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
const findings = (data.vulns ?? []).map(summarize);
|
|
43
|
+
writeCache("osv", cacheKey, findings);
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
const stale = readStaleCache("osv", cacheKey);
|
|
48
|
+
return stale?.data ?? [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|