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.
Files changed (61) hide show
  1. package/README.md +34 -0
  2. package/dist/auth.js +15 -0
  3. package/dist/check/index.d.ts +34 -0
  4. package/dist/check/index.js +77 -0
  5. package/dist/check/index.test.d.ts +1 -0
  6. package/dist/check/index.test.js +202 -0
  7. package/dist/check/run.d.ts +10 -0
  8. package/dist/check/run.js +114 -0
  9. package/dist/cli.js +131 -0
  10. package/dist/detector/cache.d.ts +6 -0
  11. package/dist/detector/cache.js +44 -0
  12. package/dist/detector/cache.test.d.ts +1 -0
  13. package/dist/detector/cache.test.js +54 -0
  14. package/dist/detector/catalog.d.ts +17 -0
  15. package/dist/detector/catalog.js +88 -0
  16. package/dist/detector/catalog.test.d.ts +1 -0
  17. package/dist/detector/catalog.test.js +33 -0
  18. package/dist/detector/eol.d.ts +13 -0
  19. package/dist/detector/eol.js +64 -0
  20. package/dist/detector/eol.test.d.ts +1 -0
  21. package/dist/detector/eol.test.js +78 -0
  22. package/dist/detector/image-parser.d.ts +11 -0
  23. package/dist/detector/image-parser.js +59 -0
  24. package/dist/detector/image-parser.test.d.ts +1 -0
  25. package/dist/detector/image-parser.test.js +87 -0
  26. package/dist/detector/index.d.ts +33 -0
  27. package/dist/detector/index.js +217 -0
  28. package/dist/detector/index.test.d.ts +1 -0
  29. package/dist/detector/index.test.js +210 -0
  30. package/dist/detector/osv.d.ts +12 -0
  31. package/dist/detector/osv.js +50 -0
  32. package/dist/detector/osv.test.d.ts +1 -0
  33. package/dist/detector/osv.test.js +86 -0
  34. package/dist/detector/report.d.ts +6 -0
  35. package/dist/detector/report.js +91 -0
  36. package/dist/detector/sources.d.ts +55 -0
  37. package/dist/detector/sources.js +218 -0
  38. package/dist/detector/sources.test.d.ts +1 -0
  39. package/dist/detector/sources.test.js +36 -0
  40. package/dist/diagnoser/approval.test.d.ts +1 -0
  41. package/dist/diagnoser/approval.test.js +49 -0
  42. package/dist/diagnoser/index.d.ts +16 -0
  43. package/dist/diagnoser/index.js +37 -11
  44. package/dist/diagnoser/tools.d.ts +2 -2
  45. package/dist/kb/loader.js +6 -0
  46. package/dist/kb/writer.d.ts +1 -0
  47. package/dist/kb/writer.js +4 -0
  48. package/dist/monitor/index.d.ts +11 -1
  49. package/dist/monitor/index.js +45 -14
  50. package/dist/monitor/scope.test.d.ts +1 -0
  51. package/dist/monitor/scope.test.js +87 -0
  52. package/dist/notify/discord.js +6 -9
  53. package/dist/notify/ssrf-guard.d.ts +5 -0
  54. package/dist/notify/ssrf-guard.js +150 -0
  55. package/dist/notify/teams.js +6 -9
  56. package/dist/notify/webhook.js +7 -9
  57. package/dist/notify/webhook.test.js +21 -0
  58. package/dist/onboard/index.js +23 -1
  59. package/dist/orchestrator.js +2 -1
  60. package/dist/telemetry.js +27 -1
  61. package/package.json +10 -1
@@ -0,0 +1,6 @@
1
+ export declare function readCache<T>(kind: string, key: string): T | null;
2
+ export declare function readStaleCache<T>(kind: string, key: string): {
3
+ data: T;
4
+ fetchedAt: number;
5
+ } | null;
6
+ export declare function writeCache<T>(kind: string, key: string, data: T): void;
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { configDir } from "../config.js";
4
+ const TTL_MS = 24 * 60 * 60 * 1000;
5
+ function cacheFile(kind, key) {
6
+ const safe = key.replace(/[^a-z0-9_.@+-]/gi, "_");
7
+ return join(configDir(), "cache", kind, `${safe}.json`);
8
+ }
9
+ export function readCache(kind, key) {
10
+ const path = cacheFile(kind, key);
11
+ if (!existsSync(path))
12
+ return null;
13
+ try {
14
+ const raw = readFileSync(path, "utf-8");
15
+ const entry = JSON.parse(raw);
16
+ if (Date.now() - entry.fetchedAt > TTL_MS)
17
+ return null;
18
+ return entry.data;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function readStaleCache(kind, key) {
25
+ const path = cacheFile(kind, key);
26
+ if (!existsSync(path))
27
+ return null;
28
+ try {
29
+ const raw = readFileSync(path, "utf-8");
30
+ const entry = JSON.parse(raw);
31
+ return { data: entry.data, fetchedAt: entry.fetchedAt };
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export function writeCache(kind, key, data) {
38
+ const path = cacheFile(kind, key);
39
+ const dir = dirname(path);
40
+ if (!existsSync(dir))
41
+ mkdirSync(dir, { recursive: true });
42
+ const entry = { fetchedAt: Date.now(), data };
43
+ writeFileSync(path, JSON.stringify(entry));
44
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ let tmpHome;
6
+ let originalHome;
7
+ beforeEach(() => {
8
+ originalHome = process.env.HOME;
9
+ tmpHome = mkdtempSync(join(tmpdir(), "kubeagent-cache-test-"));
10
+ process.env.HOME = tmpHome;
11
+ });
12
+ afterEach(() => {
13
+ if (originalHome !== undefined)
14
+ process.env.HOME = originalHome;
15
+ else
16
+ delete process.env.HOME;
17
+ rmSync(tmpHome, { recursive: true, force: true });
18
+ });
19
+ describe("cache", () => {
20
+ it("round-trips data within the TTL", async () => {
21
+ const { readCache, writeCache } = await import("./cache.js");
22
+ writeCache("osv", "redis@7.0.5", [{ id: "CVE-1" }]);
23
+ const got = readCache("osv", "redis@7.0.5");
24
+ expect(got).toEqual([{ id: "CVE-1" }]);
25
+ });
26
+ it("returns null for a missing entry", async () => {
27
+ const { readCache } = await import("./cache.js");
28
+ expect(readCache("osv", "nope")).toBeNull();
29
+ });
30
+ it("creates the cache directory on write", async () => {
31
+ const { writeCache } = await import("./cache.js");
32
+ writeCache("eol", "postgres", [{ cycle: "15" }]);
33
+ expect(existsSync(join(tmpHome, ".kubeagent", "cache", "eol", "postgres.json"))).toBe(true);
34
+ });
35
+ it("sanitizes unsafe characters in cache keys", async () => {
36
+ const { writeCache } = await import("./cache.js");
37
+ writeCache("osv", "foo/../bar@1.0", { x: 1 });
38
+ expect(existsSync(join(tmpHome, ".kubeagent", "cache", "osv", "foo_.._bar@1.0.json"))).toBe(true);
39
+ });
40
+ it("readStaleCache returns data and a fetchedAt timestamp", async () => {
41
+ const { writeCache, readStaleCache } = await import("./cache.js");
42
+ writeCache("osv", "k", "v");
43
+ const stale = readStaleCache("osv", "k");
44
+ expect(stale?.data).toBe("v");
45
+ expect(typeof stale?.fetchedAt).toBe("number");
46
+ });
47
+ it("treats a malformed cache file as a miss", async () => {
48
+ const { writeCache, readCache } = await import("./cache.js");
49
+ writeCache("osv", "k", "v");
50
+ const path = join(tmpHome, ".kubeagent", "cache", "osv", "k.json");
51
+ writeFileSync(path, "not json");
52
+ expect(readCache("osv", "k")).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,17 @@
1
+ export interface OsvLookup {
2
+ name: string;
3
+ ecosystem?: string;
4
+ }
5
+ export interface ProductEntry {
6
+ display: string;
7
+ eolProduct?: string;
8
+ osv?: OsvLookup;
9
+ }
10
+ export declare const PRODUCTS: Record<string, ProductEntry>;
11
+ export declare const ALIASES: Record<string, string>;
12
+ export interface CatalogMatch {
13
+ canonical: string;
14
+ entry: ProductEntry;
15
+ }
16
+ export declare function lookupCatalog(repository: string, name: string): CatalogMatch | null;
17
+ export declare function lookupChart(chartName: string): CatalogMatch | null;
@@ -0,0 +1,88 @@
1
+ export const PRODUCTS = {
2
+ postgres: { display: "PostgreSQL", eolProduct: "postgres", osv: { name: "postgresql" } },
3
+ mysql: { display: "MySQL", eolProduct: "mysql", osv: { name: "mysql" } },
4
+ mariadb: { display: "MariaDB", eolProduct: "mariadb", osv: { name: "mariadb" } },
5
+ redis: { display: "Redis", eolProduct: "redis", osv: { name: "redis" } },
6
+ mongodb: { display: "MongoDB", eolProduct: "mongodb", osv: { name: "mongodb" } },
7
+ nginx: { display: "nginx", eolProduct: "nginx", osv: { name: "nginx" } },
8
+ traefik: { display: "Traefik", eolProduct: "traefik" },
9
+ haproxy: { display: "HAProxy", eolProduct: "haproxy" },
10
+ rabbitmq: { display: "RabbitMQ", eolProduct: "rabbitmq", osv: { name: "rabbitmq-server" } },
11
+ kafka: { display: "Apache Kafka", eolProduct: "apache-kafka" },
12
+ elasticsearch: { display: "Elasticsearch", eolProduct: "elasticsearch", osv: { name: "elasticsearch" } },
13
+ kibana: { display: "Kibana", eolProduct: "kibana" },
14
+ grafana: { display: "Grafana", eolProduct: "grafana", osv: { name: "grafana" } },
15
+ prometheus: { display: "Prometheus", eolProduct: "prometheus" },
16
+ etcd: { display: "etcd", eolProduct: "etcd", osv: { name: "etcd" } },
17
+ kubernetes: { display: "Kubernetes", eolProduct: "kubernetes", osv: { name: "kubernetes" } },
18
+ node: { display: "Node.js", eolProduct: "nodejs" },
19
+ python: { display: "Python", eolProduct: "python", osv: { name: "python" } },
20
+ php: { display: "PHP", eolProduct: "php", osv: { name: "php" } },
21
+ ruby: { display: "Ruby", eolProduct: "ruby", osv: { name: "ruby" } },
22
+ };
23
+ export const ALIASES = {
24
+ postgresql: "postgres",
25
+ "library/postgres": "postgres",
26
+ "library/postgresql": "postgres",
27
+ "bitnami/postgresql": "postgres",
28
+ "bitnami/postgresql-repmgr": "postgres",
29
+ "library/mysql": "mysql",
30
+ "bitnami/mysql": "mysql",
31
+ "library/mariadb": "mariadb",
32
+ "bitnami/mariadb": "mariadb",
33
+ "library/redis": "redis",
34
+ "bitnami/redis": "redis",
35
+ "redis-stack": "redis",
36
+ "redis-stack-server": "redis",
37
+ "library/mongo": "mongodb",
38
+ mongo: "mongodb",
39
+ "library/mongodb": "mongodb",
40
+ "bitnami/mongodb": "mongodb",
41
+ "library/nginx": "nginx",
42
+ "bitnami/nginx": "nginx",
43
+ "nginxinc/nginx-unprivileged": "nginx",
44
+ "library/traefik": "traefik",
45
+ "library/haproxy": "haproxy",
46
+ "haproxytech/haproxy-alpine": "haproxy",
47
+ "library/rabbitmq": "rabbitmq",
48
+ "bitnami/rabbitmq": "rabbitmq",
49
+ "confluentinc/cp-kafka": "kafka",
50
+ "bitnami/kafka": "kafka",
51
+ "docker.elastic.co/elasticsearch/elasticsearch": "elasticsearch",
52
+ "docker.elastic.co/kibana/kibana": "kibana",
53
+ "grafana/grafana": "grafana",
54
+ "grafana/grafana-oss": "grafana",
55
+ "prom/prometheus": "prometheus",
56
+ "quay.io/prometheus/prometheus": "prometheus",
57
+ "library/etcd": "etcd",
58
+ "etcd": "etcd",
59
+ "registry.k8s.io/etcd": "etcd",
60
+ "library/node": "node",
61
+ "library/python": "python",
62
+ "library/php": "php",
63
+ "library/ruby": "ruby",
64
+ };
65
+ export function lookupCatalog(repository, name) {
66
+ const keys = [repository.toLowerCase(), name.toLowerCase()];
67
+ for (const key of keys) {
68
+ const aliased = ALIASES[key];
69
+ if (aliased && PRODUCTS[aliased]) {
70
+ return { canonical: aliased, entry: PRODUCTS[aliased] };
71
+ }
72
+ if (PRODUCTS[key]) {
73
+ return { canonical: key, entry: PRODUCTS[key] };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ export function lookupChart(chartName) {
79
+ const key = chartName.toLowerCase();
80
+ const aliased = ALIASES[key];
81
+ if (aliased && PRODUCTS[aliased]) {
82
+ return { canonical: aliased, entry: PRODUCTS[aliased] };
83
+ }
84
+ if (PRODUCTS[key]) {
85
+ return { canonical: key, entry: PRODUCTS[key] };
86
+ }
87
+ return null;
88
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { lookupCatalog, lookupChart } from "./catalog.js";
3
+ describe("lookupCatalog", () => {
4
+ it("matches bare image names from the products map", () => {
5
+ const m = lookupCatalog("library/postgres", "postgres");
6
+ expect(m?.canonical).toBe("postgres");
7
+ expect(m?.entry.display).toBe("PostgreSQL");
8
+ });
9
+ it("resolves aliases for bitnami charts", () => {
10
+ const m = lookupCatalog("bitnami/postgresql", "postgresql");
11
+ expect(m?.canonical).toBe("postgres");
12
+ });
13
+ it("resolves quay.io prometheus", () => {
14
+ const m = lookupCatalog("prometheus/prometheus", "prometheus");
15
+ expect(m?.canonical).toBe("prometheus");
16
+ });
17
+ it("handles case-insensitive lookups", () => {
18
+ const m = lookupCatalog("Library/PostgreSQL", "PostgreSQL");
19
+ expect(m?.canonical).toBe("postgres");
20
+ });
21
+ it("returns null for unknown repositories", () => {
22
+ expect(lookupCatalog("acme/internal-app", "internal-app")).toBeNull();
23
+ });
24
+ });
25
+ describe("lookupChart", () => {
26
+ it("matches chart names to canonical products", () => {
27
+ expect(lookupChart("postgresql")?.canonical).toBe("postgres");
28
+ expect(lookupChart("redis")?.canonical).toBe("redis");
29
+ });
30
+ it("returns null for unknown charts", () => {
31
+ expect(lookupChart("unknown-chart")).toBeNull();
32
+ });
33
+ });
@@ -0,0 +1,13 @@
1
+ export interface EolStatus {
2
+ product: string;
3
+ cycle: string;
4
+ eol: boolean;
5
+ eolDate?: string;
6
+ latest?: string;
7
+ releaseDate?: string;
8
+ lts: boolean;
9
+ }
10
+ export declare function queryEol(product: string, version: string, opts?: {
11
+ noCache?: boolean;
12
+ timeoutMs?: number;
13
+ }): Promise<EolStatus | null>;
@@ -0,0 +1,64 @@
1
+ import { readCache, readStaleCache, writeCache } from "./cache.js";
2
+ import { major, majorMinor } from "./image-parser.js";
3
+ function pickCycle(cycles, version) {
4
+ const mm = majorMinor(version);
5
+ const m = major(version);
6
+ return (cycles.find((c) => c.cycle === version) ??
7
+ cycles.find((c) => c.cycle === mm) ??
8
+ cycles.find((c) => c.cycle === m) ??
9
+ null);
10
+ }
11
+ function interpretCycle(product, cycle) {
12
+ let eol = false;
13
+ let eolDate;
14
+ if (typeof cycle.eol === "string") {
15
+ eolDate = cycle.eol;
16
+ eol = new Date(cycle.eol).getTime() < Date.now();
17
+ }
18
+ else if (cycle.eol === true) {
19
+ eol = true;
20
+ }
21
+ return {
22
+ product,
23
+ cycle: cycle.cycle,
24
+ eol,
25
+ eolDate,
26
+ latest: cycle.latest,
27
+ releaseDate: cycle.releaseDate,
28
+ lts: cycle.lts === true || (typeof cycle.lts === "string" && cycle.lts.length > 0),
29
+ };
30
+ }
31
+ export async function queryEol(product, version, opts = {}) {
32
+ if (!version)
33
+ return null;
34
+ if (!opts.noCache) {
35
+ const cached = readCache("eol", product);
36
+ if (cached) {
37
+ const cycle = pickCycle(cached, version);
38
+ return cycle ? interpretCycle(product, cycle) : null;
39
+ }
40
+ }
41
+ try {
42
+ const res = await fetch(`https://endoflife.date/api/${product}.json`, {
43
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 8000),
44
+ });
45
+ if (!res.ok) {
46
+ const stale = readStaleCache("eol", product);
47
+ if (!stale)
48
+ return null;
49
+ const cycle = pickCycle(stale.data, version);
50
+ return cycle ? interpretCycle(product, cycle) : null;
51
+ }
52
+ const data = (await res.json());
53
+ writeCache("eol", product, data);
54
+ const cycle = pickCycle(data, version);
55
+ return cycle ? interpretCycle(product, cycle) : null;
56
+ }
57
+ catch {
58
+ const stale = readStaleCache("eol", product);
59
+ if (!stale)
60
+ return null;
61
+ const cycle = pickCycle(stale.data, version);
62
+ return cycle ? interpretCycle(product, cycle) : null;
63
+ }
64
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,78 @@
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 { queryEol } from "./eol.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
+ const POSTGRES_FIXTURE = [
15
+ { cycle: "16", releaseDate: "2023-09-14", eol: "2028-11-09", latest: "16.2", lts: false },
16
+ { cycle: "15", releaseDate: "2022-10-13", eol: "2027-11-11", latest: "15.6", lts: false },
17
+ { cycle: "11", releaseDate: "2018-10-18", eol: "2023-11-09", latest: "11.22", lts: false },
18
+ ];
19
+ describe("queryEol", () => {
20
+ beforeEach(() => {
21
+ mockFetch.mockReset();
22
+ cacheRead.mockReset();
23
+ cacheStale.mockReset();
24
+ cacheWrite.mockReset();
25
+ cacheRead.mockReturnValue(null);
26
+ cacheStale.mockReturnValue(null);
27
+ });
28
+ it("hits endoflife.date and picks the matching cycle by major version", async () => {
29
+ mockFetch.mockResolvedValue({ ok: true, json: async () => POSTGRES_FIXTURE });
30
+ const status = await queryEol("postgres", "15.2", { noCache: true });
31
+ expect(mockFetch.mock.calls[0][0]).toBe("https://endoflife.date/api/postgres.json");
32
+ expect(status).toMatchObject({
33
+ product: "postgres",
34
+ cycle: "15",
35
+ eol: false,
36
+ eolDate: "2027-11-11",
37
+ latest: "15.6",
38
+ });
39
+ expect(cacheWrite).toHaveBeenCalledOnce();
40
+ });
41
+ it("flags a cycle as EOL when its date is in the past", async () => {
42
+ mockFetch.mockResolvedValue({ ok: true, json: async () => POSTGRES_FIXTURE });
43
+ const status = await queryEol("postgres", "11.18", { noCache: true });
44
+ expect(status?.cycle).toBe("11");
45
+ expect(status?.eol).toBe(true);
46
+ expect(status?.eolDate).toBe("2023-11-09");
47
+ });
48
+ it("returns null when no cycle matches", async () => {
49
+ mockFetch.mockResolvedValue({ ok: true, json: async () => POSTGRES_FIXTURE });
50
+ const status = await queryEol("postgres", "99.0", { noCache: true });
51
+ expect(status).toBeNull();
52
+ });
53
+ it("returns null on network error with no cache", async () => {
54
+ mockFetch.mockRejectedValue(new Error("network down"));
55
+ const status = await queryEol("postgres", "15.2", { noCache: true });
56
+ expect(status).toBeNull();
57
+ });
58
+ it("serves a fresh cached entry without hitting the network", async () => {
59
+ cacheRead.mockReturnValue(POSTGRES_FIXTURE);
60
+ const status = await queryEol("postgres", "15.2");
61
+ expect(mockFetch).not.toHaveBeenCalled();
62
+ expect(status?.cycle).toBe("15");
63
+ });
64
+ it("falls back to stale cache on network error", async () => {
65
+ cacheStale.mockReturnValue({ data: POSTGRES_FIXTURE, fetchedAt: 1 });
66
+ mockFetch.mockRejectedValue(new Error("offline"));
67
+ const status = await queryEol("postgres", "11.18", { noCache: true });
68
+ expect(status?.eol).toBe(true);
69
+ });
70
+ it("treats eol: true as EOL regardless of date", async () => {
71
+ mockFetch.mockResolvedValue({
72
+ ok: true,
73
+ json: async () => [{ cycle: "1.0", eol: true, latest: "1.0.5" }],
74
+ });
75
+ const status = await queryEol("oldthing", "1.0", { noCache: true });
76
+ expect(status?.eol).toBe(true);
77
+ });
78
+ });
@@ -0,0 +1,11 @@
1
+ export interface ParsedImage {
2
+ registry: string;
3
+ repository: string;
4
+ name: string;
5
+ tag: string;
6
+ digest?: string;
7
+ }
8
+ export declare function parseImage(image: string): ParsedImage;
9
+ export declare function normalizeVersion(tag: string): string;
10
+ export declare function majorMinor(version: string): string;
11
+ export declare function major(version: string): string;
@@ -0,0 +1,59 @@
1
+ const DOCKER_HUB = "docker.io";
2
+ export function parseImage(image) {
3
+ const trimmed = image.trim();
4
+ let refPart = trimmed;
5
+ let digest;
6
+ const atIdx = trimmed.indexOf("@");
7
+ if (atIdx >= 0) {
8
+ digest = trimmed.slice(atIdx + 1);
9
+ refPart = trimmed.slice(0, atIdx);
10
+ }
11
+ const firstSlash = refPart.indexOf("/");
12
+ let registry = DOCKER_HUB;
13
+ let path = refPart;
14
+ if (firstSlash > 0) {
15
+ const maybeHost = refPart.slice(0, firstSlash);
16
+ if (maybeHost === "localhost" ||
17
+ maybeHost.includes(".") ||
18
+ maybeHost.includes(":")) {
19
+ registry = maybeHost;
20
+ path = refPart.slice(firstSlash + 1);
21
+ }
22
+ }
23
+ const lastSlash = path.lastIndexOf("/");
24
+ const tagIdx = path.indexOf(":", lastSlash + 1);
25
+ let tag;
26
+ let repository;
27
+ if (tagIdx >= 0) {
28
+ tag = path.slice(tagIdx + 1);
29
+ repository = path.slice(0, tagIdx);
30
+ }
31
+ else {
32
+ repository = path;
33
+ tag = digest ? "" : "latest";
34
+ }
35
+ if (registry === DOCKER_HUB && !repository.includes("/")) {
36
+ repository = `library/${repository}`;
37
+ }
38
+ const name = repository.split("/").pop() ?? repository;
39
+ return { registry, repository, name, tag, digest };
40
+ }
41
+ const SEMVER_PREFIX_RE = /^v(?=\d)/;
42
+ export function normalizeVersion(tag) {
43
+ if (!tag || tag === "latest" || tag === "stable")
44
+ return "";
45
+ const dashIdx = tag.indexOf("-");
46
+ const core = dashIdx >= 0 ? tag.slice(0, dashIdx) : tag;
47
+ return core.replace(SEMVER_PREFIX_RE, "");
48
+ }
49
+ export function majorMinor(version) {
50
+ const parts = version.split(".");
51
+ if (parts.length === 0)
52
+ return version;
53
+ if (parts.length === 1)
54
+ return parts[0];
55
+ return `${parts[0]}.${parts[1]}`;
56
+ }
57
+ export function major(version) {
58
+ return version.split(".")[0] ?? version;
59
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseImage, normalizeVersion, majorMinor, major } from "./image-parser.js";
3
+ describe("parseImage", () => {
4
+ it("parses a bare image name", () => {
5
+ expect(parseImage("nginx")).toEqual({
6
+ registry: "docker.io",
7
+ repository: "library/nginx",
8
+ name: "nginx",
9
+ tag: "latest",
10
+ digest: undefined,
11
+ });
12
+ });
13
+ it("parses a bare image with a tag", () => {
14
+ expect(parseImage("nginx:1.24.0")).toMatchObject({
15
+ registry: "docker.io",
16
+ repository: "library/nginx",
17
+ name: "nginx",
18
+ tag: "1.24.0",
19
+ });
20
+ });
21
+ it("parses a docker hub user/repo image", () => {
22
+ expect(parseImage("bitnami/postgresql:15")).toMatchObject({
23
+ registry: "docker.io",
24
+ repository: "bitnami/postgresql",
25
+ name: "postgresql",
26
+ tag: "15",
27
+ });
28
+ });
29
+ it("parses a custom registry", () => {
30
+ expect(parseImage("quay.io/prometheus/prometheus:v2.45.0")).toMatchObject({
31
+ registry: "quay.io",
32
+ repository: "prometheus/prometheus",
33
+ name: "prometheus",
34
+ tag: "v2.45.0",
35
+ });
36
+ });
37
+ it("parses registry.k8s.io single-segment paths without forcing library/", () => {
38
+ expect(parseImage("registry.k8s.io/etcd:3.5.9-0")).toMatchObject({
39
+ registry: "registry.k8s.io",
40
+ repository: "etcd",
41
+ name: "etcd",
42
+ tag: "3.5.9-0",
43
+ });
44
+ });
45
+ it("parses a registry with a port", () => {
46
+ expect(parseImage("localhost:5000/mything:1.0")).toMatchObject({
47
+ registry: "localhost:5000",
48
+ repository: "mything",
49
+ name: "mything",
50
+ tag: "1.0",
51
+ });
52
+ });
53
+ it("parses a digest-only reference", () => {
54
+ const p = parseImage("nginx@sha256:abcdef");
55
+ expect(p.repository).toBe("library/nginx");
56
+ expect(p.tag).toBe("");
57
+ expect(p.digest).toBe("sha256:abcdef");
58
+ });
59
+ it("parses tag + digest", () => {
60
+ const p = parseImage("nginx:1.24@sha256:abcdef");
61
+ expect(p.tag).toBe("1.24");
62
+ expect(p.digest).toBe("sha256:abcdef");
63
+ });
64
+ });
65
+ describe("normalizeVersion", () => {
66
+ it("strips a leading v", () => {
67
+ expect(normalizeVersion("v1.2.3")).toBe("1.2.3");
68
+ });
69
+ it("strips suffixes like -alpine", () => {
70
+ expect(normalizeVersion("15.2-alpine")).toBe("15.2");
71
+ });
72
+ it("returns empty for placeholder tags", () => {
73
+ expect(normalizeVersion("latest")).toBe("");
74
+ expect(normalizeVersion("stable")).toBe("");
75
+ expect(normalizeVersion("")).toBe("");
76
+ });
77
+ });
78
+ describe("major/majorMinor", () => {
79
+ it("extracts major", () => {
80
+ expect(major("15.2.1")).toBe("15");
81
+ expect(major("11")).toBe("11");
82
+ });
83
+ it("extracts major.minor", () => {
84
+ expect(majorMinor("15.2.1")).toBe("15.2");
85
+ expect(majorMinor("11")).toBe("11");
86
+ });
87
+ });
@@ -0,0 +1,33 @@
1
+ import type { KubectlOptions } from "../kubectl.js";
2
+ import { type Candidate, type WorkloadRef } from "./sources.js";
3
+ import { type CveFinding } from "./osv.js";
4
+ import { type EolStatus } from "./eol.js";
5
+ export interface DetectedApp {
6
+ canonical: string;
7
+ display: string;
8
+ version: string;
9
+ sources: Array<Candidate["source"]>;
10
+ workloads: WorkloadRef[];
11
+ cves: CveFinding[];
12
+ eol: EolStatus | null;
13
+ catalogMatch: boolean;
14
+ }
15
+ export interface UnknownApp {
16
+ name: string;
17
+ version: string;
18
+ workloads: WorkloadRef[];
19
+ }
20
+ export interface DetectionResult {
21
+ apps: DetectedApp[];
22
+ unknowns: UnknownApp[];
23
+ scanned: {
24
+ workloads: number;
25
+ helmReleases: number;
26
+ };
27
+ }
28
+ export interface DetectOptions extends KubectlOptions {
29
+ noCache?: boolean;
30
+ onProgress?: (step: string) => void;
31
+ }
32
+ export declare function detectApplications(opts?: DetectOptions): Promise<DetectionResult>;
33
+ export declare function formatApplicationsMarkdown(result: DetectionResult): string;