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,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
|
|
7
|
+
import { ServicePrincipalComponent } from "./ServicePrincipalComponent";
|
|
8
|
+
import type { ServicePrincipal } from "../../core/azure/entra/servicePrincipal";
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ServicePrincipalResponse = {
|
|
15
|
+
collectionId: "entra.servicePrincipals";
|
|
16
|
+
rows: ServicePrincipal[];
|
|
17
|
+
columns: string[];
|
|
18
|
+
page: number;
|
|
19
|
+
pageSize: number;
|
|
20
|
+
count: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const columns = [
|
|
24
|
+
"displayName",
|
|
25
|
+
"servicePrincipalType",
|
|
26
|
+
"permissionRisk",
|
|
27
|
+
"ztaRemediationCountAll",
|
|
28
|
+
"ztaRemediationFailedCount",
|
|
29
|
+
"ztaMaxRisk",
|
|
30
|
+
"azureRbac",
|
|
31
|
+
"oauthPemrissionsCount",
|
|
32
|
+
"appRolesPermissionCount",
|
|
33
|
+
"entraPermissionRisk",
|
|
34
|
+
"potentialOwners",
|
|
35
|
+
"ownerConfidence",
|
|
36
|
+
"accountEnabled",
|
|
37
|
+
"id",
|
|
38
|
+
"appId",
|
|
39
|
+
"appDisplayName",
|
|
40
|
+
"publisherName",
|
|
41
|
+
"tags"
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
beforeAll(() => {
|
|
45
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
delete (globalThis as Partial<typeof globalThis>).fetch;
|
|
50
|
+
document.body.innerHTML = "";
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("loads service principals through the full table UI and sends filters and pagination to HTTP", async () => {
|
|
54
|
+
const fetchMock = jest.fn<Promise<Response>, Parameters<typeof fetch>>(async (input) => {
|
|
55
|
+
const requestUrl = String(input);
|
|
56
|
+
const url = new URL(requestUrl, window.location.origin);
|
|
57
|
+
const page = Number(url.searchParams.get("page") ?? "1");
|
|
58
|
+
const filters = readFilterQuery(url);
|
|
59
|
+
|
|
60
|
+
if (filters.displayName?.[0] === "Payroll") {
|
|
61
|
+
return jsonResponse(collection([payrollApi], { page, count: 1 }));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (filters.servicePrincipalType?.includes("Application")) {
|
|
65
|
+
return jsonResponse(collection([graphApi, payrollApi], { page: 1, count: 2 }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (filters.accountEnabled?.includes("false")) {
|
|
69
|
+
return jsonResponse(collection([disabledLegacyApp], { page: 1, count: 1 }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (filters.rbacRoleLevel?.includes("high")) {
|
|
73
|
+
return jsonResponse(collection([graphApi], { page: 1, count: 1 }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (filters.entraPermissionRisk?.includes("high")) {
|
|
77
|
+
return jsonResponse(collection([graphApi], { page: 1, count: 1 }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (filters.ztaMaxRisk?.includes("high")) {
|
|
81
|
+
return jsonResponse(collection([graphApi], { page: 1, count: 1 }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return jsonResponse(
|
|
85
|
+
collection(page === 1 ? [graphApi, payrollApi] : [disabledLegacyApp], {
|
|
86
|
+
page,
|
|
87
|
+
count: 75
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
globalThis.fetch = fetchMock;
|
|
92
|
+
|
|
93
|
+
const { container, root } = renderComponent(<ServicePrincipalComponent />);
|
|
94
|
+
|
|
95
|
+
expect(container.textContent).toContain("Loading service principals...");
|
|
96
|
+
|
|
97
|
+
await waitForText(container, "Microsoft Graph");
|
|
98
|
+
|
|
99
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/data/entra/servicePrincipals?page=1&count=20");
|
|
100
|
+
expect(getButton("Sort by Display name").textContent).toContain("Display name");
|
|
101
|
+
expect(getButton("Sort by Type").textContent).toContain("Type");
|
|
102
|
+
expect(getButton("Sort by Risk").textContent).toContain("Risk");
|
|
103
|
+
expect(getButton("Sort by ZTA remediations").textContent).toContain("ZTA remediations");
|
|
104
|
+
expect(getButton("Sort by Azure RBAC").textContent).toContain("Azure RBAC");
|
|
105
|
+
expect(getButton("Sort by Entra permissions").textContent).toContain("Entra permissions");
|
|
106
|
+
expect(getButton("Sort by Owner").textContent).toContain("Owner");
|
|
107
|
+
expect(getButton("Sort by Owner confidence").textContent).toContain("Owner confidence");
|
|
108
|
+
expect(getButton("Sort by Enabled").textContent).toContain("Enabled");
|
|
109
|
+
expect(getButton("Sort by Object ID").textContent).toContain("Object ID");
|
|
110
|
+
expect(getButton("Sort by Publisher").textContent).toContain("Publisher");
|
|
111
|
+
expect(getButton("Sort by Tags").textContent).toContain("Tags");
|
|
112
|
+
expect(container.textContent).toContain("graph-sp-id");
|
|
113
|
+
expect(container.textContent).toContain("high");
|
|
114
|
+
expect(container.textContent).toContain("2/4");
|
|
115
|
+
expect(container.textContent).toContain("3/1");
|
|
116
|
+
expect(container.textContent).toContain("1/1");
|
|
117
|
+
expect(container.textContent).toContain("platform@example.test");
|
|
118
|
+
expect(container.textContent).toContain("Microsoft");
|
|
119
|
+
expect(container.textContent).toContain("finance");
|
|
120
|
+
expect(container.textContent).toContain("Page 1 of 4");
|
|
121
|
+
|
|
122
|
+
await openValueFilter("Filter Azure RBAC");
|
|
123
|
+
await toggleCheckbox("high", true);
|
|
124
|
+
await waitForRequestContaining("filter%5B0%5D%5Bcolumn%5D=rbacRoleLevel");
|
|
125
|
+
expect(lastFetchUrl()).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=high");
|
|
126
|
+
await waitForText(container, "Microsoft Graph");
|
|
127
|
+
expect(container.textContent).not.toContain("Payroll API");
|
|
128
|
+
|
|
129
|
+
await clearValueFilter("Filter Azure RBAC");
|
|
130
|
+
await waitForText(container, "Page 1 of 4");
|
|
131
|
+
|
|
132
|
+
await openValueFilter("Filter Entra permissions");
|
|
133
|
+
await toggleCheckbox("high", true);
|
|
134
|
+
await waitForRequestContaining("filter%5B0%5D%5Bcolumn%5D=entraPermissionRisk");
|
|
135
|
+
expect(lastFetchUrl()).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=high");
|
|
136
|
+
await waitForText(container, "Microsoft Graph");
|
|
137
|
+
expect(container.textContent).not.toContain("Payroll API");
|
|
138
|
+
|
|
139
|
+
await clearValueFilter("Filter Entra permissions");
|
|
140
|
+
await waitForText(container, "Page 1 of 4");
|
|
141
|
+
|
|
142
|
+
await openValueFilter("Filter ZTA remediations");
|
|
143
|
+
await toggleCheckbox("high", true);
|
|
144
|
+
await waitForRequestContaining("filter%5B0%5D%5Bcolumn%5D=ztaMaxRisk");
|
|
145
|
+
expect(lastFetchUrl()).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=high");
|
|
146
|
+
await waitForText(container, "Microsoft Graph");
|
|
147
|
+
expect(container.textContent).not.toContain("Payroll API");
|
|
148
|
+
|
|
149
|
+
await clearValueFilter("Filter ZTA remediations");
|
|
150
|
+
await waitForText(container, "Page 1 of 4");
|
|
151
|
+
|
|
152
|
+
await changeInput("Filter Display name", "Payroll");
|
|
153
|
+
await waitForRequestContaining("filter%5B0%5D%5Bcolumn%5D=displayName");
|
|
154
|
+
expect(lastFetchUrl()).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=Payroll");
|
|
155
|
+
await waitForText(container, "Payroll API");
|
|
156
|
+
expect(container.textContent).not.toContain("Microsoft Graph");
|
|
157
|
+
|
|
158
|
+
await changeInput("Filter Display name", "");
|
|
159
|
+
await waitForText(container, "Microsoft Graph");
|
|
160
|
+
|
|
161
|
+
await openValueFilter("Filter Type");
|
|
162
|
+
await toggleCheckbox("Application", true);
|
|
163
|
+
await waitForRequestContaining("filter%5B0%5D%5Bcolumn%5D=servicePrincipalType");
|
|
164
|
+
expect(lastFetchUrl()).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=Application");
|
|
165
|
+
expect(container.textContent).toContain("Microsoft Graph");
|
|
166
|
+
expect(container.textContent).toContain("Payroll API");
|
|
167
|
+
|
|
168
|
+
await clearValueFilter("Filter Type");
|
|
169
|
+
await waitForText(container, "Page 1 of 4");
|
|
170
|
+
|
|
171
|
+
await openValueFilter("Filter Enabled");
|
|
172
|
+
await toggleCheckbox("false", true);
|
|
173
|
+
await waitForRequestContaining("filter%5B0%5D%5Bcolumn%5D=accountEnabled");
|
|
174
|
+
expect(lastFetchUrl()).toContain("filter%5B0%5D%5Bvalue%5D%5B0%5D=false");
|
|
175
|
+
await waitForText(container, "Legacy disabled app");
|
|
176
|
+
|
|
177
|
+
await clearValueFilter("Filter Enabled");
|
|
178
|
+
await waitForText(container, "Page 1 of 4");
|
|
179
|
+
|
|
180
|
+
await clickButton("Next");
|
|
181
|
+
await waitForRequestContaining("page=2&count=20");
|
|
182
|
+
await waitForText(container, "Legacy disabled app");
|
|
183
|
+
expect(container.textContent).toContain("Page 2 of 4");
|
|
184
|
+
|
|
185
|
+
act(() => root.unmount());
|
|
186
|
+
|
|
187
|
+
function lastFetchUrl() {
|
|
188
|
+
const lastCall = fetchMock.mock.calls.at(-1);
|
|
189
|
+
if (!lastCall) {
|
|
190
|
+
throw new Error("Expected fetch to have been called.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return String(lastCall[0]);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function waitForRequestContaining(part: string) {
|
|
197
|
+
await waitFor(() => {
|
|
198
|
+
expect(lastFetchUrl()).toContain(part);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("closes an open value filter when the table is clicked", async () => {
|
|
204
|
+
const fetchMock = jest.fn<Promise<Response>, Parameters<typeof fetch>>(async () =>
|
|
205
|
+
jsonResponse(collection([graphApi, payrollApi], { page: 1, count: 2 }))
|
|
206
|
+
);
|
|
207
|
+
globalThis.fetch = fetchMock;
|
|
208
|
+
|
|
209
|
+
const { container, root } = renderComponent(<ServicePrincipalComponent />);
|
|
210
|
+
|
|
211
|
+
await waitForText(container, "Microsoft Graph");
|
|
212
|
+
await openValueFilter("Filter Azure RBAC");
|
|
213
|
+
expect(findButton("Clear")).toBeDefined();
|
|
214
|
+
|
|
215
|
+
await act(async () => {
|
|
216
|
+
getCell("Microsoft Graph").dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await waitFor(() => {
|
|
220
|
+
expect(findButton("Clear")).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
act(() => root.unmount());
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const graphApi = servicePrincipal({
|
|
227
|
+
accountEnabled: true,
|
|
228
|
+
appDisplayName: "Microsoft Graph",
|
|
229
|
+
appId: "graph-client-id",
|
|
230
|
+
appRolesPermissionCount: 1,
|
|
231
|
+
displayName: "Microsoft Graph",
|
|
232
|
+
id: "graph-sp-id",
|
|
233
|
+
azureRbac: "Owner on subscription (privileged role)",
|
|
234
|
+
entraPermissionRisk: "high",
|
|
235
|
+
oauthPemrissionsCount: 3,
|
|
236
|
+
ownerConfidence: "high",
|
|
237
|
+
permissionRisk: "high",
|
|
238
|
+
potentialOwners: ["platform@example.test"],
|
|
239
|
+
publisherName: "Microsoft",
|
|
240
|
+
rbacRoleAssignmentCount: 1,
|
|
241
|
+
rbacRoleLevel: "high",
|
|
242
|
+
rbacSubscriptionCount: 1,
|
|
243
|
+
servicePrincipalType: "Application",
|
|
244
|
+
tags: ["windowsAzureActiveDirectoryIntegratedApp"],
|
|
245
|
+
ztaMaxRisk: "high",
|
|
246
|
+
ztaRemediationCountAll: 4,
|
|
247
|
+
ztaRemediationFailedCount: 2
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const payrollApi = servicePrincipal({
|
|
251
|
+
accountEnabled: true,
|
|
252
|
+
appDisplayName: "Payroll API",
|
|
253
|
+
appId: "payroll-client-id",
|
|
254
|
+
displayName: "Payroll API",
|
|
255
|
+
id: "payroll-sp-id",
|
|
256
|
+
publisherName: "Contoso",
|
|
257
|
+
servicePrincipalType: "Application",
|
|
258
|
+
tags: ["finance", "line-of-business"]
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const disabledLegacyApp = servicePrincipal({
|
|
262
|
+
accountEnabled: false,
|
|
263
|
+
appDisplayName: "Legacy disabled app",
|
|
264
|
+
appId: "legacy-client-id",
|
|
265
|
+
displayName: "Legacy disabled app",
|
|
266
|
+
id: "legacy-sp-id",
|
|
267
|
+
publisherName: null,
|
|
268
|
+
servicePrincipalType: "Legacy",
|
|
269
|
+
tags: ["disabled"]
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
function servicePrincipal(input: Partial<ServicePrincipal> & Pick<ServicePrincipal, "id" | "appId" | "displayName">): ServicePrincipal {
|
|
273
|
+
return {
|
|
274
|
+
accountEnabled: true,
|
|
275
|
+
appDisplayName: null,
|
|
276
|
+
appOwnerOrganizationId: null,
|
|
277
|
+
homepage: null,
|
|
278
|
+
loginUrl: null,
|
|
279
|
+
publisherName: null,
|
|
280
|
+
replyUrls: [],
|
|
281
|
+
servicePrincipalNames: [],
|
|
282
|
+
servicePrincipalType: "Application",
|
|
283
|
+
tags: [],
|
|
284
|
+
permissionRisk: "none",
|
|
285
|
+
azureRbac: "No Azure RBAC assignments",
|
|
286
|
+
roleAssignments: [],
|
|
287
|
+
rbacRoleAssignmentCount: 0,
|
|
288
|
+
rbacRoleLevel: "none",
|
|
289
|
+
rbacSubscriptionCount: 0,
|
|
290
|
+
oauthPemrissionsCount: 0,
|
|
291
|
+
appRolesPermissionCount: 0,
|
|
292
|
+
entraPermissionRisk: "none",
|
|
293
|
+
ztaRemediationCountAll: 0,
|
|
294
|
+
ztaRemediationFailedCount: 0,
|
|
295
|
+
ztaMaxRisk: "none",
|
|
296
|
+
...input
|
|
297
|
+
} as ServicePrincipal;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function collection(rows: ServicePrincipal[], { page, count }: { page: number; count: number }): ServicePrincipalResponse {
|
|
301
|
+
return {
|
|
302
|
+
collectionId: "entra.servicePrincipals",
|
|
303
|
+
columns,
|
|
304
|
+
count,
|
|
305
|
+
page,
|
|
306
|
+
pageSize: 20,
|
|
307
|
+
rows
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function jsonResponse(body: ServicePrincipalResponse): Response {
|
|
312
|
+
return {
|
|
313
|
+
json: async () => body,
|
|
314
|
+
ok: true,
|
|
315
|
+
status: 200
|
|
316
|
+
} as Response;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readFilterQuery(url: URL): Record<string, string[]> {
|
|
320
|
+
const filters: Record<string, string[]> = {};
|
|
321
|
+
|
|
322
|
+
for (let index = 0; ; index += 1) {
|
|
323
|
+
const column = url.searchParams.get(`filter[${index}][column]`);
|
|
324
|
+
if (!column) {
|
|
325
|
+
return filters;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
filters[column] = url.searchParams.getAll(`filter[${index}][value][0]`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } {
|
|
333
|
+
const container = document.createElement("div");
|
|
334
|
+
document.body.appendChild(container);
|
|
335
|
+
const root = createRoot(container);
|
|
336
|
+
|
|
337
|
+
act(() => {
|
|
338
|
+
root.render(component);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return { container, root };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function changeInput(label: string, value: string) {
|
|
345
|
+
const input = getInput(label);
|
|
346
|
+
|
|
347
|
+
await act(async () => {
|
|
348
|
+
setNativeInputValue(input, value);
|
|
349
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function clickButton(label: string) {
|
|
354
|
+
await act(async () => {
|
|
355
|
+
getButton(label).click();
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function openValueFilter(label: string) {
|
|
360
|
+
await clickButton(label);
|
|
361
|
+
await waitForText(document.body, "Clear");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function clearValueFilter(label: string) {
|
|
365
|
+
if (!findButton("Clear")) {
|
|
366
|
+
await openValueFilter(label);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await clickButton("Clear");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function toggleCheckbox(label: string, checked: boolean) {
|
|
373
|
+
const checkbox = getCheckbox(label);
|
|
374
|
+
|
|
375
|
+
if (checkbox.checked === checked) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await act(async () => {
|
|
380
|
+
checkbox.click();
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function waitForText(container: HTMLElement, text: string) {
|
|
385
|
+
await waitFor(() => {
|
|
386
|
+
expect(container.textContent).toContain(text);
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function waitFor(assertion: () => void) {
|
|
391
|
+
const startedAt = Date.now();
|
|
392
|
+
let lastError: unknown;
|
|
393
|
+
|
|
394
|
+
while (Date.now() - startedAt < 1000) {
|
|
395
|
+
try {
|
|
396
|
+
assertion();
|
|
397
|
+
return;
|
|
398
|
+
} catch (error) {
|
|
399
|
+
lastError = error;
|
|
400
|
+
await act(async () => {
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
throw lastError;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getInput(label: string): HTMLInputElement {
|
|
410
|
+
const input = document.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`);
|
|
411
|
+
if (!input) {
|
|
412
|
+
throw new Error(`Could not find input: ${label}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return input;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function getButton(label: string): HTMLButtonElement {
|
|
419
|
+
const button = findButton(label);
|
|
420
|
+
if (!button) {
|
|
421
|
+
throw new Error(`Could not find button: ${label}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return button;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function getCell(text: string): HTMLTableCellElement {
|
|
428
|
+
const cell = [...document.querySelectorAll<HTMLTableCellElement>("td")].find((element) =>
|
|
429
|
+
element.textContent?.includes(text)
|
|
430
|
+
);
|
|
431
|
+
if (!cell) {
|
|
432
|
+
throw new Error(`Could not find table cell: ${text}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return cell;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function findButton(label: string): HTMLButtonElement | undefined {
|
|
439
|
+
return [...document.querySelectorAll<HTMLButtonElement>("button")].find(
|
|
440
|
+
(element) => element.getAttribute("aria-label") === label || element.textContent?.trim() === label
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function getCheckbox(label: string): HTMLInputElement {
|
|
445
|
+
const labelElement = [...document.querySelectorAll("label")].find((element) => element.textContent?.trim() === label);
|
|
446
|
+
const checkbox = labelElement?.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
|
447
|
+
if (!checkbox) {
|
|
448
|
+
throw new Error(`Could not find checkbox: ${label}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return checkbox;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function setNativeInputValue(input: HTMLInputElement, value: string) {
|
|
455
|
+
const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value");
|
|
456
|
+
descriptor?.set?.call(input, value);
|
|
457
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
|
|
3
|
+
import type { EntraServicePrincipalType } from "../../core/azure/entra/types";
|
|
4
|
+
import type { ServicePrincipal } from "../../core/azure/entra/servicePrincipal";
|
|
5
|
+
import type { OwnerConfidence } from "../../core/ownership/types";
|
|
6
|
+
import type { PermissionRiskLevel } from "../../core/risk/types";
|
|
7
|
+
import { azureServicePrincipalColumnHelp } from "./azureReportConfig";
|
|
8
|
+
import { readServicePrincipals } from "./api";
|
|
9
|
+
import { GenericTable } from "../../report/components/GenericTable";
|
|
10
|
+
import type { ColumnFilters } from "../../report/components/reportTableControls";
|
|
11
|
+
import type { ReportFieldDescriptor } from "../../report/reportTypes";
|
|
12
|
+
import {
|
|
13
|
+
buildServicePrincipalFieldRenderers,
|
|
14
|
+
type AzureRbacPrincipalSelection,
|
|
15
|
+
type EntraPermissionsPrincipalSelection
|
|
16
|
+
} from "./ServicePrincipalFieldRenderers";
|
|
17
|
+
|
|
18
|
+
const permissionRiskLevelOptions: PermissionRiskLevel[] = ["high", "medium", "low", "none"];
|
|
19
|
+
const ownerConfidenceOptions: OwnerConfidence[] = ["high", "medium", "low", "none"];
|
|
20
|
+
const servicePrincipalTypeOptions: Array<Exclude<EntraServicePrincipalType, "ManagedIdentity">> = [
|
|
21
|
+
"Application",
|
|
22
|
+
"SocialIdp",
|
|
23
|
+
"Legacy"
|
|
24
|
+
];
|
|
25
|
+
const accountEnabledOptions = ["true", "false"];
|
|
26
|
+
|
|
27
|
+
const servicePrincipalFields: ReportFieldDescriptor<ServicePrincipal>[] = [
|
|
28
|
+
{
|
|
29
|
+
id: "displayName",
|
|
30
|
+
label: "Display name",
|
|
31
|
+
valueType: "text",
|
|
32
|
+
getValue: (sp) => sp.displayName,
|
|
33
|
+
filter: { kind: "text" }
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "servicePrincipalType",
|
|
37
|
+
label: "Type",
|
|
38
|
+
valueType: "text",
|
|
39
|
+
getValue: (sp) => sp.servicePrincipalType,
|
|
40
|
+
filter: { kind: "multiSelect", options: servicePrincipalTypeOptions }
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "permissionRisk",
|
|
44
|
+
label: "Risk",
|
|
45
|
+
valueType: "riskLevel",
|
|
46
|
+
getValue: (sp) => sp.permissionRisk,
|
|
47
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "ztaRemediationCountAll",
|
|
51
|
+
label: "ZTA remediations",
|
|
52
|
+
valueType: "number",
|
|
53
|
+
getValue: (sp) => sp.ztaRemediationCountAll,
|
|
54
|
+
getFilterValue: (sp) => sp.ztaMaxRisk,
|
|
55
|
+
filterColumnId: "ztaMaxRisk",
|
|
56
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "azureRbac",
|
|
60
|
+
label: "Azure RBAC",
|
|
61
|
+
valueType: "text",
|
|
62
|
+
getValue: (sp) => sp.azureRbac,
|
|
63
|
+
getFilterValue: (sp) => sp.rbacRoleLevel,
|
|
64
|
+
filterColumnId: "rbacRoleLevel",
|
|
65
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "oauthPemrissionsCount",
|
|
69
|
+
label: "Entra permissions",
|
|
70
|
+
valueType: "text",
|
|
71
|
+
getValue: (sp) => sp.oauthPemrissionsCount,
|
|
72
|
+
getFilterValue: (sp) => sp.entraPermissionRisk,
|
|
73
|
+
filterColumnId: "entraPermissionRisk",
|
|
74
|
+
filter: { kind: "multiSelect", options: permissionRiskLevelOptions }
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "potentialOwners",
|
|
78
|
+
label: "Owner",
|
|
79
|
+
valueType: "list",
|
|
80
|
+
getValue: (sp) => sp.potentialOwners,
|
|
81
|
+
filter: { kind: "text" }
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "ownerConfidence",
|
|
85
|
+
label: "Owner confidence",
|
|
86
|
+
valueType: "ownerConfidence",
|
|
87
|
+
getValue: (sp) => sp.ownerConfidence ?? "none",
|
|
88
|
+
filter: { kind: "multiSelect", options: ownerConfidenceOptions }
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "accountEnabled",
|
|
92
|
+
label: "Enabled",
|
|
93
|
+
valueType: "boolean",
|
|
94
|
+
getValue: (sp) => sp.accountEnabled,
|
|
95
|
+
filter: { kind: "multiSelect", options: accountEnabledOptions }
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "id",
|
|
99
|
+
label: "Object ID",
|
|
100
|
+
valueType: "text",
|
|
101
|
+
getValue: (sp) => sp.id,
|
|
102
|
+
filter: { kind: "text" }
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
id: "publisherName",
|
|
106
|
+
label: "Publisher",
|
|
107
|
+
valueType: "text",
|
|
108
|
+
getValue: (sp) => sp.publisherName,
|
|
109
|
+
filter: { kind: "text" }
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: "tags",
|
|
113
|
+
label: "Tags",
|
|
114
|
+
valueType: "list",
|
|
115
|
+
getValue: (sp) => sp.tags,
|
|
116
|
+
filter: { kind: "text" }
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
export function ServicePrincipalComponent({
|
|
122
|
+
initialFilters,
|
|
123
|
+
onAzureRbacClick,
|
|
124
|
+
onEntraPermissionsClick,
|
|
125
|
+
onZtaRemediationsClick
|
|
126
|
+
}: {
|
|
127
|
+
initialFilters?: ColumnFilters;
|
|
128
|
+
onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void;
|
|
129
|
+
onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void;
|
|
130
|
+
onZtaRemediationsClick?: (objectId: string) => void;
|
|
131
|
+
}) {
|
|
132
|
+
const fieldRenderers = useMemo(
|
|
133
|
+
() =>
|
|
134
|
+
buildServicePrincipalFieldRenderers<ServicePrincipal>({
|
|
135
|
+
onAzureRbacClick,
|
|
136
|
+
onEntraPermissionsClick,
|
|
137
|
+
onZtaRemediationsClick
|
|
138
|
+
}),
|
|
139
|
+
[onAzureRbacClick, onEntraPermissionsClick, onZtaRemediationsClick]
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<GenericTable
|
|
144
|
+
columnHelp={azureServicePrincipalColumnHelp}
|
|
145
|
+
emptyMessage="No service principals match the filter."
|
|
146
|
+
fieldRenderers={fieldRenderers}
|
|
147
|
+
fields={servicePrincipalFields}
|
|
148
|
+
getRowKey={(row) => row.id}
|
|
149
|
+
initialFilters={initialFilters}
|
|
150
|
+
loadPage={readServicePrincipals}
|
|
151
|
+
loadingMessage="Loading service principals..."
|
|
152
|
+
minWidthClassName="min-w-[2400px]"
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|