@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.
- package/package.json +2 -2
- package/src/api/client.ts +30 -0
- package/src/components/CloudConnectFlow.tsx +21 -7
- package/src/components/CloudFunnelButton.tsx +85 -8
- package/src/components/cloudConnectHandoff.test.ts +10 -1
- package/src/components/cloudConnectHandoff.ts +15 -1
- package/src/components/diagnose/AIMarkdown.tsx +87 -0
- package/src/components/diagnose/ActivityTurn.tsx +1244 -0
- package/src/components/diagnose/AgentControls.tsx +603 -0
- package/src/components/diagnose/AnalysisStory.test.tsx +31 -25
- package/src/components/diagnose/AnalysisStory.tsx +13 -18
- package/src/components/diagnose/ApplyDialog.tsx +289 -0
- package/src/components/diagnose/AssessmentCard.tsx +1560 -0
- package/src/components/diagnose/DiagnoseSurface.test.tsx +9 -9
- package/src/components/diagnose/DiagnoseSurface.tsx +5 -1
- package/src/components/diagnose/EvidenceCard.tsx +611 -0
- package/src/components/diagnose/Home.test.tsx +1 -3
- package/src/components/diagnose/Home.tsx +2 -1
- package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +6 -8
- package/src/components/diagnose/InvestigationEvidencePane.test.tsx +5 -3
- package/src/components/diagnose/InvestigationEvidencePane.tsx +31 -3445
- package/src/components/diagnose/InvestigationEvidenceSections.tsx +523 -0
- package/src/components/diagnose/InvestigationView.tsx +11 -28
- package/src/components/diagnose/StoryExcerpt.tsx +118 -0
- package/src/components/diagnose/assessmentCopy.ts +169 -0
- package/src/components/diagnose/investigationCase.test.tsx +631 -2
- package/src/components/diagnose/investigationCase.ts +132 -24
- package/src/components/diagnose/investigationEvidence/adapters/coverage.test.tsx +660 -0
- package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx} +63 -0
- package/src/components/diagnose/investigationEvidence/adapters/helm.ts +70 -1
- package/src/components/diagnose/investigationEvidence/adapters/listings.ts +134 -0
- package/src/components/diagnose/investigationEvidence/adapters/{permissions.test.ts → permissions.test.tsx} +50 -0
- package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +18 -0
- package/src/components/diagnose/investigationEvidence/adapters/posture.ts +272 -0
- package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +49 -0
- package/src/components/diagnose/investigationEvidence/adapters/rankings.ts +147 -0
- package/src/components/diagnose/investigationEvidence/adapters/resource.ts +20 -10
- package/src/components/diagnose/investigationEvidence/bodies/index.tsx +127 -0
- package/src/components/diagnose/investigationEvidence/bodies/inventory.tsx +158 -0
- package/src/components/diagnose/investigationEvidence/bodies/metrics.tsx +373 -0
- package/src/components/diagnose/investigationEvidence/bodies/network.tsx +274 -0
- package/src/components/diagnose/investigationEvidence/bodies/platform.tsx +475 -0
- package/src/components/diagnose/investigationEvidence/bodies/posture.tsx +60 -0
- package/src/components/diagnose/investigationEvidence/bodies/ranking.tsx +76 -0
- package/src/components/diagnose/investigationEvidence/bodies/streams.tsx +212 -0
- package/src/components/diagnose/investigationEvidence/bodies/workload.tsx +335 -0
- package/src/components/diagnose/investigationEvidence/builder.ts +15 -0
- package/src/components/diagnose/investigationEvidence/cardParts.tsx +312 -0
- package/src/components/diagnose/investigationEvidence/excerpt.test.ts +105 -0
- package/src/components/diagnose/investigationEvidence/excerpt.ts +92 -0
- package/src/components/diagnose/investigationEvidence/identity.test.ts +5 -7
- package/src/components/diagnose/investigationEvidence/index.ts +1 -1
- package/src/components/diagnose/investigationEvidence/navigation.tsx +26 -0
- package/src/components/diagnose/investigationEvidence/observations.ts +11 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +1 -1
- package/src/components/diagnose/investigationEvidence/types.ts +97 -2
- package/src/components/diagnose/investigationEvidenceKinds.ts +22 -0
- package/src/components/diagnose/investigationEvidencePartition.ts +296 -0
- package/src/components/diagnose/parts.test.tsx +3 -1
- package/src/components/diagnose/parts.tsx +26 -3904
- package/src/components/diagnose/target.ts +9 -3
- package/src/components/diagnose/toolCallLabel.test.ts +47 -1
- package/src/components/diagnose/toolCallLabel.ts +41 -2
- package/src/components/helm/HelmReleaseDrawer.tsx +1 -1
- package/src/components/home/MCPSetupDialog.tsx +1 -1
- package/src/components/resource/PrometheusChartsGrid.tsx +1 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resource/WorkloadMetricsSection.tsx +1 -1
- package/src/components/resources/PodFilePreview.tsx +1 -1
- package/src/components/ui/CommandPalette.tsx +12 -8
- package/src/components/ui/DiagnosticsOverlay.tsx +7 -5
- package/src/components/ui/Omnibar.tsx +33 -10
- package/src/components/ui/ShortcutHelpOverlay.tsx +8 -5
- package/src/components/ui/Disclosure.tsx +0 -47
package/src/components/diagnose/investigationEvidence/adapters/{helm.test.ts → helm.test.tsx}
RENAMED
|
@@ -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
|
+
}
|