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,140 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EntraPrincipalPermissionSummary,
|
|
3
|
+
EntraPrincipalRbacSummary
|
|
4
|
+
} from "../../core/azure/entra/servicePrincipal";
|
|
5
|
+
import type { PermissionRiskLevel } from "../../core/risk/types";
|
|
6
|
+
import type { ZtaRemediationSummary } from "../../core/azure/ztaReport";
|
|
7
|
+
import type { ReportColumnRenderers } from "../../report/buildCollectionColumns";
|
|
8
|
+
import { Badge, type BadgeProps } from "../../report/components/ui/badge";
|
|
9
|
+
import { ZtaRemediationBadge } from "./ZtaRemediationBadge";
|
|
10
|
+
|
|
11
|
+
type EntraPrincipalSummaryRow = EntraPrincipalPermissionSummary & EntraPrincipalRbacSummary & ZtaRemediationSummary & {
|
|
12
|
+
azureRbac: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
id: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type AzureRbacPrincipalSelection = {
|
|
18
|
+
displayName: string;
|
|
19
|
+
objectId: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type EntraPermissionsPrincipalSelection = AzureRbacPrincipalSelection;
|
|
23
|
+
|
|
24
|
+
export function buildServicePrincipalFieldRenderers<TRow extends EntraPrincipalSummaryRow>({
|
|
25
|
+
onAzureRbacClick,
|
|
26
|
+
onEntraPermissionsClick,
|
|
27
|
+
onZtaRemediationsClick
|
|
28
|
+
}: {
|
|
29
|
+
onAzureRbacClick?: (principal: AzureRbacPrincipalSelection) => void;
|
|
30
|
+
onEntraPermissionsClick?: (principal: EntraPermissionsPrincipalSelection) => void;
|
|
31
|
+
onZtaRemediationsClick?: (objectId: string) => void;
|
|
32
|
+
} = {}): ReportColumnRenderers<TRow> {
|
|
33
|
+
return {
|
|
34
|
+
azureRbac: (sp) => (
|
|
35
|
+
<RbacSummaryBadge
|
|
36
|
+
rbacRoleAssignmentCount={sp.rbacRoleAssignmentCount}
|
|
37
|
+
rbacRoleLevel={sp.rbacRoleLevel}
|
|
38
|
+
rbacSubscriptionCount={sp.rbacSubscriptionCount}
|
|
39
|
+
title={sp.azureRbac}
|
|
40
|
+
onClick={onAzureRbacClick ? () => onAzureRbacClick({ displayName: sp.displayName, objectId: sp.id }) : undefined}
|
|
41
|
+
/>
|
|
42
|
+
),
|
|
43
|
+
oauthPemrissionsCount: (sp) => (
|
|
44
|
+
<PermissionCountBadge
|
|
45
|
+
appRolePermissionsCount={sp.appRolesPermissionCount}
|
|
46
|
+
entraPermissionRisk={sp.entraPermissionRisk}
|
|
47
|
+
oauthPermissionsCount={sp.oauthPemrissionsCount}
|
|
48
|
+
onClick={
|
|
49
|
+
onEntraPermissionsClick ? () => onEntraPermissionsClick({ displayName: sp.displayName, objectId: sp.id }) : undefined
|
|
50
|
+
}
|
|
51
|
+
/>
|
|
52
|
+
),
|
|
53
|
+
ztaRemediationCountAll: (sp) => (
|
|
54
|
+
<ZtaRemediationBadge
|
|
55
|
+
ztaMaxRisk={sp.ztaMaxRisk}
|
|
56
|
+
ztaRemediationCountAll={sp.ztaRemediationCountAll}
|
|
57
|
+
ztaRemediationFailedCount={sp.ztaRemediationFailedCount}
|
|
58
|
+
onClick={onZtaRemediationsClick ? () => onZtaRemediationsClick(sp.id) : undefined}
|
|
59
|
+
/>
|
|
60
|
+
)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const servicePrincipalFieldRenderers = buildServicePrincipalFieldRenderers();
|
|
65
|
+
|
|
66
|
+
const permissionRiskBadgeVariants: Record<PermissionRiskLevel, BadgeProps["variant"]> = {
|
|
67
|
+
high: "riskHigh",
|
|
68
|
+
medium: "riskMedium",
|
|
69
|
+
low: "riskLow",
|
|
70
|
+
none: "riskNone"
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function RbacSummaryBadge({
|
|
74
|
+
onClick,
|
|
75
|
+
rbacRoleAssignmentCount,
|
|
76
|
+
rbacRoleLevel,
|
|
77
|
+
rbacSubscriptionCount,
|
|
78
|
+
title
|
|
79
|
+
}: EntraPrincipalRbacSummary & { title: string; onClick?: () => void }) {
|
|
80
|
+
const badge = (
|
|
81
|
+
<Badge
|
|
82
|
+
className="min-w-12 justify-center tabular-nums"
|
|
83
|
+
title={title}
|
|
84
|
+
variant={permissionRiskBadgeVariants[rbacRoleLevel]}
|
|
85
|
+
>
|
|
86
|
+
{rbacRoleAssignmentCount}/{rbacSubscriptionCount}
|
|
87
|
+
</Badge>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (!onClick) {
|
|
91
|
+
return badge;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
aria-label={`Open Azure RBAC assignments ${rbacRoleAssignmentCount}/${rbacSubscriptionCount}`}
|
|
97
|
+
className="rounded-full transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
98
|
+
title={title}
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onClick}
|
|
101
|
+
>
|
|
102
|
+
{badge}
|
|
103
|
+
</button>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function PermissionCountBadge({
|
|
108
|
+
appRolePermissionsCount,
|
|
109
|
+
entraPermissionRisk,
|
|
110
|
+
onClick,
|
|
111
|
+
oauthPermissionsCount
|
|
112
|
+
}: {
|
|
113
|
+
appRolePermissionsCount: number;
|
|
114
|
+
entraPermissionRisk: PermissionRiskLevel;
|
|
115
|
+
onClick?: () => void;
|
|
116
|
+
oauthPermissionsCount: number;
|
|
117
|
+
}) {
|
|
118
|
+
const label = `${oauthPermissionsCount}/${appRolePermissionsCount}`;
|
|
119
|
+
const badge = (
|
|
120
|
+
<Badge className="min-w-8 justify-center tabular-nums" variant={permissionRiskBadgeVariants[entraPermissionRisk]}>
|
|
121
|
+
{label}
|
|
122
|
+
</Badge>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (!onClick) {
|
|
126
|
+
return badge;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
aria-label={`Open Entra permissions ${label}`}
|
|
132
|
+
className="rounded-full transition-opacity hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
133
|
+
title={`Open Entra permissions ${label}`}
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={onClick}
|
|
136
|
+
>
|
|
137
|
+
{badge}
|
|
138
|
+
</button>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
|
|
7
|
+
import type { ZtaReport } from "../../core/azure/ztaReport";
|
|
8
|
+
import { ZtaComponent } from "./ZtaComponent";
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
delete (globalThis as Partial<typeof globalThis>).fetch;
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("loads Zero Trust Assessment report metadata and tests", async () => {
|
|
24
|
+
const fetchMock = jest.fn<Promise<Response>, Parameters<typeof fetch>>(async () =>
|
|
25
|
+
jsonResponse({
|
|
26
|
+
Meta: {
|
|
27
|
+
Account: "admin@example.test",
|
|
28
|
+
CurrentVersion: "1.0.0",
|
|
29
|
+
Domain: "example.test",
|
|
30
|
+
ExecutedAt: "2026-06-02T16:06:31.3057648+02:00",
|
|
31
|
+
LatestVersion: "1.1.0",
|
|
32
|
+
TenantId: "tenant-1",
|
|
33
|
+
TenantName: "Example Tenant",
|
|
34
|
+
TestResultSummary: {
|
|
35
|
+
Failed: 1,
|
|
36
|
+
Passed: 1
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
Tests: [
|
|
40
|
+
{
|
|
41
|
+
TestCategory: "Identity",
|
|
42
|
+
TestId: "21791",
|
|
43
|
+
TestImpact: "High security impact",
|
|
44
|
+
TestImplementationCost: "Low",
|
|
45
|
+
TestMinimumLicense: ["Free"],
|
|
46
|
+
TestPillar: "Identity",
|
|
47
|
+
TestResult: "Failed",
|
|
48
|
+
TestRisk: "High",
|
|
49
|
+
RelatedObjects: [
|
|
50
|
+
{
|
|
51
|
+
id: "related-object-1"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
object_id: "related-object-2"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
TestStatus: "Completed",
|
|
58
|
+
TestTags: ["mfa", "identity"],
|
|
59
|
+
TestTitle: "Require MFA for administrators"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
SkippedReason: "Feature disabled",
|
|
63
|
+
TestCategory: "Devices",
|
|
64
|
+
TestId: 21823,
|
|
65
|
+
TestPillar: "Endpoint",
|
|
66
|
+
TestResult: "Skipped",
|
|
67
|
+
TestRisk: "Medium",
|
|
68
|
+
TestStatus: "Skipped",
|
|
69
|
+
TestTitle: "Require compliant devices"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
})
|
|
73
|
+
);
|
|
74
|
+
globalThis.fetch = fetchMock;
|
|
75
|
+
|
|
76
|
+
const { container, root } = renderComponent(<ZtaComponent />);
|
|
77
|
+
|
|
78
|
+
expect(container.textContent).toContain("Loading Zero Trust Assessment report...");
|
|
79
|
+
|
|
80
|
+
await waitForText(container, "Require MFA for administrators");
|
|
81
|
+
|
|
82
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/data/zeroTrustAssessment/report?page=1&count=20");
|
|
83
|
+
expect(getButton("Sort by Test ID").textContent).toContain("Test ID");
|
|
84
|
+
expect(getButton("Sort by Related objects").textContent).toContain("Related objects");
|
|
85
|
+
expect(getButton("Sort by Risk").textContent).toContain("Risk");
|
|
86
|
+
expect(queryButton("Sort by Result")).toBeNull();
|
|
87
|
+
expect(container.textContent).toContain("Example Tenant");
|
|
88
|
+
expect(container.textContent).toContain("21791");
|
|
89
|
+
expect(container.textContent).toContain("Completed");
|
|
90
|
+
expect(container.textContent).toContain("related-object-1");
|
|
91
|
+
expect(container.textContent).toContain("related-object-2");
|
|
92
|
+
expect(container.textContent).toContain("High security impact");
|
|
93
|
+
expect(container.textContent).toContain("Free");
|
|
94
|
+
expect(container.textContent).toContain("mfa");
|
|
95
|
+
expect(container.textContent).toContain("Feature disabled");
|
|
96
|
+
|
|
97
|
+
act(() => root.unmount());
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("filters related objects by non-rendered related object fields", async () => {
|
|
101
|
+
const tests: ZtaReport["Tests"] = [
|
|
102
|
+
{
|
|
103
|
+
TestId: "21791",
|
|
104
|
+
RelatedObjects: [
|
|
105
|
+
{
|
|
106
|
+
id: "related-object-1",
|
|
107
|
+
displayName: "Privileged automation app",
|
|
108
|
+
servicePrincipalType: "Application"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
TestStatus: "Completed",
|
|
112
|
+
TestTitle: "Require MFA for administrators"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
TestId: "21823",
|
|
116
|
+
RelatedObjects: [
|
|
117
|
+
{
|
|
118
|
+
id: "related-object-2",
|
|
119
|
+
displayName: "Break glass account",
|
|
120
|
+
userPrincipalName: "breakglass@example.test"
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
TestStatus: "Completed",
|
|
124
|
+
TestTitle: "Require compliant devices"
|
|
125
|
+
}
|
|
126
|
+
];
|
|
127
|
+
const fetchMock = jest.fn<Promise<Response>, Parameters<typeof fetch>>(async (input) => {
|
|
128
|
+
const url = new URL(String(input), window.location.origin);
|
|
129
|
+
const filterValue = url.searchParams.get("filter[0][value][0]");
|
|
130
|
+
const filteredTests = filterValue ? tests.filter((test) => JSON.stringify(test).includes(filterValue)) : tests;
|
|
131
|
+
|
|
132
|
+
return jsonResponse({
|
|
133
|
+
Meta: {
|
|
134
|
+
TenantName: "Example Tenant"
|
|
135
|
+
},
|
|
136
|
+
Tests: filteredTests
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
globalThis.fetch = fetchMock;
|
|
140
|
+
|
|
141
|
+
const { container, root } = renderComponent(<ZtaComponent />);
|
|
142
|
+
|
|
143
|
+
await waitForText(container, "Require MFA for administrators");
|
|
144
|
+
expect(container.textContent).not.toContain("Privileged automation app");
|
|
145
|
+
|
|
146
|
+
act(() => {
|
|
147
|
+
changeInputValue(getInput("Filter Related objects"), "Privileged automation");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
const filteredRequest = fetchMock.mock.calls
|
|
152
|
+
.map(([input]) => String(input))
|
|
153
|
+
.find((requestUrl) => {
|
|
154
|
+
const url = new URL(requestUrl, window.location.origin);
|
|
155
|
+
return url.searchParams.get("filter[0][column]") === "RelatedObjects";
|
|
156
|
+
});
|
|
157
|
+
expect(filteredRequest).toBeDefined();
|
|
158
|
+
expect(container.textContent).toContain("Require MFA for administrators");
|
|
159
|
+
expect(container.textContent).not.toContain("Require compliant devices");
|
|
160
|
+
expect(container.textContent).toContain("related-object-1");
|
|
161
|
+
expect(container.textContent).not.toContain("Privileged automation app");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
act(() => root.unmount());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("renders HTTP errors", async () => {
|
|
168
|
+
const fetchMock = jest.fn<Promise<Response>, Parameters<typeof fetch>>(async () =>
|
|
169
|
+
({
|
|
170
|
+
ok: false,
|
|
171
|
+
status: 500
|
|
172
|
+
}) as Response
|
|
173
|
+
);
|
|
174
|
+
globalThis.fetch = fetchMock;
|
|
175
|
+
|
|
176
|
+
const { container, root } = renderComponent(<ZtaComponent />);
|
|
177
|
+
|
|
178
|
+
await waitForText(container, "Zero Trust Assessment report read failed: 500");
|
|
179
|
+
|
|
180
|
+
act(() => root.unmount());
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
function jsonResponse(body: ZtaReport): Response {
|
|
184
|
+
return {
|
|
185
|
+
json: async () => ({
|
|
186
|
+
collectionId: "zeroTrustAssessment.report",
|
|
187
|
+
rows: body.Tests,
|
|
188
|
+
columns: [],
|
|
189
|
+
page: 1,
|
|
190
|
+
pageSize: 20,
|
|
191
|
+
count: body.Tests.length,
|
|
192
|
+
...body
|
|
193
|
+
}),
|
|
194
|
+
ok: true,
|
|
195
|
+
status: 200
|
|
196
|
+
} as Response;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderComponent(component: React.ReactNode): { container: HTMLElement; root: Root } {
|
|
200
|
+
const container = document.createElement("div");
|
|
201
|
+
document.body.appendChild(container);
|
|
202
|
+
const root = createRoot(container);
|
|
203
|
+
|
|
204
|
+
act(() => {
|
|
205
|
+
root.render(component);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { container, root };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getButton(label: string): HTMLButtonElement {
|
|
212
|
+
const button = queryButton(label);
|
|
213
|
+
if (!button) {
|
|
214
|
+
throw new Error(`Expected button ${label}.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return button;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function queryButton(label: string): HTMLButtonElement | null {
|
|
221
|
+
const button = [...document.querySelectorAll("button")].find((candidate) => candidate.getAttribute("aria-label") === label);
|
|
222
|
+
if (!(button instanceof HTMLButtonElement)) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return button;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getInput(label: string): HTMLInputElement {
|
|
230
|
+
const input = [...document.querySelectorAll("input")].find((candidate) => candidate.getAttribute("aria-label") === label);
|
|
231
|
+
if (!(input instanceof HTMLInputElement)) {
|
|
232
|
+
throw new Error(`Expected input ${label}.`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return input;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function changeInputValue(input: HTMLInputElement, value: string): void {
|
|
239
|
+
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
240
|
+
valueSetter?.call(input, value);
|
|
241
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function waitForText(container: HTMLElement, text: string) {
|
|
245
|
+
await waitFor(() => {
|
|
246
|
+
expect(container.textContent).toContain(text);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function waitFor(assertion: () => void): Promise<void> {
|
|
251
|
+
const startedAt = Date.now();
|
|
252
|
+
let lastError: unknown;
|
|
253
|
+
|
|
254
|
+
while (Date.now() - startedAt < 1000) {
|
|
255
|
+
try {
|
|
256
|
+
assertion();
|
|
257
|
+
return;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
lastError = error;
|
|
260
|
+
await act(async () => {
|
|
261
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw lastError;
|
|
267
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ZtaRelatedObject, ZtaReportMeta, ZtaReportTest } from "../../core/azure/ztaReport";
|
|
4
|
+
import { formatDate, formatValue } from "../../lib/utils";
|
|
5
|
+
import type { ReportColumnRenderers } from "../../report/buildCollectionColumns";
|
|
6
|
+
import { GenericTable } from "../../report/components/GenericTable";
|
|
7
|
+
import type { ColumnFilters } from "../../report/components/reportTableControls";
|
|
8
|
+
import { Badge } from "../../report/components/ui/badge";
|
|
9
|
+
import { Card } from "../../report/components/ui/card";
|
|
10
|
+
import type { ReportFieldDescriptor } from "../../report/reportTypes";
|
|
11
|
+
import { readZeroTrustAssessmentReport } from "./api";
|
|
12
|
+
|
|
13
|
+
type ZtaTestRow = ZtaReportTest & {
|
|
14
|
+
rowIndex: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type ZtaComponentProps = {
|
|
18
|
+
initialFilters?: ColumnFilters;
|
|
19
|
+
onRelatedObjectClick?: (relatedObject: ZtaRelatedObject) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ztaStatusOptions = ["Completed", "Skipped", "Passed", "Failed"];
|
|
23
|
+
const ztaRiskOptions = ["High", "Medium", "Low", "None"];
|
|
24
|
+
|
|
25
|
+
const ztaTestFields: ReportFieldDescriptor<ZtaTestRow>[] = [
|
|
26
|
+
{
|
|
27
|
+
id: "TestId",
|
|
28
|
+
label: "Test ID",
|
|
29
|
+
valueType: "text",
|
|
30
|
+
getValue: (test) => test.TestId,
|
|
31
|
+
filter: { kind: "text" }
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "TestTitle",
|
|
35
|
+
label: "Title",
|
|
36
|
+
valueType: "text",
|
|
37
|
+
getValue: (test) => test.TestTitle,
|
|
38
|
+
filter: { kind: "text" }
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "TestStatus",
|
|
42
|
+
label: "Status",
|
|
43
|
+
valueType: "text",
|
|
44
|
+
getValue: (test) => test.TestStatus,
|
|
45
|
+
filter: { kind: "multiSelect", options: ztaStatusOptions }
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "RelatedObjects",
|
|
49
|
+
label: "Related objects",
|
|
50
|
+
valueType: "list",
|
|
51
|
+
getValue: getRelatedObjectSearchValues,
|
|
52
|
+
filter: { kind: "text" }
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "TestRisk",
|
|
56
|
+
label: "Risk",
|
|
57
|
+
valueType: "riskLevel",
|
|
58
|
+
getValue: (test) => test.TestRisk,
|
|
59
|
+
filter: { kind: "multiSelect", options: ztaRiskOptions }
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "TestPillar",
|
|
63
|
+
label: "Pillar",
|
|
64
|
+
valueType: "text",
|
|
65
|
+
getValue: (test) => test.TestPillar,
|
|
66
|
+
filter: { kind: "text" }
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "TestCategory",
|
|
70
|
+
label: "Category",
|
|
71
|
+
valueType: "text",
|
|
72
|
+
getValue: (test) => test.TestCategory,
|
|
73
|
+
filter: { kind: "text" }
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "TestImpact",
|
|
77
|
+
label: "Impact",
|
|
78
|
+
valueType: "riskLevel",
|
|
79
|
+
getValue: (test) => test.TestImpact,
|
|
80
|
+
filter: { kind: "text" }
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "TestImplementationCost",
|
|
84
|
+
label: "Implementation cost",
|
|
85
|
+
valueType: "text",
|
|
86
|
+
getValue: (test) => test.TestImplementationCost,
|
|
87
|
+
filter: { kind: "text" }
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "TestMinimumLicense",
|
|
91
|
+
label: "Minimum license",
|
|
92
|
+
valueType: "list",
|
|
93
|
+
getValue: (test) => test.TestMinimumLicense,
|
|
94
|
+
filter: { kind: "text" }
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
id: "TestTags",
|
|
98
|
+
label: "Tags",
|
|
99
|
+
valueType: "list",
|
|
100
|
+
getValue: (test) => test.TestTags,
|
|
101
|
+
filter: { kind: "text" }
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
id: "SkippedReason",
|
|
105
|
+
label: "Skipped reason",
|
|
106
|
+
valueType: "text",
|
|
107
|
+
getValue: (test) => test.SkippedReason,
|
|
108
|
+
filter: { kind: "text" }
|
|
109
|
+
}
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
export function ZtaComponent({ initialFilters, onRelatedObjectClick }: ZtaComponentProps = {}) {
|
|
113
|
+
const [meta, setMeta] = useState<ZtaReportMeta | null>(null);
|
|
114
|
+
const [testCount, setTestCount] = useState(0);
|
|
115
|
+
const fieldRenderers = useMemo<ReportColumnRenderers<ZtaTestRow>>(
|
|
116
|
+
() => ({
|
|
117
|
+
RelatedObjects: (test) => (
|
|
118
|
+
<RelatedObjectBadges objects={getRelatedObjectsWithIds(test)} onRelatedObjectClick={onRelatedObjectClick} />
|
|
119
|
+
)
|
|
120
|
+
}),
|
|
121
|
+
[onRelatedObjectClick]
|
|
122
|
+
);
|
|
123
|
+
const loadPage = useCallback(
|
|
124
|
+
async ({ filters, page, signal }: { filters: ColumnFilters; page: number; signal: AbortSignal }) => {
|
|
125
|
+
const report = await readZeroTrustAssessmentReport({ filters, page, signal });
|
|
126
|
+
const responsePage = report.page;
|
|
127
|
+
const responsePageSize = report.pageSize;
|
|
128
|
+
const rows = (report.Tests ?? report.rows ?? []).map((test, rowIndex) => ({
|
|
129
|
+
...test,
|
|
130
|
+
rowIndex: (responsePage - 1) * responsePageSize + rowIndex
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
setMeta(report.Meta);
|
|
134
|
+
setTestCount(report.count);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
rows,
|
|
138
|
+
page: responsePage,
|
|
139
|
+
pageSize: responsePageSize,
|
|
140
|
+
count: report.count
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
[]
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<section className="flex flex-col gap-4">
|
|
148
|
+
{meta ? <ZtaMetaPanel meta={meta} testCount={testCount} /> : null}
|
|
149
|
+
<GenericTable
|
|
150
|
+
emptyMessage="No Zero Trust Assessment tests found."
|
|
151
|
+
fields={ztaTestFields}
|
|
152
|
+
fieldRenderers={fieldRenderers}
|
|
153
|
+
getRowKey={(row) => `${formatValue(row.TestId)}:${row.rowIndex}`}
|
|
154
|
+
initialFilters={initialFilters}
|
|
155
|
+
loadPage={loadPage}
|
|
156
|
+
loadingMessage="Loading Zero Trust Assessment report..."
|
|
157
|
+
minWidthClassName="min-w-[2200px]"
|
|
158
|
+
/>
|
|
159
|
+
</section>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function ZtaMetaPanel({ meta, testCount }: { meta: ZtaReportMeta; testCount: number }) {
|
|
164
|
+
return (
|
|
165
|
+
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
166
|
+
<SummaryCard label="Tenant" value={meta.TenantName ?? meta.TenantId} />
|
|
167
|
+
<SummaryCard label="Executed" value={formatDate(meta.ExecutedAt)} />
|
|
168
|
+
<SummaryCard label="Tests" value={testCount} />
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function SummaryCard({ label, value }: { label: string; value: unknown }) {
|
|
174
|
+
return (
|
|
175
|
+
<Card className="flex min-h-24 flex-col gap-2 p-4">
|
|
176
|
+
<span className="text-sm text-muted-foreground">{label}</span>
|
|
177
|
+
<strong className="[overflow-wrap:anywhere] text-xl leading-tight">{formatValue(value)}</strong>
|
|
178
|
+
</Card>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function RelatedObjectBadges({
|
|
183
|
+
objects,
|
|
184
|
+
onRelatedObjectClick
|
|
185
|
+
}: {
|
|
186
|
+
objects: ZtaRelatedObject[];
|
|
187
|
+
onRelatedObjectClick?: (relatedObject: ZtaRelatedObject) => void;
|
|
188
|
+
}) {
|
|
189
|
+
if (objects.length === 0) {
|
|
190
|
+
return formatValue(null);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div className="flex max-w-96 flex-wrap gap-1">
|
|
195
|
+
{objects.map((object) => {
|
|
196
|
+
const id = getRelatedObjectId(object);
|
|
197
|
+
const title = getRelatedObjectTooltipTitle(object);
|
|
198
|
+
|
|
199
|
+
if (!onRelatedObjectClick) {
|
|
200
|
+
return (
|
|
201
|
+
<Badge key={id} className="max-w-full font-mono font-medium" title={title} variant="outline">
|
|
202
|
+
<span className="truncate">{id}</span>
|
|
203
|
+
</Badge>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<button
|
|
209
|
+
key={id}
|
|
210
|
+
aria-label={`Open related object ${id}`}
|
|
211
|
+
className="inline-flex max-w-full items-center rounded-full border px-2.5 py-0.5 font-mono text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
212
|
+
title={title}
|
|
213
|
+
type="button"
|
|
214
|
+
onClick={() => onRelatedObjectClick(object)}
|
|
215
|
+
>
|
|
216
|
+
<span className="truncate">{id}</span>
|
|
217
|
+
</button>
|
|
218
|
+
);
|
|
219
|
+
})}
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function getRelatedObjectSearchValues(test: ZtaReportTest): string[] {
|
|
225
|
+
return getRelatedObjectsWithIds(test).flatMap(getRelatedObjectSearchValuesForObject);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getRelatedObjectSearchValuesForObject(object: ZtaRelatedObject): string[] {
|
|
229
|
+
return [
|
|
230
|
+
object.id,
|
|
231
|
+
object.object_id,
|
|
232
|
+
object.servicePrincipalId,
|
|
233
|
+
object.applicationId,
|
|
234
|
+
object.displayName,
|
|
235
|
+
object.servicePrincipalType,
|
|
236
|
+
object.userPrincipalName,
|
|
237
|
+
...(object.tags ?? [])
|
|
238
|
+
].filter(isNonEmptyString);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function getRelatedObjectsWithIds(test: ZtaReportTest): ZtaRelatedObject[] {
|
|
242
|
+
return (test.RelatedObjects ?? []).filter((object): object is ZtaRelatedObject & ({ id: string } | { object_id: string }) =>
|
|
243
|
+
isNonEmptyString(getRelatedObjectId(object))
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getRelatedObjectId(object: ZtaRelatedObject): string {
|
|
248
|
+
return object.id ?? object.object_id ?? "";
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function getRelatedObjectTooltipTitle(object: ZtaRelatedObject): string {
|
|
252
|
+
return [
|
|
253
|
+
["id", object.id],
|
|
254
|
+
["object_id", object.object_id],
|
|
255
|
+
["servicePrincipalId", object.servicePrincipalId],
|
|
256
|
+
["tags", object.tags],
|
|
257
|
+
["applicationId", object.applicationId],
|
|
258
|
+
["displayName", object.displayName],
|
|
259
|
+
["servicePrincipalType", object.servicePrincipalType],
|
|
260
|
+
["userPrincipalName", object.userPrincipalName]
|
|
261
|
+
]
|
|
262
|
+
.map(([label, value]) => `${label}: ${formatTooltipValue(value)}`)
|
|
263
|
+
.join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function formatTooltipValue(value: string | string[] | null | undefined): string {
|
|
267
|
+
if (Array.isArray(value)) {
|
|
268
|
+
return value.length > 0 ? value.join(", ") : "-";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return isNonEmptyString(value) ? value : "-";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isNonEmptyString(value: string | null | undefined): value is string {
|
|
275
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
276
|
+
}
|