@skyhook-io/k8s-ui 1.6.1 → 1.7.0
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 +6 -1
- package/src/components/charts/AreaChart.tsx +371 -0
- package/src/components/charts/MetricsSummary.tsx +49 -0
- package/src/components/charts/SeriesLegend.tsx +27 -0
- package/src/components/charts/colors.ts +49 -0
- package/src/components/charts/format.ts +41 -0
- package/src/components/charts/index.ts +14 -0
- package/src/components/charts/saturation.test.ts +60 -0
- package/src/components/charts/saturation.ts +32 -0
- package/src/components/charts/types.ts +36 -0
- package/src/components/compare/CompareResourcePicker.tsx +192 -0
- package/src/components/compare/CompareTray.tsx +130 -0
- package/src/components/compare/ResourceCompareView.tsx +272 -0
- package/src/components/compare/index.ts +14 -0
- package/src/components/compare/normalize.test.ts +193 -0
- package/src/components/compare/normalize.ts +69 -0
- package/src/components/compare/picks.test.ts +97 -0
- package/src/components/compare/picks.ts +35 -0
- package/src/components/compare/sort.test.ts +78 -0
- package/src/components/compare/sort.ts +26 -0
- package/src/components/compare/types.ts +43 -0
- package/src/components/compare/url.test.ts +61 -0
- package/src/components/compare/url.ts +26 -0
- package/src/components/resources/ResourcesView.tsx +431 -96
- package/src/components/resources/index.ts +1 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
- package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
- package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
- package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
- package/src/components/resources/renderers/PodRenderer.tsx +207 -2
- package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
- package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
- package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
- package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
- package/src/components/resources/renderers/flux-cells.tsx +12 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
- package/src/components/resources/resource-utils-crossplane.ts +325 -0
- package/src/components/resources/resource-utils-flux.ts +14 -0
- package/src/components/resources/resource-utils.ts +1 -0
- package/src/components/resources/resources-column-filter.test.ts +74 -0
- package/src/components/shared/ResourceActionsBar.tsx +77 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
- package/src/components/topology/K8sResourceNode.tsx +21 -2
- package/src/components/topology/TopologyGraph.tsx +46 -10
- package/src/components/topology/layout.ts +20 -11
- package/src/components/topology/layout.worker.ts +21 -17
- package/src/components/ui/YamlEditor.tsx +28 -15
- package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
- package/src/index.ts +6 -0
- package/src/perf/index.ts +1 -0
- package/src/perf/store.ts +110 -0
- package/src/types/core.ts +10 -0
- package/src/types/index.ts +1 -0
- package/src/types/rbac.ts +99 -0
- package/src/utils/api-resources.ts +9 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/rbac-badges.ts +61 -0
- package/src/utils/rbac-blast-radius.test.ts +137 -0
- package/src/utils/rbac-blast-radius.ts +98 -0
- package/src/utils/resource-hierarchy.ts +13 -3
- package/src/utils/structure-hash.test.ts +94 -0
- package/src/utils/structure-hash.ts +35 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Always-on, in-memory performance instrumentation for k8s-ui internals.
|
|
2
|
+
// Records ELK layout duration and structureKey rebuild duration so users can
|
|
3
|
+
// include them in bug reports via the host app's diagnostics overlay.
|
|
4
|
+
// Cost is one performance.now() pair + a 50-entry ring buffer append per
|
|
5
|
+
// topology layout — negligible.
|
|
6
|
+
|
|
7
|
+
const RING_SIZE = 50
|
|
8
|
+
|
|
9
|
+
interface Ring {
|
|
10
|
+
samples: number[]
|
|
11
|
+
next: number
|
|
12
|
+
count: number
|
|
13
|
+
last: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeRing(): Ring {
|
|
17
|
+
return { samples: new Array(RING_SIZE), next: 0, count: 0, last: 0 }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ringAdd(r: Ring, v: number): void {
|
|
21
|
+
r.samples[r.next] = v
|
|
22
|
+
r.next = (r.next + 1) % RING_SIZE
|
|
23
|
+
if (r.count < RING_SIZE) r.count++
|
|
24
|
+
r.last = v
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SampleWindow {
|
|
28
|
+
count: number
|
|
29
|
+
last: number
|
|
30
|
+
min: number
|
|
31
|
+
p50: number
|
|
32
|
+
p95: number
|
|
33
|
+
p99: number
|
|
34
|
+
max: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ringSnapshot(r: Ring): SampleWindow {
|
|
38
|
+
if (r.count === 0) return { count: 0, last: 0, min: 0, p50: 0, p95: 0, p99: 0, max: 0 }
|
|
39
|
+
const buf = r.samples.slice(0, r.count).sort((a, b) => a - b)
|
|
40
|
+
const pick = (p: number) => buf[Math.min(buf.length - 1, Math.floor((buf.length - 1) * p))]
|
|
41
|
+
return {
|
|
42
|
+
count: r.count,
|
|
43
|
+
last: r.last,
|
|
44
|
+
min: buf[0],
|
|
45
|
+
p50: pick(0.5),
|
|
46
|
+
p95: pick(0.95),
|
|
47
|
+
p99: pick(0.99),
|
|
48
|
+
max: buf[buf.length - 1],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const layoutMs = makeRing()
|
|
53
|
+
const structureKeyUs = makeRing()
|
|
54
|
+
const lastLayoutNodeCount = { value: 0 }
|
|
55
|
+
const lastLayoutEdgeCount = { value: 0 }
|
|
56
|
+
let totalLayouts = 0
|
|
57
|
+
let totalLayoutsSkipped = 0
|
|
58
|
+
let totalStructureKeyComputes = 0
|
|
59
|
+
|
|
60
|
+
export interface K8sUIPerfSnapshot {
|
|
61
|
+
totalLayouts: number
|
|
62
|
+
totalLayoutsSkipped: number
|
|
63
|
+
totalStructureKeyComputes: number
|
|
64
|
+
lastLayoutNodeCount: number
|
|
65
|
+
lastLayoutEdgeCount: number
|
|
66
|
+
layoutMs: SampleWindow
|
|
67
|
+
structureKeyUs: SampleWindow
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function recordLayoutDuration(ms: number, nodeCount: number, edgeCount: number): void {
|
|
71
|
+
totalLayouts++
|
|
72
|
+
ringAdd(layoutMs, ms)
|
|
73
|
+
lastLayoutNodeCount.value = nodeCount
|
|
74
|
+
lastLayoutEdgeCount.value = edgeCount
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function recordLayoutSkipped(): void {
|
|
78
|
+
totalLayoutsSkipped++
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function recordStructureKeyDuration(us: number): void {
|
|
82
|
+
totalStructureKeyComputes++
|
|
83
|
+
ringAdd(structureKeyUs, us)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getK8sUIPerfSnapshot(): K8sUIPerfSnapshot {
|
|
87
|
+
return {
|
|
88
|
+
totalLayouts,
|
|
89
|
+
totalLayoutsSkipped,
|
|
90
|
+
totalStructureKeyComputes,
|
|
91
|
+
lastLayoutNodeCount: lastLayoutNodeCount.value,
|
|
92
|
+
lastLayoutEdgeCount: lastLayoutEdgeCount.value,
|
|
93
|
+
layoutMs: ringSnapshot(layoutMs),
|
|
94
|
+
structureKeyUs: ringSnapshot(structureKeyUs),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Test seam — reset all counters and windows. Not safe to call concurrently
|
|
99
|
+
// with the record functions.
|
|
100
|
+
export function resetK8sUIPerf(): void {
|
|
101
|
+
layoutMs.samples = new Array(RING_SIZE)
|
|
102
|
+
layoutMs.next = 0; layoutMs.count = 0; layoutMs.last = 0
|
|
103
|
+
structureKeyUs.samples = new Array(RING_SIZE)
|
|
104
|
+
structureKeyUs.next = 0; structureKeyUs.count = 0; structureKeyUs.last = 0
|
|
105
|
+
totalLayouts = 0
|
|
106
|
+
totalLayoutsSkipped = 0
|
|
107
|
+
totalStructureKeyComputes = 0
|
|
108
|
+
lastLayoutNodeCount.value = 0
|
|
109
|
+
lastLayoutEdgeCount.value = 0
|
|
110
|
+
}
|
package/src/types/core.ts
CHANGED
|
@@ -212,9 +212,19 @@ export interface Topology {
|
|
|
212
212
|
largeCluster?: boolean // True if cluster exceeds large cluster threshold
|
|
213
213
|
hiddenKinds?: string[] // Resource kinds auto-hidden for performance
|
|
214
214
|
requiresNamespaceFilter?: boolean // True if cluster is too large for all-namespace topology
|
|
215
|
+
estimatedNodes?: number // Pre-build node count estimate
|
|
216
|
+
summaryMode?: boolean // True when the pod tier was collapsed into per-workload/service counts
|
|
215
217
|
crdDiscoveryStatus?: 'idle' | 'discovering' | 'ready' // CRD discovery status
|
|
216
218
|
}
|
|
217
219
|
|
|
220
|
+
// PodSummary is stamped onto a workload or service node's data in summary mode.
|
|
221
|
+
export interface PodSummary {
|
|
222
|
+
total: number
|
|
223
|
+
healthy: number
|
|
224
|
+
degraded: number
|
|
225
|
+
unhealthy: number
|
|
226
|
+
}
|
|
227
|
+
|
|
218
228
|
// K8s Event (from SSE stream)
|
|
219
229
|
export interface K8sEvent {
|
|
220
230
|
kind: string
|
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Wire types for the /api/rbac/* endpoints, kept in the shared package so
|
|
2
|
+
// both the host (which fetches) and the renderers (which display) agree on
|
|
3
|
+
// the shape. Mirrors the Go types in internal/server/rbac_handlers.go and
|
|
4
|
+
// pkg/rbac/index.go.
|
|
5
|
+
|
|
6
|
+
export interface RBACSubject {
|
|
7
|
+
kind: string // "ServiceAccount" | "User" | "Group"
|
|
8
|
+
namespace: string // empty for User/Group
|
|
9
|
+
name: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RBACRoleRef {
|
|
13
|
+
kind: string // "Role" | "ClusterRole"
|
|
14
|
+
namespace: string // empty for ClusterRole
|
|
15
|
+
name: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RBACBindingRef {
|
|
19
|
+
kind: string // "RoleBinding" | "ClusterRoleBinding"
|
|
20
|
+
namespace: string // empty for ClusterRoleBinding
|
|
21
|
+
name: string
|
|
22
|
+
roleRef: RBACRoleRef
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RBACPolicyRule {
|
|
26
|
+
verbs?: string[]
|
|
27
|
+
apiGroups?: string[]
|
|
28
|
+
resources?: string[]
|
|
29
|
+
resourceNames?: string[]
|
|
30
|
+
nonResourceURLs?: string[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RBACBindingRules {
|
|
34
|
+
binding: RBACBindingRef
|
|
35
|
+
role: RBACRoleRef
|
|
36
|
+
rules: RBACPolicyRule[]
|
|
37
|
+
/** Populated when a RoleBinding references a ClusterRole — rules apply
|
|
38
|
+
* only in this namespace. */
|
|
39
|
+
scopeNamespace?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RBACInheritedGroup {
|
|
43
|
+
groupName: string // e.g. "system:authenticated"
|
|
44
|
+
bindings: RBACBindingRules[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RBACSubjectResponse {
|
|
48
|
+
subject: RBACSubject
|
|
49
|
+
direct: RBACBindingRules[]
|
|
50
|
+
inheritedFromGroups: RBACInheritedGroup[]
|
|
51
|
+
flat: RBACPolicyRule[]
|
|
52
|
+
truncated: boolean
|
|
53
|
+
/** Pods whose spec.serviceAccountName references this SA. Populated only
|
|
54
|
+
* for ServiceAccount subjects; undefined or empty for User/Group. */
|
|
55
|
+
usedByPods?: RBACPodRef[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RBACPodRef {
|
|
59
|
+
namespace: string
|
|
60
|
+
name: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RBACNamespaceResponse {
|
|
64
|
+
namespace: string
|
|
65
|
+
roleBindings: RBACBindingWithSubjects[]
|
|
66
|
+
/** ClusterRoleBindings with at least one subject in this namespace. */
|
|
67
|
+
clusterRoleBindingsWithLocalSubject: RBACBindingWithSubjects[]
|
|
68
|
+
serviceAccountCount: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RBACBindingWithSubjects {
|
|
72
|
+
binding: RBACBindingRef
|
|
73
|
+
subjects: RBACSubject[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RBACRoleResponse {
|
|
77
|
+
role: RBACRoleRef
|
|
78
|
+
bindings: RBACBindingWithSubjects[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RBACResourceRule {
|
|
82
|
+
verbs: string[]
|
|
83
|
+
apiGroups?: string[]
|
|
84
|
+
resources?: string[]
|
|
85
|
+
resourceNames?: string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface RBACNonResourceRule {
|
|
89
|
+
verbs: string[]
|
|
90
|
+
nonResourceURLs?: string[]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface RBACWhoamiResponse {
|
|
94
|
+
namespace: string
|
|
95
|
+
resourceRules: RBACResourceRule[]
|
|
96
|
+
nonResourceRules: RBACNonResourceRule[]
|
|
97
|
+
incomplete: boolean
|
|
98
|
+
evaluationError?: string
|
|
99
|
+
}
|
|
@@ -124,8 +124,11 @@ export function formatGroupName(group: string): string {
|
|
|
124
124
|
'gateway.networking.k8s.io': 'Gateway API',
|
|
125
125
|
'traefik.io': 'Traefik',
|
|
126
126
|
'traefik.containo.us': 'Traefik',
|
|
127
|
+
'crossplane.io': 'Crossplane',
|
|
127
128
|
'pkg.crossplane.io': 'Crossplane',
|
|
128
129
|
'apiextensions.crossplane.io': 'Crossplane',
|
|
130
|
+
'helm.crossplane.io': 'Crossplane',
|
|
131
|
+
'kubernetes.crossplane.io': 'Crossplane',
|
|
129
132
|
'source.toolkit.fluxcd.io': 'Flux',
|
|
130
133
|
'helm.toolkit.fluxcd.io': 'Flux',
|
|
131
134
|
'kustomize.toolkit.fluxcd.io': 'Flux',
|
|
@@ -167,7 +170,12 @@ export function formatGroupName(group: string): string {
|
|
|
167
170
|
'kubeflow.org': 'Kubeflow',
|
|
168
171
|
'snapshot.storage.k8s.io': 'Snapshots',
|
|
169
172
|
}
|
|
170
|
-
|
|
173
|
+
if (knownGroups[group]) return knownGroups[group]
|
|
174
|
+
// Suffix rules — for unbounded provider group sets (Crossplane providers ship
|
|
175
|
+
// their own per-service groups like s3.aws.upbound.io, compute.gcp.upbound.io).
|
|
176
|
+
if (group.endsWith('.upbound.io')) return 'Crossplane'
|
|
177
|
+
if (group.endsWith('.crossplane.io')) return 'Crossplane'
|
|
178
|
+
return group
|
|
171
179
|
}
|
|
172
180
|
|
|
173
181
|
export function shortenGroupName(group: string): string {
|
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Theme-aware badge classes for RBAC display (verbs, resources, API groups).
|
|
2
|
+
// Reuses the same light/dark pairs as Badge.tsx's SEVERITY/KIND maps so the
|
|
3
|
+
// RBAC views read with the same contrast as the rest of the app — the
|
|
4
|
+
// inherited `text-*-400` foreground without a light counterpart was washing
|
|
5
|
+
// out on light backgrounds.
|
|
6
|
+
//
|
|
7
|
+
// Centralized here so SA / Pod / RoleBinding renderers and the
|
|
8
|
+
// MyPermissions dialog all stay in sync; previous duplicate copies drifted.
|
|
9
|
+
|
|
10
|
+
import { BADGE_SEVERITY_COLORS, BADGE_KIND_COLORS } from '../components/ui/Badge'
|
|
11
|
+
import {
|
|
12
|
+
RBAC_BLAST_READ_VERBS,
|
|
13
|
+
RBAC_BLAST_WRITE_VERBS,
|
|
14
|
+
RBAC_BLAST_DELETE_VERBS,
|
|
15
|
+
RBAC_BLAST_ESCALATION_VERBS,
|
|
16
|
+
} from './rbac-blast-radius'
|
|
17
|
+
|
|
18
|
+
/** Theme-aware classes for an RBAC verb chip. */
|
|
19
|
+
export function rbacVerbBadgeClass(verb: string): string {
|
|
20
|
+
if (verb === '*') return BADGE_SEVERITY_COLORS.error
|
|
21
|
+
if (RBAC_BLAST_ESCALATION_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.error
|
|
22
|
+
if (RBAC_BLAST_DELETE_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.error
|
|
23
|
+
if (RBAC_BLAST_WRITE_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.warning
|
|
24
|
+
if (RBAC_BLAST_READ_VERBS.has(verb)) return BADGE_SEVERITY_COLORS.success
|
|
25
|
+
return BADGE_SEVERITY_COLORS.info
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Theme-aware classes for an RBAC resource chip (pods, configmaps, …). */
|
|
29
|
+
export const rbacResourceBadgeClass: string =
|
|
30
|
+
BADGE_KIND_COLORS.HTTPRoute // purple-100/-700 in light, purple-950/-400 in dark
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Theme-aware kind badge for the four RBAC kinds. We can't put these in
|
|
34
|
+
* Badge.tsx's KIND map without churn there, so reuse the closest existing
|
|
35
|
+
* palette entry: blue/info for namespaced (Role/RoleBinding), purple for
|
|
36
|
+
* cluster-scoped (ClusterRole/ClusterRoleBinding).
|
|
37
|
+
*/
|
|
38
|
+
export function rbacKindBadgeClass(kind: string): string {
|
|
39
|
+
switch (kind) {
|
|
40
|
+
case 'Role':
|
|
41
|
+
case 'RoleBinding':
|
|
42
|
+
return BADGE_KIND_COLORS.Service // blue-100/-700 light, blue-950/-400 dark
|
|
43
|
+
case 'ClusterRole':
|
|
44
|
+
case 'ClusterRoleBinding':
|
|
45
|
+
return BADGE_KIND_COLORS.HelmRelease // purple-100/-700 light, purple-950/-400 dark
|
|
46
|
+
default:
|
|
47
|
+
return BADGE_KIND_COLORS.HelmRelease
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Theme-aware classes for an API group chip (notification.toolkit.fluxcd.io, …). */
|
|
52
|
+
export const rbacApiGroupBadgeClass: string =
|
|
53
|
+
'bg-theme-hover/60 text-theme-text-secondary border-theme-border'
|
|
54
|
+
|
|
55
|
+
/** Theme-aware classes for a resourceName chip (rare — `resourceNames: [...]`). */
|
|
56
|
+
export const rbacResourceNameBadgeClass: string =
|
|
57
|
+
BADGE_KIND_COLORS.PersistentVolumeClaim // cyan, distinct from resources
|
|
58
|
+
|
|
59
|
+
/** Theme-aware classes for a non-resource URL chip (/healthz etc). */
|
|
60
|
+
export const rbacNonResourceUrlBadgeClass: string =
|
|
61
|
+
BADGE_KIND_COLORS.Application // orange, distinct from everything above
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { detectBlastRadius, rulePermissivenessScore } from './rbac-blast-radius'
|
|
3
|
+
import type { RBACBindingRules, RBACSubjectResponse } from '../types/rbac'
|
|
4
|
+
|
|
5
|
+
// Skeleton helpers — keep fixtures terse so each test reads as a contract.
|
|
6
|
+
function binding(over: Partial<RBACBindingRules> = {}): RBACBindingRules {
|
|
7
|
+
return {
|
|
8
|
+
binding: { kind: 'RoleBinding', namespace: 'default', name: 'b', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } },
|
|
9
|
+
role: { kind: 'Role', namespace: 'default', name: 'r' },
|
|
10
|
+
rules: [],
|
|
11
|
+
...over,
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function subject(direct: RBACBindingRules[] = [], inherited: { groupName: string; bindings: RBACBindingRules[] }[] = []): RBACSubjectResponse {
|
|
16
|
+
return {
|
|
17
|
+
subject: { kind: 'ServiceAccount', namespace: 'default', name: 'app-sa' },
|
|
18
|
+
direct,
|
|
19
|
+
inheritedFromGroups: inherited,
|
|
20
|
+
flat: [],
|
|
21
|
+
truncated: false,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('detectBlastRadius', () => {
|
|
26
|
+
// The five documented triggers; each is a security-UX invariant the PR
|
|
27
|
+
// description and inline comments call load-bearing. If any of these
|
|
28
|
+
// silently regresses, the Pod blast-radius banner stops alarming on the
|
|
29
|
+
// exact scenarios it was added for.
|
|
30
|
+
|
|
31
|
+
it('flags cluster-admin role-name match', () => {
|
|
32
|
+
const b = binding({ role: { kind: 'ClusterRole', name: 'cluster-admin', namespace: '' } })
|
|
33
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
34
|
+
expect(reasons).toHaveLength(1)
|
|
35
|
+
expect(reasons[0].reason).toMatch(/cluster-admin/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('flags verb wildcards', () => {
|
|
39
|
+
const b = binding({
|
|
40
|
+
rules: [{ verbs: ['*'], resources: ['pods'], apiGroups: [''] }],
|
|
41
|
+
})
|
|
42
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
43
|
+
expect(reasons).toHaveLength(1)
|
|
44
|
+
expect(reasons[0].reason).toMatch(/verb wildcard/)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('flags escalation verbs', () => {
|
|
48
|
+
const b = binding({
|
|
49
|
+
rules: [{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }],
|
|
50
|
+
})
|
|
51
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
52
|
+
expect(reasons).toHaveLength(1)
|
|
53
|
+
expect(reasons[0].reason).toMatch(/escalate/)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('flags impersonate and bind too', () => {
|
|
57
|
+
const b1 = binding({ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b1', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } }, rules: [{ verbs: ['impersonate'], resources: ['users'], apiGroups: [''] }] })
|
|
58
|
+
const b2 = binding({ binding: { kind: 'RoleBinding', namespace: 'default', name: 'b2', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } }, rules: [{ verbs: ['bind'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] }] })
|
|
59
|
+
const reasons = detectBlastRadius(subject([b1, b2]))
|
|
60
|
+
expect(reasons).toHaveLength(2)
|
|
61
|
+
expect(reasons[0].reason).toMatch(/impersonate/)
|
|
62
|
+
expect(reasons[1].reason).toMatch(/bind/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('flags cluster-wide "create pods" but NOT namespace-scoped "create pods"', () => {
|
|
66
|
+
// The cluster-wide form is the lateral-movement vector — a Pod with
|
|
67
|
+
// namespace-scoped pod-create can only move within its namespace.
|
|
68
|
+
const cluster = binding({
|
|
69
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'cluster-b', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
|
|
70
|
+
rules: [{ verbs: ['create'], resources: ['pods'], apiGroups: [''] }],
|
|
71
|
+
})
|
|
72
|
+
const namespaced = binding({
|
|
73
|
+
binding: { kind: 'RoleBinding', namespace: 'default', name: 'ns-b', roleRef: { kind: 'Role', namespace: 'default', name: 'r' } },
|
|
74
|
+
rules: [{ verbs: ['create'], resources: ['pods'], apiGroups: [''] }],
|
|
75
|
+
})
|
|
76
|
+
expect(detectBlastRadius(subject([cluster]))).toHaveLength(1)
|
|
77
|
+
expect(detectBlastRadius(subject([namespaced]))).toHaveLength(0)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('does NOT flag resource-only wildcards', () => {
|
|
81
|
+
// The `view` ClusterRole grants get/list/watch on `*` resources in
|
|
82
|
+
// every fluxcd CRD. Flagging this would alarm on every authenticated
|
|
83
|
+
// SA — calibration call documented in the source.
|
|
84
|
+
const b = binding({
|
|
85
|
+
rules: [{ verbs: ['get', 'list', 'watch'], resources: ['*'], apiGroups: ['source.toolkit.fluxcd.io'] }],
|
|
86
|
+
})
|
|
87
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('dedupes by binding identity when multiple risky rules match', () => {
|
|
91
|
+
const b = binding({
|
|
92
|
+
rules: [
|
|
93
|
+
{ verbs: ['*'], resources: ['pods'], apiGroups: [''] },
|
|
94
|
+
{ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] },
|
|
95
|
+
],
|
|
96
|
+
})
|
|
97
|
+
const reasons = detectBlastRadius(subject([b]))
|
|
98
|
+
expect(reasons).toHaveLength(1)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('includes inherited-from-group bindings, not just direct', () => {
|
|
102
|
+
const b = binding({
|
|
103
|
+
binding: { kind: 'ClusterRoleBinding', namespace: '', name: 'inherited', roleRef: { kind: 'ClusterRole', namespace: '', name: 'r' } },
|
|
104
|
+
rules: [{ verbs: ['*'], resources: ['secrets'], apiGroups: [''] }],
|
|
105
|
+
})
|
|
106
|
+
const reasons = detectBlastRadius(subject([], [{ groupName: 'system:authenticated', bindings: [b] }]))
|
|
107
|
+
expect(reasons).toHaveLength(1)
|
|
108
|
+
expect(reasons[0].reason).toMatch(/verb wildcard/)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns empty for benign bindings', () => {
|
|
112
|
+
const b = binding({
|
|
113
|
+
rules: [{ verbs: ['get', 'list'], resources: ['configmaps'], apiGroups: [''] }],
|
|
114
|
+
})
|
|
115
|
+
expect(detectBlastRadius(subject([b]))).toHaveLength(0)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('rulePermissivenessScore', () => {
|
|
120
|
+
it('scores wildcards highest', () => {
|
|
121
|
+
const wildcard = rulePermissivenessScore({ verbs: ['*'], resources: ['*'], apiGroups: ['*'] })
|
|
122
|
+
const benign = rulePermissivenessScore({ verbs: ['get'], resources: ['configmaps'], apiGroups: [''] })
|
|
123
|
+
expect(wildcard).toBeGreaterThan(benign)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('boosts cluster-wide "create pods"', () => {
|
|
127
|
+
const createPods = rulePermissivenessScore({ verbs: ['create'], resources: ['pods'], apiGroups: [''] })
|
|
128
|
+
const createOthers = rulePermissivenessScore({ verbs: ['create'], resources: ['configmaps'], apiGroups: [''] })
|
|
129
|
+
expect(createPods).toBeGreaterThan(createOthers)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('scores escalation verbs higher than plain writes', () => {
|
|
133
|
+
const escalate = rulePermissivenessScore({ verbs: ['escalate'], resources: ['roles'], apiGroups: ['rbac.authorization.k8s.io'] })
|
|
134
|
+
const patch = rulePermissivenessScore({ verbs: ['patch'], resources: ['configmaps'], apiGroups: [''] })
|
|
135
|
+
expect(escalate).toBeGreaterThan(patch)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pure helpers for the "what's the blast radius of this Pod / Workload / SA"
|
|
2
|
+
// detection. Lives outside the renderers because it's the security-UX core
|
|
3
|
+
// of the RBAC visibility feature — the rule that resource-only wildcards do
|
|
4
|
+
// NOT trigger (because `view` covers every fluxcd CRD via wildcards and would
|
|
5
|
+
// alarm on every authenticated SA) is load-bearing and easily regressed
|
|
6
|
+
// silently. Tested via rbac-blast-radius.test.ts; keep it pure.
|
|
7
|
+
|
|
8
|
+
import type { RBACBindingRules, RBACPolicyRule, RBACSubjectResponse } from '../types/rbac'
|
|
9
|
+
|
|
10
|
+
export const RBAC_BLAST_READ_VERBS = new Set(['get', 'list', 'watch'])
|
|
11
|
+
export const RBAC_BLAST_WRITE_VERBS = new Set(['create', 'update', 'patch'])
|
|
12
|
+
export const RBAC_BLAST_DELETE_VERBS = new Set(['delete', 'deletecollection'])
|
|
13
|
+
export const RBAC_BLAST_ESCALATION_VERBS = new Set(['escalate', 'bind', 'impersonate'])
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Score a single PolicyRule by max permissiveness. Higher is scarier.
|
|
17
|
+
* The ordering matters more than the absolute scale — used to sort the
|
|
18
|
+
* rule preview so dangerous rules surface first in incident response.
|
|
19
|
+
*/
|
|
20
|
+
export function rulePermissivenessScore(r: RBACPolicyRule): number {
|
|
21
|
+
let score = 0
|
|
22
|
+
const verbs = r.verbs ?? []
|
|
23
|
+
const resources = r.resources ?? []
|
|
24
|
+
const groups = r.apiGroups ?? []
|
|
25
|
+
if (verbs.includes('*')) score += 100
|
|
26
|
+
if (resources.includes('*')) score += 100
|
|
27
|
+
if (groups.includes('*')) score += 100
|
|
28
|
+
for (const v of verbs) {
|
|
29
|
+
if (RBAC_BLAST_ESCALATION_VERBS.has(v)) score += 80
|
|
30
|
+
else if (RBAC_BLAST_DELETE_VERBS.has(v)) score += 40
|
|
31
|
+
else if (RBAC_BLAST_WRITE_VERBS.has(v)) score += 20
|
|
32
|
+
else if (RBAC_BLAST_READ_VERBS.has(v)) score += 5
|
|
33
|
+
else score += 10
|
|
34
|
+
}
|
|
35
|
+
if (verbs.includes('create') && resources.includes('pods')) score += 50
|
|
36
|
+
return score
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BlastRadiusReason {
|
|
40
|
+
binding: RBACBindingRules
|
|
41
|
+
reason: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Detect bindings that genuinely make a Pod's compromise a cluster takeover.
|
|
46
|
+
* Triggers on, in priority order:
|
|
47
|
+
*
|
|
48
|
+
* 1. cluster-admin role-name match (covers the "bound my SA to cluster-admin
|
|
49
|
+
* for testing" pattern even when rules aren't loaded)
|
|
50
|
+
* 2. verb wildcards (`*`)
|
|
51
|
+
* 3. escalation verbs (`escalate` / `bind` / `impersonate`)
|
|
52
|
+
* 4. cluster-wide `create pods` (lateral-movement vector)
|
|
53
|
+
*
|
|
54
|
+
* Resource-only wildcards (e.g. `view`'s fluxcd CRD coverage) deliberately
|
|
55
|
+
* do NOT trigger — they fire on every authenticated SA and would train
|
|
56
|
+
* operators to ignore the alarm. The current set is the calibrated minimum
|
|
57
|
+
* for "the Pod's identity is genuinely god-mode".
|
|
58
|
+
*
|
|
59
|
+
* Dedupes by binding identity so a binding with multiple risky rules
|
|
60
|
+
* counts once; first matching reason wins.
|
|
61
|
+
*/
|
|
62
|
+
export function detectBlastRadius(rbacData: RBACSubjectResponse): BlastRadiusReason[] {
|
|
63
|
+
const reasons: BlastRadiusReason[] = []
|
|
64
|
+
const allBindings = [
|
|
65
|
+
...rbacData.direct,
|
|
66
|
+
...rbacData.inheritedFromGroups.flatMap((g) => g.bindings),
|
|
67
|
+
]
|
|
68
|
+
for (const br of allBindings) {
|
|
69
|
+
if (br.role.name === 'cluster-admin' && br.role.kind === 'ClusterRole') {
|
|
70
|
+
reasons.push({ binding: br, reason: 'bound to cluster-admin (full god-mode access)' })
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
for (const r of br.rules ?? []) {
|
|
74
|
+
const verbs = r.verbs ?? []
|
|
75
|
+
const resources = r.resources ?? []
|
|
76
|
+
if (verbs.includes('*')) {
|
|
77
|
+
reasons.push({ binding: br, reason: 'grants verb wildcard (*) — every action on the listed resources' })
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
const escalations = verbs.filter((v) => RBAC_BLAST_ESCALATION_VERBS.has(v))
|
|
81
|
+
if (escalations.length > 0) {
|
|
82
|
+
reasons.push({ binding: br, reason: `grants privilege-escalation verbs: ${escalations.join(', ')}` })
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
if (verbs.includes('create') && resources.includes('pods') && br.binding.kind === 'ClusterRoleBinding') {
|
|
86
|
+
reasons.push({ binding: br, reason: 'grants cluster-wide "create pods" — a known lateral-movement vector' })
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const seen = new Set<string>()
|
|
92
|
+
return reasons.filter((r) => {
|
|
93
|
+
const key = r.binding.binding.kind + '/' + r.binding.binding.namespace + '/' + r.binding.binding.name
|
|
94
|
+
if (seen.has(key)) return false
|
|
95
|
+
seen.add(key)
|
|
96
|
+
return true
|
|
97
|
+
})
|
|
98
|
+
}
|
|
@@ -559,13 +559,23 @@ export function buildResourceHierarchy(options: HierarchyOptions): ResourceLane[
|
|
|
559
559
|
TraefikService: 2, Middleware: 3, MiddlewareTCP: 3,
|
|
560
560
|
HTTPProxy: 1, // Contour
|
|
561
561
|
}
|
|
562
|
+
// Precompute each child's latest event time once — otherwise the
|
|
563
|
+
// comparator below reparses every child's event timestamps on every
|
|
564
|
+
// comparison (O(children log children × events) Date parses).
|
|
565
|
+
const latestByChildId = new Map<string, number>()
|
|
566
|
+
for (const c of lane.children) {
|
|
567
|
+
let latest = 0
|
|
568
|
+
for (const e of c.events) {
|
|
569
|
+
const t = new Date(e.timestamp).getTime()
|
|
570
|
+
if (t > latest) latest = t
|
|
571
|
+
}
|
|
572
|
+
latestByChildId.set(c.id, latest)
|
|
573
|
+
}
|
|
562
574
|
lane.children.sort((a, b) => {
|
|
563
575
|
const aPriority = kindPriority[a.kind] || 10
|
|
564
576
|
const bPriority = kindPriority[b.kind] || 10
|
|
565
577
|
if (aPriority !== bPriority) return aPriority - bPriority
|
|
566
|
-
|
|
567
|
-
const bLatest = b.events.length > 0 ? Math.max(...b.events.map(e => new Date(e.timestamp).getTime())) : 0
|
|
568
|
-
return bLatest - aLatest
|
|
578
|
+
return (latestByChildId.get(b.id) ?? 0) - (latestByChildId.get(a.id) ?? 0)
|
|
569
579
|
})
|
|
570
580
|
}
|
|
571
581
|
|