@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.
Files changed (69) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/AreaChart.tsx +371 -0
  3. package/src/components/charts/MetricsSummary.tsx +49 -0
  4. package/src/components/charts/SeriesLegend.tsx +27 -0
  5. package/src/components/charts/colors.ts +49 -0
  6. package/src/components/charts/format.ts +41 -0
  7. package/src/components/charts/index.ts +14 -0
  8. package/src/components/charts/saturation.test.ts +60 -0
  9. package/src/components/charts/saturation.ts +32 -0
  10. package/src/components/charts/types.ts +36 -0
  11. package/src/components/compare/CompareResourcePicker.tsx +192 -0
  12. package/src/components/compare/CompareTray.tsx +130 -0
  13. package/src/components/compare/ResourceCompareView.tsx +272 -0
  14. package/src/components/compare/index.ts +14 -0
  15. package/src/components/compare/normalize.test.ts +193 -0
  16. package/src/components/compare/normalize.ts +69 -0
  17. package/src/components/compare/picks.test.ts +97 -0
  18. package/src/components/compare/picks.ts +35 -0
  19. package/src/components/compare/sort.test.ts +78 -0
  20. package/src/components/compare/sort.ts +26 -0
  21. package/src/components/compare/types.ts +43 -0
  22. package/src/components/compare/url.test.ts +61 -0
  23. package/src/components/compare/url.ts +26 -0
  24. package/src/components/resources/ResourcesView.tsx +431 -96
  25. package/src/components/resources/index.ts +1 -0
  26. package/src/components/resources/renderers/CompositeRenderer.tsx +233 -0
  27. package/src/components/resources/renderers/CompositionRenderer.tsx +218 -0
  28. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +145 -0
  29. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +71 -0
  30. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  31. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +131 -0
  32. package/src/components/resources/renderers/NamespaceRenderer.tsx +223 -0
  33. package/src/components/resources/renderers/PVCRenderer.tsx +6 -1
  34. package/src/components/resources/renderers/PodRenderer.tsx +207 -2
  35. package/src/components/resources/renderers/RoleBindingRenderer.tsx +132 -20
  36. package/src/components/resources/renderers/RoleRenderer.tsx +148 -18
  37. package/src/components/resources/renderers/ServiceAccountRenderer.tsx +456 -3
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +189 -2
  39. package/src/components/resources/renderers/XRDRenderer.tsx +153 -0
  40. package/src/components/resources/renderers/crossplane-cells.tsx +146 -0
  41. package/src/components/resources/renderers/flux-cells.tsx +12 -0
  42. package/src/components/resources/renderers/index.ts +8 -0
  43. package/src/components/resources/resource-utils-crossplane.test.ts +609 -0
  44. package/src/components/resources/resource-utils-crossplane.ts +325 -0
  45. package/src/components/resources/resource-utils-flux.ts +14 -0
  46. package/src/components/resources/resource-utils.ts +1 -0
  47. package/src/components/resources/resources-column-filter.test.ts +74 -0
  48. package/src/components/shared/ResourceActionsBar.tsx +77 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +138 -9
  50. package/src/components/topology/K8sResourceNode.tsx +21 -2
  51. package/src/components/topology/TopologyGraph.tsx +46 -10
  52. package/src/components/topology/layout.ts +20 -11
  53. package/src/components/topology/layout.worker.ts +21 -17
  54. package/src/components/ui/YamlEditor.tsx +28 -15
  55. package/src/components/workload/ResourceDetailDrawer.tsx +1 -1
  56. package/src/index.ts +6 -0
  57. package/src/perf/index.ts +1 -0
  58. package/src/perf/store.ts +110 -0
  59. package/src/types/core.ts +10 -0
  60. package/src/types/index.ts +1 -0
  61. package/src/types/rbac.ts +99 -0
  62. package/src/utils/api-resources.ts +9 -1
  63. package/src/utils/index.ts +1 -0
  64. package/src/utils/rbac-badges.ts +61 -0
  65. package/src/utils/rbac-blast-radius.test.ts +137 -0
  66. package/src/utils/rbac-blast-radius.ts +98 -0
  67. package/src/utils/resource-hierarchy.ts +13 -3
  68. package/src/utils/structure-hash.test.ts +94 -0
  69. 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
@@ -2,3 +2,4 @@ export * from './core'
2
2
  export * from './gitops'
3
3
  export * from './gitops-tree'
4
4
  export * from './gitops-insights'
5
+ export * from './rbac'
@@ -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
- return knownGroups[group] || group
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 {
@@ -16,3 +16,4 @@ export * from './view-transition'
16
16
  export * from './validators'
17
17
  export * from './gitops-owner'
18
18
  export * from './gitops-route'
19
+ export * from './rbac-badges'
@@ -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
- const aLatest = a.events.length > 0 ? Math.max(...a.events.map(e => new Date(e.timestamp).getTime())) : 0
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