ownerlens 0.1.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/LICENSE +183 -0
- package/README.md +209 -0
- package/bin/ownerlens.js +92 -0
- package/dist/assets/index-B9aAYpVl.css +1 -0
- package/dist/assets/index-BcwLk2bx.js +10 -0
- package/dist/index.html +13 -0
- package/package.json +73 -0
- package/src/App.tsx +18 -0
- package/src/components/azure/AzureComponent.test.tsx +625 -0
- package/src/components/azure/AzureComponent.tsx +189 -0
- package/src/components/azure/AzureRbacComponent.tsx +104 -0
- package/src/components/azure/ClosableAzureTab.tsx +42 -0
- package/src/components/azure/EntraPermissionsComponent.tsx +194 -0
- package/src/components/azure/ManagedIdentityComponent.test.tsx +324 -0
- package/src/components/azure/ManagedIdentityComponent.tsx +141 -0
- package/src/components/azure/ResourceGroupComponent.tsx +157 -0
- package/src/components/azure/ServicePrincipalComponent.test.tsx +457 -0
- package/src/components/azure/ServicePrincipalComponent.tsx +155 -0
- package/src/components/azure/ServicePrincipalFieldRenderers.tsx +140 -0
- package/src/components/azure/ZtaComponent.test.tsx +267 -0
- package/src/components/azure/ZtaComponent.tsx +276 -0
- package/src/components/azure/ZtaRemediationBadge.tsx +70 -0
- package/src/components/azure/api.ts +216 -0
- package/src/components/azure/azureReportConfig.ts +247 -0
- package/src/core/azure/azureRbac.ts +70 -0
- package/src/core/azure/entra/index.ts +1 -0
- package/src/core/azure/entra/managedIdentity.ts +21 -0
- package/src/core/azure/entra/servicePrincipal.ts +34 -0
- package/src/core/azure/entra/types.ts +56 -0
- package/src/core/azure/identityEnrichment.ts +65 -0
- package/src/core/azure/resources.ts +141 -0
- package/src/core/azure/ztaReport.ts +58 -0
- package/src/core/config.ts +39 -0
- package/src/core/ownership/OwnershipTarget.ts +32 -0
- package/src/core/ownership/resolveOwner.ts +5 -0
- package/src/core/ownership/types.ts +14 -0
- package/src/core/risk/types.ts +1 -0
- package/src/core/runtime/index.ts +1 -0
- package/src/core/runtime/localSnapshotFiles.ts +74 -0
- package/src/core/runtime/rest.ts +61 -0
- package/src/lib/searchFilterUtils.ts +17 -0
- package/src/lib/utils.ts +48 -0
- package/src/main.tsx +10 -0
- package/src/providers/azure/identities/azureIdentityTypes.ts +1 -0
- package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.test.ts +32 -0
- package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.ts +35 -0
- package/src/providers/azure/identities/userAssignedIdentityAssignments.ts +52 -0
- package/src/providers/azure/inputTransferObject/entra/EntraAppRoleAssignment.ts +10 -0
- package/src/providers/azure/inputTransferObject/entra/EntraApplication.ts +27 -0
- package/src/providers/azure/inputTransferObject/entra/EntraOAuth2PermissionGrant.ts +8 -0
- package/src/providers/azure/inputTransferObject/entra/EntraServicePrincipal.ts +43 -0
- package/src/providers/azure/inputTransferObject/entra/EntraSnapshot.ts +13 -0
- package/src/providers/azure/inputTransferObject/entra/EntraSnapshotMeta.ts +12 -0
- package/src/providers/azure/inputTransferObject/resources/AzureActivityLog.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureResource.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureResourceGroup.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureRoleAssignment.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureSnapshot.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureSnapshotMeta.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureSubscription.ts +1 -0
- package/src/providers/azure/inputTransferObject/resources/AzureUserAssignedManagedIdentity.ts +1 -0
- package/src/providers/azure/ownership/azureActivityOwnershipEvidence.ts +60 -0
- package/src/providers/azure/ownership/azureOwnerReportTypes.ts +13 -0
- package/src/providers/azure/ownership/azureOwnershipConfig.ts +21 -0
- package/src/providers/azure/ownership/azureOwnershipTypes.ts +46 -0
- package/src/providers/azure/ownership/buildAzureOwnershipReport.test.ts +99 -0
- package/src/providers/azure/ownership/buildAzureOwnershipReport.ts +90 -0
- package/src/providers/azure/ownership/buildAzureOwnershipTargets.test.ts +87 -0
- package/src/providers/azure/ownership/buildAzureOwnershipTargets.ts +42 -0
- package/src/providers/azure/ownership/resolveAzureOwner.ts +146 -0
- package/src/providers/azure/runtime/DisabledEvidenceStore.ts +34 -0
- package/src/providers/azure/runtime/EnrichmentService.ts +35 -0
- package/src/providers/azure/runtime/LocalReportRuntime.test.ts +2318 -0
- package/src/providers/azure/runtime/LocalReportRuntime.ts +302 -0
- package/src/providers/azure/runtime/RuntimeHost.ts +60 -0
- package/src/providers/azure/runtime/SnapshotImporter.ts +44 -0
- package/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts +523 -0
- package/src/providers/azure/runtime/enrichment/azureScopeClassifier.ts +30 -0
- package/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts +88 -0
- package/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +307 -0
- package/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts +227 -0
- package/src/providers/azure/runtime/entra/appRoleAssignmentsTable.ts +52 -0
- package/src/providers/azure/runtime/entra/applicationsTable.ts +175 -0
- package/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts +63 -0
- package/src/providers/azure/runtime/entra/localReportRuntimeRest.ts +41 -0
- package/src/providers/azure/runtime/entra/oauth2PermissionGrantsTable.ts +48 -0
- package/src/providers/azure/runtime/entra/principalProjection.ts +173 -0
- package/src/providers/azure/runtime/entra/servicePrincipalsTable.ts +149 -0
- package/src/providers/azure/runtime/entra/snapshotMetadataTable.ts +18 -0
- package/src/providers/azure/runtime/entra/snapshotStore.ts +102 -0
- package/src/providers/azure/runtime/localReportCollections.ts +101 -0
- package/src/providers/azure/runtime/localReportRuntimeRest.ts +71 -0
- package/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +145 -0
- package/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts +114 -0
- package/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts +60 -0
- package/src/providers/azure/runtime/resources/localReportRuntimeRest.ts +81 -0
- package/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +90 -0
- package/src/providers/azure/runtime/resources/snapshotMetadataTable.ts +19 -0
- package/src/providers/azure/runtime/resources/snapshotStore.ts +128 -0
- package/src/providers/azure/runtime/resources/tables.ts +441 -0
- package/src/providers/azure/runtime/runtimeRestQuery.ts +46 -0
- package/src/providers/azure/runtime/runtimeSqlSchema.ts +357 -0
- package/src/providers/azure/runtime/zta/Discovery.ts +141 -0
- package/src/providers/azure/runtime/zta/LocalZeroTrustAssessmentReportRuntime.ts +86 -0
- package/src/providers/azure/runtime/zta/ZeroTrustAssessmentQueryService.ts +124 -0
- package/src/providers/azure/runtime/zta/localReportRuntimeRest.ts +15 -0
- package/src/providers/azure/runtime/zta/snapshotMetadataTable.ts +77 -0
- package/src/providers/azure/runtime/zta/snapshotStore.ts +112 -0
- package/src/providers/azure/runtime/zta/tables.ts +361 -0
- package/src/providers/azure/runtime/zta/types.ts +7 -0
- package/src/providers/azure/runtime/zta/ztaReportMapper.ts +12 -0
- package/src/report/applyCollectionControls.ts +289 -0
- package/src/report/buildCollectionColumns.tsx +38 -0
- package/src/report/components/ConfidenceBadge.tsx +10 -0
- package/src/report/components/EvidenceList.test.ts +25 -0
- package/src/report/components/EvidenceList.tsx +52 -0
- package/src/report/components/GenericTable.tsx +373 -0
- package/src/report/components/PermissionRiskBadge.tsx +19 -0
- package/src/report/components/reportTableControls.test.ts +175 -0
- package/src/report/components/reportTableControls.tsx +483 -0
- package/src/report/components/ui/badge.tsx +35 -0
- package/src/report/components/ui/button.tsx +38 -0
- package/src/report/components/ui/card.tsx +23 -0
- package/src/report/components/ui/input.tsx +15 -0
- package/src/report/components/ui/table.tsx +44 -0
- package/src/report/components/ui/tabs.tsx +29 -0
- package/src/report/export/csv.ts +34 -0
- package/src/report/ownerManualPrecheck.test.ts +137 -0
- package/src/report/ownerManualPrecheck.ts +132 -0
- package/src/report/reportArchitecture.test.ts +125 -0
- package/src/report/reportTypes.ts +54 -0
- package/src/report/reportValueRenderers.tsx +54 -0
- package/src/report/runtimeCollectionQuery.ts +23 -0
- package/src/report/types.ts +14 -0
- package/src/styles.css +43 -0
- package/tools/README.md +108 -0
- package/tools/azure-activity-check.ps1 +164 -0
- package/tools/collect-azure.ps1 +54 -0
- package/tools/collect-entra.ps1 +47 -0
- package/tools/collect-scripts.test.ts +22 -0
- package/tools/prepare-entra-snapshot.ps1 +403 -0
- package/tools/prepare-entra-snapshot.test.ts +14 -0
- package/tools/prepare-resource-snapshot.ps1 +345 -0
- package/vite.config.ts +23 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
|
|
7
|
+
import { ManagedIdentityComponent } from "./ManagedIdentityComponent";
|
|
8
|
+
import type { ManagedIdentity } from "../../core/azure/entra/managedIdentity";
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ManagedIdentityResponse = {
|
|
15
|
+
collectionId: "entra.managedIdentities";
|
|
16
|
+
rows: ManagedIdentity[];
|
|
17
|
+
columns: string[];
|
|
18
|
+
page: number;
|
|
19
|
+
pageSize: number;
|
|
20
|
+
count: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
delete (globalThis as Partial<typeof globalThis>).fetch;
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("loads managed identities with runtime risk enrichment", async () => {
|
|
33
|
+
const mediumIdentity = managedIdentity({
|
|
34
|
+
appId: "client-1",
|
|
35
|
+
azureRbac: "Contributor on rg/rg-app (write-capable role)",
|
|
36
|
+
displayName: "uami-a",
|
|
37
|
+
id: "principal-uami-1",
|
|
38
|
+
permissionRisk: "medium",
|
|
39
|
+
rbacRoleAssignmentCount: 1,
|
|
40
|
+
rbacRoleLevel: "medium",
|
|
41
|
+
rbacSubscriptionCount: 1,
|
|
42
|
+
ztaRemediationCountAll: 3,
|
|
43
|
+
ztaRemediationFailedCount: 1,
|
|
44
|
+
ztaMaxRisk: "medium",
|
|
45
|
+
oauthPemrissionsCount: 1,
|
|
46
|
+
appRolesPermissionCount: 2,
|
|
47
|
+
entraPermissionRisk: "high",
|
|
48
|
+
assignedResourceGroups: ["rg-app"],
|
|
49
|
+
potentialOwners: ["alice@example.test"],
|
|
50
|
+
ownerConfidence: "high",
|
|
51
|
+
tags: ["ownerlens", "managed-identity"]
|
|
52
|
+
});
|
|
53
|
+
const highIdentity = managedIdentity({
|
|
54
|
+
appId: "client-2",
|
|
55
|
+
azureRbac: "Owner on subscription (privileged role)",
|
|
56
|
+
displayName: "uami-high",
|
|
57
|
+
id: "principal-uami-2",
|
|
58
|
+
permissionRisk: "high",
|
|
59
|
+
rbacRoleAssignmentCount: 2,
|
|
60
|
+
rbacRoleLevel: "high",
|
|
61
|
+
rbacSubscriptionCount: 1
|
|
62
|
+
});
|
|
63
|
+
const fetchMock = jest.fn<Promise<Response>, Parameters<typeof fetch>>(async (input) => {
|
|
64
|
+
const url = new URL(String(input), window.location.origin);
|
|
65
|
+
const filters = readFilterQuery(url);
|
|
66
|
+
|
|
67
|
+
if (filters.rbacRoleLevel?.includes("high")) {
|
|
68
|
+
return jsonResponse(collection([highIdentity], { count: 1 }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (filters.entraPermissionRisk?.includes("high")) {
|
|
72
|
+
return jsonResponse(collection([mediumIdentity], { count: 1 }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (filters.ztaMaxRisk?.includes("medium")) {
|
|
76
|
+
return jsonResponse(collection([mediumIdentity], { count: 1 }));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return jsonResponse(collection([mediumIdentity, highIdentity], { count: 2 }));
|
|
80
|
+
});
|
|
81
|
+
globalThis.fetch = fetchMock;
|
|
82
|
+
|
|
83
|
+
const { container, root } = renderComponent(<ManagedIdentityComponent />);
|
|
84
|
+
|
|
85
|
+
await waitForText(container, "uami-a");
|
|
86
|
+
|
|
87
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/data/entra/managedIdentities?page=1&count=20");
|
|
88
|
+
expect(getButton("Sort by Risk").textContent).toContain("Risk");
|
|
89
|
+
expect(getButton("Sort by ZTA remediations").textContent).toContain("ZTA remediations");
|
|
90
|
+
expect(getButton("Sort by Entra permissions").textContent).toContain("Entra permissions");
|
|
91
|
+
expect(getButton("Sort by Tags").textContent).toContain("Tags");
|
|
92
|
+
expect(container.textContent).toContain("1/2");
|
|
93
|
+
expect(container.textContent).toContain("1/3");
|
|
94
|
+
expect(document.querySelector("[title='Contributor on rg/rg-app (write-capable role)']")).toBeDefined();
|
|
95
|
+
expect(container.textContent).toContain("rg-app");
|
|
96
|
+
expect(container.textContent).toContain("alice@example.test");
|
|
97
|
+
expect(container.textContent).toContain("high");
|
|
98
|
+
expect(container.textContent).toContain("ownerlens");
|
|
99
|
+
expect(container.textContent).toContain("managed-identity");
|
|
100
|
+
|
|
101
|
+
await openValueFilter("Filter Azure RBAC");
|
|
102
|
+
await toggleCheckbox("high", true);
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(lastFetchUrl(fetchMock)).toContain("filter%5B0%5D%5Bcolumn%5D=rbacRoleLevel");
|
|
105
|
+
});
|
|
106
|
+
expect(lastFetchUrl(fetchMock)).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=high");
|
|
107
|
+
await waitForText(container, "uami-high");
|
|
108
|
+
expect(container.textContent).not.toContain("uami-a");
|
|
109
|
+
|
|
110
|
+
await clearValueFilter("Filter Azure RBAC");
|
|
111
|
+
await waitForText(container, "uami-a");
|
|
112
|
+
|
|
113
|
+
await openValueFilter("Filter Entra permissions");
|
|
114
|
+
await toggleCheckbox("high", true);
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(lastFetchUrl(fetchMock)).toContain("filter%5B0%5D%5Bcolumn%5D=entraPermissionRisk");
|
|
117
|
+
});
|
|
118
|
+
expect(lastFetchUrl(fetchMock)).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=high");
|
|
119
|
+
await waitForText(container, "uami-a");
|
|
120
|
+
expect(container.textContent).not.toContain("uami-high");
|
|
121
|
+
|
|
122
|
+
await clearValueFilter("Filter Entra permissions");
|
|
123
|
+
await waitForText(container, "uami-high");
|
|
124
|
+
|
|
125
|
+
await openValueFilter("Filter ZTA remediations");
|
|
126
|
+
await toggleCheckbox("medium", true);
|
|
127
|
+
await waitFor(() => {
|
|
128
|
+
expect(lastFetchUrl(fetchMock)).toContain("filter%5B0%5D%5Bcolumn%5D=ztaMaxRisk");
|
|
129
|
+
});
|
|
130
|
+
expect(lastFetchUrl(fetchMock)).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=medium");
|
|
131
|
+
await waitForText(container, "uami-a");
|
|
132
|
+
expect(container.textContent).not.toContain("uami-high");
|
|
133
|
+
|
|
134
|
+
act(() => root.unmount());
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const columns = [
|
|
138
|
+
"displayName",
|
|
139
|
+
"permissionRisk",
|
|
140
|
+
"ztaRemediationCountAll",
|
|
141
|
+
"ztaRemediationFailedCount",
|
|
142
|
+
"ztaMaxRisk",
|
|
143
|
+
"azureRbac",
|
|
144
|
+
"oauthPemrissionsCount",
|
|
145
|
+
"appRolesPermissionCount",
|
|
146
|
+
"entraPermissionRisk",
|
|
147
|
+
"assignedResourceGroups",
|
|
148
|
+
"potentialOwners",
|
|
149
|
+
"ownerConfidence",
|
|
150
|
+
"accountEnabled",
|
|
151
|
+
"id",
|
|
152
|
+
"appId",
|
|
153
|
+
"tags"
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
function managedIdentity(input: Partial<ManagedIdentity> & Pick<ManagedIdentity, "id" | "appId" | "displayName">): ManagedIdentity {
|
|
157
|
+
return {
|
|
158
|
+
accountEnabled: true,
|
|
159
|
+
appDisplayName: null,
|
|
160
|
+
appOwnerOrganizationId: null,
|
|
161
|
+
azureRbac: "No Azure RBAC assignments",
|
|
162
|
+
homepage: null,
|
|
163
|
+
loginUrl: null,
|
|
164
|
+
managedIdentityAssignments: [],
|
|
165
|
+
permissionRisk: "none",
|
|
166
|
+
publisherName: null,
|
|
167
|
+
rbacRoleAssignmentCount: 0,
|
|
168
|
+
rbacRoleLevel: "none",
|
|
169
|
+
rbacSubscriptionCount: 0,
|
|
170
|
+
replyUrls: [],
|
|
171
|
+
roleAssignments: [],
|
|
172
|
+
oauthPemrissionsCount: 0,
|
|
173
|
+
appRolesPermissionCount: 0,
|
|
174
|
+
entraPermissionRisk: "none",
|
|
175
|
+
servicePrincipalNames: [],
|
|
176
|
+
servicePrincipalType: "ManagedIdentity",
|
|
177
|
+
assignedResourceGroups: [],
|
|
178
|
+
potentialOwners: [],
|
|
179
|
+
ownerConfidence: "none",
|
|
180
|
+
tags: [],
|
|
181
|
+
ztaMaxRisk: "none",
|
|
182
|
+
ztaRemediationCountAll: 0,
|
|
183
|
+
ztaRemediationFailedCount: 0,
|
|
184
|
+
...input
|
|
185
|
+
} as ManagedIdentity;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function collection(rows: ManagedIdentity[], { count }: { count: number }): ManagedIdentityResponse {
|
|
189
|
+
return {
|
|
190
|
+
collectionId: "entra.managedIdentities",
|
|
191
|
+
columns,
|
|
192
|
+
count,
|
|
193
|
+
page: 1,
|
|
194
|
+
pageSize: 20,
|
|
195
|
+
rows
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function jsonResponse(body: ManagedIdentityResponse): Response {
|
|
200
|
+
return {
|
|
201
|
+
json: async () => body,
|
|
202
|
+
ok: true,
|
|
203
|
+
status: 200
|
|
204
|
+
} as Response;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } {
|
|
208
|
+
const container = document.createElement("div");
|
|
209
|
+
document.body.appendChild(container);
|
|
210
|
+
const root = createRoot(container);
|
|
211
|
+
|
|
212
|
+
act(() => {
|
|
213
|
+
root.render(component);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return { container, root };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function clickButton(label: string) {
|
|
220
|
+
await act(async () => {
|
|
221
|
+
getButton(label).click();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function openValueFilter(label: string) {
|
|
226
|
+
await clickButton(label);
|
|
227
|
+
await waitForText(document.body, "Clear");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function clearValueFilter(label: string) {
|
|
231
|
+
if (!findButton("Clear")) {
|
|
232
|
+
await openValueFilter(label);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
await clickButton("Clear");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function toggleCheckbox(label: string, checked: boolean) {
|
|
239
|
+
const checkbox = getCheckbox(label);
|
|
240
|
+
|
|
241
|
+
if (checkbox.checked === checked) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await act(async () => {
|
|
246
|
+
checkbox.click();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function getButton(label: string): HTMLButtonElement {
|
|
251
|
+
const button = findButton(label);
|
|
252
|
+
if (!(button instanceof HTMLButtonElement)) {
|
|
253
|
+
throw new Error(`Expected button ${label}.`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return button;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function findButton(label: string): HTMLButtonElement | undefined {
|
|
260
|
+
return [...document.querySelectorAll("button")].find((candidate): candidate is HTMLButtonElement => {
|
|
261
|
+
if (!(candidate instanceof HTMLButtonElement)) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return candidate.getAttribute("aria-label") === label || candidate.textContent?.trim() === label;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function getCheckbox(label: string): HTMLInputElement {
|
|
270
|
+
const labelElement = [...document.querySelectorAll("label")].find((element) => element.textContent?.trim() === label);
|
|
271
|
+
const checkbox = labelElement?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
|
272
|
+
if (!checkbox) {
|
|
273
|
+
throw new Error(`Could not find checkbox: ${label}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return checkbox;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readFilterQuery(url: URL): Record<string, string[]> {
|
|
280
|
+
const filters: Record<string, string[]> = {};
|
|
281
|
+
|
|
282
|
+
for (let index = 0; ; index += 1) {
|
|
283
|
+
const column = url.searchParams.get(`filter[${index}][column]`);
|
|
284
|
+
if (!column) {
|
|
285
|
+
return filters;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
filters[column] = url.searchParams.getAll(`filter[${index}][value][0]`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function lastFetchUrl(fetchMock: jest.Mock<Promise<Response>, Parameters<typeof fetch>>): string {
|
|
293
|
+
const lastCall = fetchMock.mock.calls.at(-1);
|
|
294
|
+
if (!lastCall) {
|
|
295
|
+
throw new Error("Expected fetch to have been called.");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return String(lastCall[0]);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function waitForText(container: HTMLElement, text: string) {
|
|
302
|
+
await waitFor(() => {
|
|
303
|
+
expect(container.textContent).toContain(text);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function waitFor(assertion: () => void): Promise<void> {
|
|
308
|
+
const startedAt = Date.now();
|
|
309
|
+
let lastError: unknown;
|
|
310
|
+
|
|
311
|
+
while (Date.now() - startedAt < 1000) {
|
|
312
|
+
try {
|
|
313
|
+
assertion();
|
|
314
|
+
return;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
lastError = error;
|
|
317
|
+
await act(async () => {
|
|
318
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
throw lastError;
|
|
324
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ManagedIdentity } from "../../core/azure/entra/managedIdentity";
|
|
4
|
+
import type { OwnerConfidence } from "../../core/ownership/types";
|
|
5
|
+
import type { PermissionRiskLevel } from "../../core/risk/types";
|
|
6
|
+
import { azureManagedIdentityColumnHelp } from "./azureReportConfig";
|
|
7
|
+
import { readManagedIdentities } from "./api";
|
|
8
|
+
import { GenericTable } from "../../report/components/GenericTable";
|
|
9
|
+
import type { ColumnFilters } from "../../report/components/reportTableControls";
|
|
10
|
+
import type { ReportFieldDescriptor } from "../../report/reportTypes";
|
|
11
|
+
import {
|
|
12
|
+
buildServicePrincipalFieldRenderers,
|
|
13
|
+
type AzureRbacPrincipalSelection,
|
|
14
|
+
type EntraPermissionsPrincipalSelection
|
|
15
|
+
} from "./ServicePrincipalFieldRenderers";
|
|
16
|
+
|
|
17
|
+
const permissionRiskLevelOptions: PermissionRiskLevel[] = ["high", "medium", "low", "none"];
|
|
18
|
+
const ownerConfidenceOptions: OwnerConfidence[] = ["high", "medium", "low", "none"];
|
|
19
|
+
const accountEnabledOptions = ["true", "false"];
|
|
20
|
+
|
|
21
|
+
const managedIdentityFields: ReportFieldDescriptor<ManagedIdentity>[] = [
|
|
22
|
+
{
|
|
23
|
+
id: "displayName",
|
|
24
|
+
label: "Display name",
|
|
25
|
+
valueType: "text",
|
|
26
|
+
getValue: (identity) => identity.displayName,
|
|
27
|
+
filter: { kind: "text" }
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "permissionRisk",
|
|
31
|
+
label: "Risk",
|
|
32
|
+
valueType: "riskLevel",
|
|
33
|
+
getValue: (identity) => identity.permissionRisk,
|
|
34
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "ztaRemediationCountAll",
|
|
38
|
+
label: "ZTA remediations",
|
|
39
|
+
valueType: "number",
|
|
40
|
+
getValue: (identity) => identity.ztaRemediationCountAll,
|
|
41
|
+
getFilterValue: (identity) => identity.ztaMaxRisk,
|
|
42
|
+
filterColumnId: "ztaMaxRisk",
|
|
43
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "azureRbac",
|
|
47
|
+
label: "Azure RBAC",
|
|
48
|
+
valueType: "text",
|
|
49
|
+
getValue: (identity) => identity.azureRbac,
|
|
50
|
+
getFilterValue: (identity) => identity.rbacRoleLevel,
|
|
51
|
+
filterColumnId: "rbacRoleLevel",
|
|
52
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "oauthPemrissionsCount",
|
|
56
|
+
label: "Entra permissions",
|
|
57
|
+
valueType: "number",
|
|
58
|
+
getValue: (identity) => identity.oauthPemrissionsCount,
|
|
59
|
+
getFilterValue: (identity) => identity.entraPermissionRisk,
|
|
60
|
+
filterColumnId: "entraPermissionRisk",
|
|
61
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "assignedResourceGroups",
|
|
65
|
+
label: "Assigned resource groups",
|
|
66
|
+
valueType: "list",
|
|
67
|
+
getValue: (identity) => identity.assignedResourceGroups,
|
|
68
|
+
filter: { kind: "text" }
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "potentialOwners",
|
|
72
|
+
label: "Owner",
|
|
73
|
+
valueType: "list",
|
|
74
|
+
getValue: (identity) => identity.potentialOwners,
|
|
75
|
+
filter: { kind: "text" }
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "ownerConfidence",
|
|
79
|
+
label: "Owner confidence",
|
|
80
|
+
valueType: "ownerConfidence",
|
|
81
|
+
getValue: (identity) => identity.ownerConfidence ?? "none",
|
|
82
|
+
filter: { kind: "multiSelect", options: ownerConfidenceOptions }
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "accountEnabled",
|
|
86
|
+
label: "Enabled",
|
|
87
|
+
valueType: "boolean",
|
|
88
|
+
getValue: (identity) => identity.accountEnabled,
|
|
89
|
+
filter: { kind: "multiSelect", options: accountEnabledOptions }
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "id",
|
|
93
|
+
label: "Object ID",
|
|
94
|
+
valueType: "text",
|
|
95
|
+
getValue: (identity) => identity.id,
|
|
96
|
+
filter: { kind: "text" }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: "tags",
|
|
100
|
+
label: "Tags",
|
|
101
|
+
valueType: "list",
|
|
102
|
+
getValue: (identity) => identity.tags,
|
|
103
|
+
filter: { kind: "text" }
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
export function ManagedIdentityComponent({
|
|
108
|
+
initialFilters,
|
|
109
|
+
onAzureRbacClick,
|
|
110
|
+
onEntraPermissionsClick,
|
|
111
|
+
onZtaRemediationsClick
|
|
112
|
+
}: {
|
|
113
|
+
initialFilters?: ColumnFilters;
|
|
114
|
+
onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void;
|
|
115
|
+
onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void;
|
|
116
|
+
onZtaRemediationsClick?: (objectId: string) => void;
|
|
117
|
+
}) {
|
|
118
|
+
const fieldRenderers = useMemo(
|
|
119
|
+
() =>
|
|
120
|
+
buildServicePrincipalFieldRenderers<ManagedIdentity>({
|
|
121
|
+
onAzureRbacClick,
|
|
122
|
+
onEntraPermissionsClick,
|
|
123
|
+
onZtaRemediationsClick
|
|
124
|
+
}),
|
|
125
|
+
[onAzureRbacClick, onEntraPermissionsClick, onZtaRemediationsClick]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<GenericTable
|
|
130
|
+
columnHelp={azureManagedIdentityColumnHelp}
|
|
131
|
+
emptyMessage="No managed identities match the filter."
|
|
132
|
+
fieldRenderers={fieldRenderers}
|
|
133
|
+
fields={managedIdentityFields}
|
|
134
|
+
getRowKey={(row) => row.id}
|
|
135
|
+
initialFilters={initialFilters}
|
|
136
|
+
loadPage={readManagedIdentities}
|
|
137
|
+
loadingMessage="Loading managed identities..."
|
|
138
|
+
minWidthClassName="min-w-[2160px]"
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { AzureResourceTags, ResourceGroupOwnershipRow } from "../../core/azure/resources";
|
|
4
|
+
import { appConfig } from "../../core/config";
|
|
5
|
+
import type { OwnerConfidence } from "../../core/ownership/types";
|
|
6
|
+
import type { OwnerEvidence } from "../../report/types";
|
|
7
|
+
import { azureOwnerColumnHelp } from "./azureReportConfig";
|
|
8
|
+
import { readResourceGroups, updateDisabledOwnerEvidence } from "./api";
|
|
9
|
+
import { EvidenceList } from "../../report/components/EvidenceList";
|
|
10
|
+
import { GenericTable } from "../../report/components/GenericTable";
|
|
11
|
+
import type { ColumnFilters } from "../../report/components/reportTableControls";
|
|
12
|
+
import { Card } from "../../report/components/ui/card";
|
|
13
|
+
import { getOwnerEvidenceKey, isActivityOwnerRow } from "../../report/ownerManualPrecheck";
|
|
14
|
+
import type { ReportColumnRenderers } from "../../report/buildCollectionColumns";
|
|
15
|
+
import type { ReportFieldDescriptor } from "../../report/reportTypes";
|
|
16
|
+
|
|
17
|
+
const ownerConfidenceOptions: OwnerConfidence[] = ["high", "medium", "low", "none"];
|
|
18
|
+
const resourceGroupOwnerSourceOptions = [
|
|
19
|
+
...appConfig.azure.ownership.ownerTags.map((tag) => `tag.${tag.name}`),
|
|
20
|
+
"activity.lastModifier",
|
|
21
|
+
"none"
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const resourceGroupFields: ReportFieldDescriptor<ResourceGroupOwnershipRow>[] = [
|
|
25
|
+
{
|
|
26
|
+
id: "resourceGroup",
|
|
27
|
+
label: "Resource group",
|
|
28
|
+
valueType: "text",
|
|
29
|
+
getValue: (group) => group.resourceGroup,
|
|
30
|
+
filter: { kind: "text" }
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "subscriptionName",
|
|
34
|
+
label: "Subscription",
|
|
35
|
+
valueType: "text",
|
|
36
|
+
getValue: (group) => group.subscriptionName,
|
|
37
|
+
filter: { kind: "text" }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "owner",
|
|
41
|
+
label: "Owner",
|
|
42
|
+
valueType: "text",
|
|
43
|
+
getValue: (group) => group.owner,
|
|
44
|
+
filter: { kind: "text" }
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "confidence",
|
|
48
|
+
label: "Confidence",
|
|
49
|
+
valueType: "ownerConfidence",
|
|
50
|
+
getValue: (group) => group.confidence,
|
|
51
|
+
filter: { kind: "multiSelect", options: ownerConfidenceOptions }
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "source",
|
|
55
|
+
label: "Source",
|
|
56
|
+
valueType: "text",
|
|
57
|
+
getValue: (group) => group.source,
|
|
58
|
+
filter: { kind: "multiSelect", options: resourceGroupOwnerSourceOptions }
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: "evidence",
|
|
62
|
+
label: "Evidence",
|
|
63
|
+
valueType: "list",
|
|
64
|
+
getValue: (group) => group.evidence.map((entry) => [entry.user, entry.date]),
|
|
65
|
+
filter: { kind: "text" }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "location",
|
|
69
|
+
label: "Location",
|
|
70
|
+
valueType: "text",
|
|
71
|
+
getValue: (group) => group.location,
|
|
72
|
+
filter: { kind: "text" }
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "subscriptionId",
|
|
76
|
+
label: "Subscription ID",
|
|
77
|
+
valueType: "text",
|
|
78
|
+
getValue: (group) => group.subscriptionId,
|
|
79
|
+
filter: { kind: "text" }
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "tags",
|
|
83
|
+
label: "Tags",
|
|
84
|
+
valueType: "text",
|
|
85
|
+
getValue: (group) => formatAzureTags(group.tags),
|
|
86
|
+
filter: { kind: "text" }
|
|
87
|
+
}
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export function ResourceGroupComponent() {
|
|
91
|
+
const [refreshToken, setRefreshToken] = useState(0);
|
|
92
|
+
const [toggleError, setToggleError] = useState<string | null>(null);
|
|
93
|
+
const handleOwnerEvidenceDisabledChange = useCallback(
|
|
94
|
+
async (row: ResourceGroupOwnershipRow, entry: OwnerEvidence, disabled: boolean) => {
|
|
95
|
+
setToggleError(null);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await updateDisabledOwnerEvidence({
|
|
99
|
+
disabled,
|
|
100
|
+
key: getOwnerEvidenceKey(row, entry)
|
|
101
|
+
});
|
|
102
|
+
setRefreshToken((current) => current + 1);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
setToggleError(error instanceof Error ? error.message : "Could not update owner candidate.");
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
[]
|
|
108
|
+
);
|
|
109
|
+
const resourceGroupFieldRenderers = useMemo<ReportColumnRenderers<ResourceGroupOwnershipRow>>(
|
|
110
|
+
() => ({
|
|
111
|
+
evidence: (group) => (
|
|
112
|
+
<EvidenceList
|
|
113
|
+
canDisable={isActivityOwnerRow(group)}
|
|
114
|
+
evidence={group.evidence}
|
|
115
|
+
onDisabledChange={(entry, disabled) => {
|
|
116
|
+
void handleOwnerEvidenceDisabledChange(group, entry, disabled);
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
)
|
|
120
|
+
}),
|
|
121
|
+
[handleOwnerEvidenceDisabledChange]
|
|
122
|
+
);
|
|
123
|
+
const loadResourceGroups = useCallback(
|
|
124
|
+
(input: { filters: ColumnFilters; page: number; signal: AbortSignal }) => readResourceGroups(input),
|
|
125
|
+
[refreshToken]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
{toggleError ? <Card className="border-red-200 bg-red-50 p-4 text-sm text-red-900">{toggleError}</Card> : null}
|
|
131
|
+
<GenericTable
|
|
132
|
+
columnHelp={azureOwnerColumnHelp}
|
|
133
|
+
emptyMessage="No resource groups match the filter."
|
|
134
|
+
fieldRenderers={resourceGroupFieldRenderers}
|
|
135
|
+
fields={resourceGroupFields}
|
|
136
|
+
getRowKey={getResourceGroupOwnershipRowKey}
|
|
137
|
+
loadPage={loadResourceGroups}
|
|
138
|
+
loadingMessage="Loading resource groups..."
|
|
139
|
+
minWidthClassName="min-w-[1360px]"
|
|
140
|
+
/>
|
|
141
|
+
</>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getResourceGroupOwnershipRowKey(row: Pick<ResourceGroupOwnershipRow, "subscriptionId" | "resourceGroup">) {
|
|
146
|
+
return `${row.subscriptionId}:${row.resourceGroup}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatAzureTags(tags: AzureResourceTags | null): string {
|
|
150
|
+
if (!tags) {
|
|
151
|
+
return "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return Object.entries(tags)
|
|
155
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
156
|
+
.join(", ");
|
|
157
|
+
}
|