@skyhook-io/radar-app 1.14.2 → 1.14.3

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 (74) hide show
  1. package/package.json +2 -2
  2. package/src/api/client.ts +30 -0
  3. package/src/components/CloudConnectFlow.tsx +21 -7
  4. package/src/components/CloudFunnelButton.tsx +85 -8
  5. package/src/components/cloudConnectHandoff.test.ts +10 -1
  6. package/src/components/cloudConnectHandoff.ts +15 -1
  7. package/src/components/diagnose/AIMarkdown.tsx +87 -0
  8. package/src/components/diagnose/ActivityTurn.tsx +1244 -0
  9. package/src/components/diagnose/AgentControls.tsx +603 -0
  10. package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
  11. package/src/components/diagnose/AnalysisStory.tsx +13 -18
  12. package/src/components/diagnose/ApplyDialog.tsx +289 -0
  13. package/src/components/diagnose/AssessmentCard.tsx +1560 -0
  14. package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
  15. package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
  16. package/src/components/diagnose/EvidenceCard.tsx +611 -0
  17. package/src/components/diagnose/Home.test.tsx +1 -3
  18. package/src/components/diagnose/Home.tsx +2 -1
  19. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
  20. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
  21. package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
  22. package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
  23. package/src/components/diagnose/InvestigationView.tsx +11 -28
  24. package/src/components/diagnose/StoryExcerpt.tsx +118 -0
  25. package/src/components/diagnose/assessmentCopy.ts +169 -0
  26. package/src/components/diagnose/investigationCase.test.tsx +631 -2
  27. package/src/components/diagnose/investigationCase.ts +132 -24
  28. package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
  29. package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
  30. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
  31. package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
  38. package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
  39. package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
  40. package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
  41. package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
  42. package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
  43. package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
  44. package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
  45. package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
  46. package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
  48. package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
  49. package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
  50. package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
  51. package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
  52. package/src/components/diagnose/investigationEvidence/index.ts +1 -1
  53. package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
  54. package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
  55. package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
  56. package/src/components/diagnose/investigationEvidence/types.ts +97 -2
  57. package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
  58. package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
  59. package/src/components/diagnose/parts.test.tsx +3 -1
  60. package/src/components/diagnose/parts.tsx +26 -3904
  61. package/src/components/diagnose/target.ts +9 -3
  62. package/src/components/diagnose/toolCallLabel.test.ts +47 -1
  63. package/src/components/diagnose/toolCallLabel.ts +41 -2
  64. package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
  65. package/src/components/home/MCPSetupDialog.tsx +1 -1
  66. package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
  67. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  68. package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
  69. package/src/components/resources/PodFilePreview.tsx +1 -1
  70. package/src/components/ui/CommandPalette.tsx +12 -8
  71. package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
  72. package/src/components/ui/Omnibar.tsx +33 -10
  73. package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
  74. package/src/components/ui/Disclosure.tsx +0 -47
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { investigationEvidenceSubjectRef } from "../index";
3
3
  import { groupsOf, project, tool } from "../evidenceFixtures";
4
+ import { renderToStaticMarkup } from "react-dom/server";
5
+ import { HelmBody } from "../bodies/platform";
4
6
 
5
7
  const helmRelease = {
6
8
  name: "shop",
@@ -240,3 +242,64 @@ describe("helm release adapter", () => {
240
242
  ).toEqual(["Helm release", "Helm resources", "Helm operation"]);
241
243
  });
242
244
  });
245
+
246
+ describe("helm release card detail", () => {
247
+ it("keeps hooks, the newest ten revisions and the user's values, and renders them", () => {
248
+ const projection = project([
249
+ tool("helm", "get_helm_release", {
250
+ ...helmRelease,
251
+ history: [
252
+ {
253
+ revision: 5,
254
+ status: "superseded",
255
+ chart: "shop-1.4.0",
256
+ updated: "2026-09-01T07:00:00Z",
257
+ },
258
+ {
259
+ revision: 7,
260
+ status: "deployed",
261
+ chart: "shop-1.4.2",
262
+ updated: "2026-09-07T07:00:00Z",
263
+ description: "Upgrade complete",
264
+ },
265
+ {
266
+ revision: 6,
267
+ status: "failed",
268
+ chart: "shop-1.4.1",
269
+ updated: "2026-09-03T07:00:00Z",
270
+ description: "pre-upgrade hooks failed",
271
+ },
272
+ ],
273
+ hooks: [
274
+ {
275
+ name: "shop-test-connection",
276
+ kind: "Pod",
277
+ events: ["test"],
278
+ weight: 0,
279
+ status: "Succeeded",
280
+ completedAt: "2026-09-07T07:01:00Z",
281
+ },
282
+ ],
283
+ values: {
284
+ replicaCount: 2,
285
+ ui: { color: "#34577c", message: "hi" },
286
+ tags: ["a", "b"],
287
+ },
288
+ }),
289
+ ]);
290
+ const [group] = groupsOf(projection.groups, "helm");
291
+ const data = group.latest.data;
292
+ if (data.type !== "helm") throw new Error("expected a helm card");
293
+ expect(data.release.history?.map((rev) => rev.revision)).toEqual([7, 6, 5]);
294
+ expect(data.release.hooks?.[0].name).toBe("shop-test-connection");
295
+ const html = renderToStaticMarkup(<HelmBody data={data} />);
296
+ expect(html).toContain('data-helm-revision="6"');
297
+ expect(html).toContain("pre-upgrade hooks failed");
298
+ expect(html).toContain("data-helm-hook");
299
+ expect(html).toContain("shop-test-connection");
300
+ expect(html).toContain("Values set by the user");
301
+ expect(html).toContain("replicaCount");
302
+ expect(html).toContain("{ color, message }");
303
+ expect(html).toContain("[2 items]");
304
+ });
305
+ });
@@ -5,13 +5,15 @@ import {
5
5
  invalidPayload,
6
6
  resourceMatchesTarget,
7
7
  } from "../observations";
8
- import { nonEmptyString, record } from "../parse";
8
+ import { nonEmptyString, record, stringArray } from "../parse";
9
9
  import {
10
10
  type InvestigationEvidenceRelevance,
11
11
  type InvestigationEvidenceSource,
12
12
  type InvestigationHelmOperation,
13
13
  type InvestigationHelmOwnedResource,
14
14
  type InvestigationHelmRelease,
15
+ InvestigationHelmRevision,
16
+ InvestigationHelmHook,
15
17
  } from "../types";
16
18
 
17
19
  function helmOwnedResource(
@@ -39,6 +41,67 @@ function helmOwnedResource(
39
41
  }
40
42
  return candidate as unknown as InvestigationHelmOwnedResource;
41
43
  }
44
+ // Revisions are stored oldest first; the card reads newest first and keeps
45
+ // the ten that matter for a rollback decision.
46
+ function helmHistory(value: unknown): InvestigationHelmRevision[] | undefined {
47
+ if (!Array.isArray(value)) return undefined;
48
+ const revisions = value.flatMap((entry) => {
49
+ const item = record(entry);
50
+ if (
51
+ !item ||
52
+ typeof item.revision !== "number" ||
53
+ !nonEmptyString(item.status) ||
54
+ typeof item.chart !== "string" ||
55
+ typeof item.updated !== "string"
56
+ )
57
+ return [];
58
+ return [
59
+ {
60
+ revision: item.revision,
61
+ status: item.status,
62
+ chart: item.chart,
63
+ ...(nonEmptyString(item.appVersion)
64
+ ? { appVersion: item.appVersion }
65
+ : {}),
66
+ ...(nonEmptyString(item.description)
67
+ ? { description: item.description }
68
+ : {}),
69
+ updated: item.updated,
70
+ },
71
+ ];
72
+ });
73
+ if (revisions.length === 0) return undefined;
74
+ return revisions.sort((a, b) => b.revision - a.revision).slice(0, 10);
75
+ }
76
+
77
+ function helmHooks(value: unknown): InvestigationHelmHook[] | undefined {
78
+ if (!Array.isArray(value)) return undefined;
79
+ const hooks = value.flatMap((entry) => {
80
+ const item = record(entry);
81
+ if (!item || !nonEmptyString(item.name) || !nonEmptyString(item.kind))
82
+ return [];
83
+ return [
84
+ {
85
+ name: item.name,
86
+ kind: item.kind,
87
+ ...(nonEmptyString(item.namespace)
88
+ ? { namespace: item.namespace }
89
+ : {}),
90
+ events: stringArray(item.events) ?? [],
91
+ weight: typeof item.weight === "number" ? item.weight : 0,
92
+ ...(nonEmptyString(item.status) ? { status: item.status } : {}),
93
+ ...(nonEmptyString(item.startedAt)
94
+ ? { startedAt: item.startedAt }
95
+ : {}),
96
+ ...(nonEmptyString(item.completedAt)
97
+ ? { completedAt: item.completedAt }
98
+ : {}),
99
+ },
100
+ ];
101
+ });
102
+ return hooks.length > 0 ? hooks : undefined;
103
+ }
104
+
42
105
  function helmOperation(value: unknown): InvestigationHelmOperation | undefined {
43
106
  const candidate = record(value);
44
107
  if (
@@ -109,6 +172,9 @@ export function adaptHelmRelease(
109
172
  invalidPayload(builder, source, "Helm resources");
110
173
  return;
111
174
  }
175
+ const history = helmHistory(value.history);
176
+ const hooks = helmHooks(value.hooks);
177
+ const values = record(value.values) ?? undefined;
112
178
  const lastOperation =
113
179
  value.lastOperation === undefined
114
180
  ? undefined
@@ -137,6 +203,9 @@ export function adaptHelmRelease(
137
203
  string | undefined,
138
204
  lastOperation,
139
205
  resources,
206
+ ...(history ? { history } : {}),
207
+ ...(hooks ? { hooks } : {}),
208
+ ...(values ? { values } : {}),
140
209
  };
141
210
  // The release manages the target when the target is among the resources
142
211
  // Helm rendered for it; a release is never the investigated object itself.
@@ -0,0 +1,134 @@
1
+ import { adaptListResources } from "./resource";
2
+ import { nonEmptyString, parseJSON, record, stringArray } from "../parse";
3
+ import { invalidPayload, type ProjectionBuilder } from "../observations";
4
+ import type { InvestigationEvidenceSource } from "../types";
5
+
6
+ // Helm releases, installed packages and search hits are inventories: rows
7
+ // with a kind, a name and a namespace, one line of status, and for a hit
8
+ // the text that matched. They render on the inventory card so a citation
9
+ // can name a row the way it names a resource in a listing.
10
+
11
+ export function adaptListHelmReleases(
12
+ builder: ProjectionBuilder,
13
+ source: InvestigationEvidenceSource,
14
+ payload: unknown,
15
+ ): void {
16
+ if (!Array.isArray(payload)) {
17
+ invalidPayload(builder, source);
18
+ return;
19
+ }
20
+ const args = record(parseJSON(source.args ?? ""));
21
+ const namespace = nonEmptyString(args?.namespace)
22
+ ? args.namespace
23
+ : undefined;
24
+ adaptListResources(
25
+ builder,
26
+ source,
27
+ payload.map((entry) => {
28
+ const item = record(entry);
29
+ if (!item) return entry;
30
+ const chart = [item.chart, item.chartVersion]
31
+ .filter((part) => nonEmptyString(part))
32
+ .join(" ");
33
+ return {
34
+ kind: "HelmRelease",
35
+ name: item.name,
36
+ namespace: item.namespace,
37
+ status: [item.status, chart ? chart : undefined]
38
+ .filter(Boolean)
39
+ .join(" · "),
40
+ ...(nonEmptyString(item.healthIssue)
41
+ ? { issue: item.healthIssue }
42
+ : {}),
43
+ };
44
+ }),
45
+ { title: namespace ? `Helm releases in ${namespace}` : "Helm releases" },
46
+ );
47
+ }
48
+
49
+ export function adaptListPackages(
50
+ builder: ProjectionBuilder,
51
+ source: InvestigationEvidenceSource,
52
+ payload: unknown,
53
+ ): void {
54
+ const value = record(payload);
55
+ const rows = value?.packages;
56
+ if (!value || !Array.isArray(rows)) {
57
+ invalidPayload(builder, source);
58
+ return;
59
+ }
60
+ const legend = record(value.sourceLegend) ?? {};
61
+ const args = record(parseJSON(source.args ?? ""));
62
+ const namespace = nonEmptyString(args?.namespace)
63
+ ? args.namespace
64
+ : undefined;
65
+ adaptListResources(
66
+ builder,
67
+ source,
68
+ rows.map((entry) => {
69
+ const item = record(entry);
70
+ if (!item) return entry;
71
+ const sources = (stringArray(item.sources) ?? []).map((code) =>
72
+ nonEmptyString(legend[code]) ? legend[code] : code,
73
+ );
74
+ const health = nonEmptyString(item.health)
75
+ ? item.health.toLowerCase()
76
+ : undefined;
77
+ const status = [
78
+ nonEmptyString(item.version) ? item.version : undefined,
79
+ sources.length > 0 ? sources.join(", ") : undefined,
80
+ ]
81
+ .filter(Boolean)
82
+ .join(" · ");
83
+ return {
84
+ kind: "Package",
85
+ name: nonEmptyString(item.releaseName) ? item.releaseName : item.chart,
86
+ namespace: item.namespace,
87
+ status,
88
+ ...(health === "degraded" || health === "unhealthy"
89
+ ? { issue: health }
90
+ : {}),
91
+ };
92
+ }),
93
+ { title: namespace ? `Packages in ${namespace}` : "Installed packages" },
94
+ );
95
+ }
96
+
97
+ export function adaptSearch(
98
+ builder: ProjectionBuilder,
99
+ source: InvestigationEvidenceSource,
100
+ payload: unknown,
101
+ ): void {
102
+ const value = record(payload);
103
+ const hits = value?.hits;
104
+ if (!value || !Array.isArray(hits)) {
105
+ invalidPayload(builder, source);
106
+ return;
107
+ }
108
+ const args = record(parseJSON(source.args ?? ""));
109
+ const query = nonEmptyString(args?.query) ? args.query : undefined;
110
+ adaptListResources(
111
+ builder,
112
+ source,
113
+ hits.map((entry) => {
114
+ const item = record(entry);
115
+ if (!item) return entry;
116
+ const snippets = Array.isArray(item.snippets) ? item.snippets : [];
117
+ const matched = Array.isArray(item.matched) ? item.matched : [];
118
+ const first = record(snippets[0]);
119
+ const site = record(matched[0]);
120
+ const match = first
121
+ ? `${nonEmptyString(first.path) ? `${first.path}: ` : ""}${nonEmptyString(first.snippet) ? first.snippet : ""}`
122
+ : site && nonEmptyString(site.site)
123
+ ? `matched ${site.site}`
124
+ : undefined;
125
+ return {
126
+ kind: item.kind,
127
+ name: item.name,
128
+ namespace: item.namespace,
129
+ ...(match ? { match } : {}),
130
+ };
131
+ }),
132
+ { title: query ? `Search results for “${query}”` : "Search results" },
133
+ );
134
+ }
@@ -4,6 +4,8 @@ import {
4
4
  projectInvestigationEvidence,
5
5
  } from "../index";
6
6
  import { deployment, groupsOf, project, tool } from "../evidenceFixtures";
7
+ import { renderToStaticMarkup } from "react-dom/server";
8
+ import { PermissionsBody } from "../bodies/platform";
7
9
 
8
10
  const deploymentWithServiceAccount = {
9
11
  ...deployment,
@@ -526,3 +528,51 @@ describe("subject permissions adapter", () => {
526
528
  expect(projection.limitations[0].sources).toHaveLength(3);
527
529
  });
528
530
  });
531
+
532
+ describe("subject permissions card detail", () => {
533
+ it("keeps the effective rules and renders them", () => {
534
+ const projection = project([
535
+ tool("diagnose", "diagnose", {
536
+ resource: deploymentWithServiceAccount,
537
+ resourceContext: { tier: "basic" },
538
+ }),
539
+ tool(
540
+ "perm",
541
+ "get_subject_permissions",
542
+ {
543
+ subject: {
544
+ kind: "ServiceAccount",
545
+ namespace: "shop",
546
+ name: "api-sa",
547
+ },
548
+ bindings: [],
549
+ flatRules: [
550
+ {
551
+ verbs: ["get", "list"],
552
+ apiGroups: [""],
553
+ resources: ["pods", "pods/log"],
554
+ },
555
+ {
556
+ verbs: ["get"],
557
+ apiGroups: [""],
558
+ resources: ["secrets"],
559
+ resourceNames: ["db-creds"],
560
+ },
561
+ ],
562
+ truncated: false,
563
+ },
564
+ { summary: permissionsArgs },
565
+ ),
566
+ ]);
567
+ const [group] = groupsOf(projection.groups, "permissions");
568
+ const data = group.latest.data;
569
+ if (data.type !== "permissions")
570
+ throw new Error("expected a permissions card");
571
+ expect(data.rules?.length).toBe(2);
572
+ const html = renderToStaticMarkup(<PermissionsBody data={data} />);
573
+ expect(html).toContain("Effective rules");
574
+ expect(html).toContain("get, list");
575
+ expect(html).toContain("pods, pods/log");
576
+ expect(html).toContain("[db-creds]");
577
+ });
578
+ });
@@ -18,6 +18,7 @@ import {
18
18
  type InvestigationEvidenceSource,
19
19
  type InvestigationKubernetesResource,
20
20
  type InvestigationPermissionBinding,
21
+ InvestigationPermissionRule,
21
22
  type InvestigationPermissionSubject,
22
23
  } from "../types";
23
24
 
@@ -248,6 +249,22 @@ export function adaptSubjectPermissions(
248
249
  }
249
250
  const truncated = value.truncated === true;
250
251
  const flatRulesCount = value.flatRules.length;
252
+ const rules: InvestigationPermissionRule[] = value.flatRules
253
+ .slice(0, 200)
254
+ .map((entry) => {
255
+ const rule = record(entry)!;
256
+ return {
257
+ verbs: stringArray(rule.verbs) ?? [],
258
+ apiGroups: stringArray(rule.apiGroups) ?? [],
259
+ resources: stringArray(rule.resources) ?? [],
260
+ ...(stringArray(rule.resourceNames)?.length
261
+ ? { resourceNames: stringArray(rule.resourceNames) }
262
+ : {}),
263
+ ...(stringArray(rule.nonResourceURLs)?.length
264
+ ? { nonResourceURLs: stringArray(rule.nonResourceURLs) }
265
+ : {}),
266
+ };
267
+ });
251
268
  const usedByPods = stringArray(value.usedByPods) ?? [];
252
269
  builder.observe(`permissions:subject:${subjectKey}`, "permissions", source, {
253
270
  tier: evidenceTierForRelevance("context", relevance),
@@ -260,6 +277,7 @@ export function adaptSubjectPermissions(
260
277
  subject,
261
278
  bindings,
262
279
  flatRulesCount,
280
+ rules,
263
281
  truncated,
264
282
  usedByPods,
265
283
  podsTotal: value.podsTotal as number | undefined,
@@ -0,0 +1,272 @@
1
+ import { nonEmptyString, record, stringArray } from "../parse";
2
+ import {
3
+ invalidPayload,
4
+ evidenceTierForRelevance,
5
+ relevanceForResource,
6
+ scopeFromArgs,
7
+ type ProjectionBuilder,
8
+ } from "../observations";
9
+ import type {
10
+ InvestigationEvidenceSource,
11
+ InvestigationPostureFinding,
12
+ } from "../types";
13
+
14
+ // Posture findings are configuration checks. Only the ones about the
15
+ // investigated resource are evidence here; the rest of a cluster-wide scan
16
+ // stays in Activity, counted on the receipt.
17
+ function keep(
18
+ builder: ProjectionBuilder,
19
+ source: InvestigationEvidenceSource,
20
+ origin: "audit" | "upgrade",
21
+ findings: InvestigationPostureFinding[],
22
+ relevances: string[],
23
+ missing: string[],
24
+ label: string,
25
+ ): void {
26
+ const kept = findings.filter((_, index) => relevances[index] !== "broader");
27
+ const scope = scopeFromArgs(source);
28
+ if (missing.length > 0) {
29
+ builder.limit(
30
+ source,
31
+ label,
32
+ `Inputs missing from the scan: ${missing.join(", ")}.`,
33
+ "unknown",
34
+ );
35
+ }
36
+ if (kept.length === 0) {
37
+ if (!source.confirmedSuccess) return;
38
+ builder.observe(
39
+ `posture:${origin}:receipt:${source.args ?? scope}`,
40
+ "receipt",
41
+ source,
42
+ {
43
+ tier: evidenceTierForRelevance("checked", "producer-related"),
44
+ relevance: "producer-related",
45
+ tone: "neutral",
46
+ title: `No ${label.toLowerCase()} on this resource`,
47
+ summary: scope,
48
+ data: {
49
+ type: "receipt",
50
+ checked: "posture",
51
+ scope,
52
+ message:
53
+ [
54
+ findings.length > 0
55
+ ? `${findings.length} finding${findings.length === 1 ? "" : "s"} elsewhere in the scan.`
56
+ : undefined,
57
+ missing.length > 0
58
+ ? `Not every check ran: inputs missing for ${missing.join(", ")}.`
59
+ : undefined,
60
+ ]
61
+ .filter(Boolean)
62
+ .join(" ") || undefined,
63
+ },
64
+ },
65
+ );
66
+ return;
67
+ }
68
+ const onTarget = kept.filter((finding) => finding.target).length;
69
+ const relevance = onTarget > 0 ? "target" : "producer-related";
70
+ // Two expansions of one scan differ in what they asked for (an upgrade
71
+ // check id, an audit category), which the scope alone does not carry.
72
+ builder.observe(
73
+ `posture:${origin}:${source.args ?? scope}`,
74
+ "posture",
75
+ source,
76
+ {
77
+ tier: evidenceTierForRelevance("context", relevance),
78
+ relevance,
79
+ tone: "neutral",
80
+ title: label,
81
+ summary: `${kept.length} finding${kept.length === 1 ? "" : "s"}${onTarget > 0 ? ` · ${onTarget} on this resource` : ""} · ${scope}`,
82
+ data: { type: "posture", source: origin, findings: kept, scope },
83
+ },
84
+ );
85
+ }
86
+
87
+ function relevanceOf(
88
+ builder: ProjectionBuilder,
89
+ ref: { kind: string; group?: string; namespace?: string; name: string },
90
+ ): string {
91
+ // An audit names its resource as Kind/namespace/name with no API group; the
92
+ // investigation target's group stands in, as it does for events and changes.
93
+ return relevanceForResource(builder, {
94
+ kind: ref.kind,
95
+ group: ref.group ?? builder.target.group,
96
+ namespace: ref.namespace,
97
+ name: ref.name,
98
+ });
99
+ }
100
+
101
+ export function adaptClusterAudit(
102
+ builder: ProjectionBuilder,
103
+ source: InvestigationEvidenceSource,
104
+ payload: unknown,
105
+ ): void {
106
+ const value = record(payload);
107
+ if (!value || !Array.isArray(value.findings)) {
108
+ invalidPayload(builder, source);
109
+ return;
110
+ }
111
+ const findings: InvestigationPostureFinding[] = [];
112
+ const relevances: string[] = [];
113
+ for (const entry of value.findings) {
114
+ const item = record(entry);
115
+ if (!item || !nonEmptyString(item.resource) || !nonEmptyString(item.check))
116
+ continue;
117
+ // "Deployment/default/web" or "Node//worker-1" for a cluster-scoped kind.
118
+ const [kind, namespace, name] = item.resource.split("/");
119
+ if (!kind || name === undefined) continue;
120
+ const ref = { kind, namespace: namespace || undefined, name };
121
+ const relevance = relevanceOf(builder, ref);
122
+ findings.push({
123
+ ...ref,
124
+ check: item.check,
125
+ severity: nonEmptyString(item.severity) ? item.severity : "medium",
126
+ ...(nonEmptyString(item.category) ? { category: item.category } : {}),
127
+ message: nonEmptyString(item.message) ? item.message : item.check,
128
+ ...(nonEmptyString(item.remediation)
129
+ ? { remediation: item.remediation }
130
+ : {}),
131
+ target: relevance === "target",
132
+ });
133
+ relevances.push(relevance);
134
+ }
135
+ if (value.truncated === true) {
136
+ builder.limit(
137
+ source,
138
+ "Posture findings",
139
+ "The scan hit its cap; findings past it were not returned. A namespace-scoped scan shows the rest.",
140
+ "unknown",
141
+ );
142
+ }
143
+ keep(
144
+ builder,
145
+ source,
146
+ "audit",
147
+ findings,
148
+ relevances,
149
+ stringArray(value.missingInputs) ?? [],
150
+ "Posture findings",
151
+ );
152
+ }
153
+
154
+ export function adaptUpgradeReadiness(
155
+ builder: ProjectionBuilder,
156
+ source: InvestigationEvidenceSource,
157
+ payload: unknown,
158
+ ): void {
159
+ const value = record(payload);
160
+ if (!value) {
161
+ invalidPayload(builder, source);
162
+ return;
163
+ }
164
+ // The tier-1 result lists checks without findings; a check=<id> call
165
+ // carries the findings under "check".
166
+ const check = record(value.check);
167
+ const rawFindings =
168
+ check && Array.isArray(check.findings) ? check.findings : [];
169
+ const target = nonEmptyString(value.targetVersion)
170
+ ? value.targetVersion
171
+ : undefined;
172
+ const label = target ? `Upgrade findings for ${target}` : "Upgrade findings";
173
+ if (!check) {
174
+ if (!source.confirmedSuccess) return;
175
+ const verdict = nonEmptyString(value.verdict)
176
+ ? value.verdict.replaceAll("_", " ")
177
+ : undefined;
178
+ builder.observe(
179
+ `posture:upgrade:verdict:${scopeFromArgs(source)}`,
180
+ "receipt",
181
+ source,
182
+ {
183
+ tier: evidenceTierForRelevance("checked", "broader"),
184
+ relevance: "broader",
185
+ tone: "neutral",
186
+ title: label,
187
+ summary: verdict ? `verdict ${verdict}` : scopeFromArgs(source),
188
+ data: {
189
+ type: "receipt",
190
+ checked: "posture",
191
+ scope: scopeFromArgs(source),
192
+ message: nonEmptyString(value.verdictCaveat)
193
+ ? value.verdictCaveat
194
+ : undefined,
195
+ },
196
+ },
197
+ );
198
+ return;
199
+ }
200
+ const findings: InvestigationPostureFinding[] = [];
201
+ const relevances: string[] = [];
202
+ for (const entry of rawFindings) {
203
+ const item = record(entry);
204
+ const resource = record(item?.resource);
205
+ const managedBy = record(item?.managedBy);
206
+ const named = resource ?? managedBy;
207
+ if (
208
+ !item ||
209
+ !named ||
210
+ !nonEmptyString(named.kind) ||
211
+ !nonEmptyString(named.name)
212
+ )
213
+ continue;
214
+ const refOf = (value: Record<string, unknown>) => ({
215
+ kind: String(value.kind),
216
+ group: nonEmptyString(value.group) ? value.group : undefined,
217
+ namespace: nonEmptyString(value.namespace) ? value.namespace : undefined,
218
+ name: String(value.name),
219
+ });
220
+ const ref = refOf(named);
221
+ // A finding on an object the workload owns (its HPA, its Ingress) is a
222
+ // finding about the workload, and the workload's name reaches it.
223
+ const owner =
224
+ resource &&
225
+ managedBy &&
226
+ nonEmptyString(managedBy.kind) &&
227
+ nonEmptyString(managedBy.name)
228
+ ? refOf(managedBy)
229
+ : undefined;
230
+ let relevance = relevanceOf(builder, ref);
231
+ if (relevance === "broader" && owner) {
232
+ relevance = relevanceOf(builder, owner);
233
+ }
234
+ const evidence = record(item.evidence);
235
+ findings.push({
236
+ ...ref,
237
+ check: nonEmptyString(item.title)
238
+ ? item.title
239
+ : nonEmptyString(check.title)
240
+ ? check.title
241
+ : "Upgrade check",
242
+ severity: nonEmptyString(item.level) ? item.level : "review",
243
+ category: nonEmptyString(check.category) ? check.category : undefined,
244
+ message: [
245
+ nonEmptyString(item.impact) ? item.impact : undefined,
246
+ evidence && nonEmptyString(evidence.detail)
247
+ ? evidence.detail
248
+ : undefined,
249
+ ]
250
+ .filter(Boolean)
251
+ .join(" "),
252
+ ...(nonEmptyString(item.remediation)
253
+ ? { remediation: item.remediation }
254
+ : {}),
255
+ target: relevance === "target",
256
+ ...(owner ? { managedBy: owner } : {}),
257
+ });
258
+ relevances.push(relevance);
259
+ }
260
+ if (
261
+ typeof check.findingsTruncated === "number" &&
262
+ check.findingsTruncated > 0
263
+ ) {
264
+ builder.limit(
265
+ source,
266
+ label,
267
+ `${check.findingsTruncated} more finding${check.findingsTruncated === 1 ? "" : "s"} past this page were not returned; the next page has them.`,
268
+ "unknown",
269
+ );
270
+ }
271
+ keep(builder, source, "upgrade", findings, relevances, [], label);
272
+ }