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,34 @@
|
|
|
1
|
+
import type { ReportCsvRow } from "../reportTypes";
|
|
2
|
+
|
|
3
|
+
export function serializeCsv(rows: ReportCsvRow[]): string {
|
|
4
|
+
if (rows.length === 0) {
|
|
5
|
+
return "";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const headers = Object.keys(rows[0]);
|
|
9
|
+
const body = rows.map((row) => headers.map((header) => escapeCsvValue(row[header])).join(","));
|
|
10
|
+
|
|
11
|
+
return [headers.join(","), ...body].join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function escapeCsvValue(value: unknown): string {
|
|
15
|
+
const text = formatCsvValue(value);
|
|
16
|
+
|
|
17
|
+
if (!/[",\n\r]/.test(text)) {
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatCsvValue(value: unknown): string {
|
|
25
|
+
if (value === null || value === undefined) {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
30
|
+
return String(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return JSON.stringify(value);
|
|
34
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyOwnerManualPrecheck,
|
|
3
|
+
buildOwnerManualPrecheckExportArtifact,
|
|
4
|
+
buildOwnerManualPrecheckExport,
|
|
5
|
+
disableOwnerCandidate,
|
|
6
|
+
enableOwnerCandidate,
|
|
7
|
+
getOwnerEvidenceKey
|
|
8
|
+
} from "./ownerManualPrecheck";
|
|
9
|
+
import type { OwnerReport, OwnerReportRow } from "./types";
|
|
10
|
+
|
|
11
|
+
test("disabled activity owner candidates are skipped when selecting the row owner", () => {
|
|
12
|
+
const row = ownerRow([
|
|
13
|
+
["alice@example.com", "2026-05-01T10:00:00.000Z"],
|
|
14
|
+
["bob@example.com", "2026-04-30T10:00:00.000Z"]
|
|
15
|
+
]);
|
|
16
|
+
const disabledKeys = disableOwnerCandidate(new Set(), getOwnerEvidenceKey(row, row.evidence[0]));
|
|
17
|
+
|
|
18
|
+
const report = applyOwnerManualPrecheck(ownerReport([row]), disabledKeys);
|
|
19
|
+
|
|
20
|
+
expect(report.owners[0].owner).toBe("bob@example.com");
|
|
21
|
+
expect(report.owners[0].confidence).toBe("low");
|
|
22
|
+
expect(report.owners[0].evidence[0].disabled).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("activity owner candidates without email addresses are disabled by default", () => {
|
|
26
|
+
const row = ownerRow([
|
|
27
|
+
["automation-account", "2026-05-01T10:00:00.000Z"],
|
|
28
|
+
["alice@example.com", "2026-04-30T10:00:00.000Z"]
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const report = applyOwnerManualPrecheck(ownerReport([row]), new Set());
|
|
32
|
+
|
|
33
|
+
expect(report.owners[0].owner).toBe("alice@example.com");
|
|
34
|
+
expect(report.owners[0].confidence).toBe("low");
|
|
35
|
+
expect(report.owners[0].evidence[0].disabled).toBe(true);
|
|
36
|
+
expect(report.owners[0].evidence[1].disabled).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("manual precheck keeps disabled activity evidence visible when no candidate remains active", () => {
|
|
40
|
+
const row = ownerRow([["alice@example.com", "2026-05-01T10:00:00.000Z"]]);
|
|
41
|
+
const disabledKeys = disableOwnerCandidate(new Set(), getOwnerEvidenceKey(row, row.evidence[0]));
|
|
42
|
+
|
|
43
|
+
const report = applyOwnerManualPrecheck(ownerReport([row]), disabledKeys);
|
|
44
|
+
|
|
45
|
+
expect(report.owners[0]).toMatchObject({
|
|
46
|
+
owner: null,
|
|
47
|
+
confidence: "none",
|
|
48
|
+
source: "activity.lastModifier",
|
|
49
|
+
evidence: [
|
|
50
|
+
{
|
|
51
|
+
user: "alice@example.com",
|
|
52
|
+
date: "2026-05-01T10:00:00.000Z",
|
|
53
|
+
disabled: true
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("export keeps disabled activity evidence while omitting it from owner selection", () => {
|
|
60
|
+
const row = ownerRow([["alice@example.com", "2026-05-01T10:00:00.000Z"]]);
|
|
61
|
+
const disabledKeys = disableOwnerCandidate(new Set(), getOwnerEvidenceKey(row, row.evidence[0]));
|
|
62
|
+
const report = applyOwnerManualPrecheck(ownerReport([row]), disabledKeys);
|
|
63
|
+
|
|
64
|
+
const exportableReport = buildOwnerManualPrecheckExport(report);
|
|
65
|
+
|
|
66
|
+
expect(exportableReport.owners[0]).toMatchObject({
|
|
67
|
+
owner: null,
|
|
68
|
+
confidence: "none",
|
|
69
|
+
source: "activity.lastModifier",
|
|
70
|
+
evidence: [
|
|
71
|
+
{
|
|
72
|
+
user: "alice@example.com",
|
|
73
|
+
date: "2026-05-01T10:00:00.000Z",
|
|
74
|
+
disabled: true
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("owner manual precheck export artifact builds csv rows from report layer", () => {
|
|
81
|
+
const row = ownerRow([["alice@example.com", "2026-05-01T10:00:00.000Z"]]);
|
|
82
|
+
const disabledKeys = disableOwnerCandidate(new Set(), getOwnerEvidenceKey(row, row.evidence[0]));
|
|
83
|
+
const report = applyOwnerManualPrecheck(ownerReport([row]), disabledKeys);
|
|
84
|
+
|
|
85
|
+
const artifact = buildOwnerManualPrecheckExportArtifact(report, "csv", "owners");
|
|
86
|
+
|
|
87
|
+
expect(artifact).toEqual({
|
|
88
|
+
kind: "csv",
|
|
89
|
+
fileName: "owners.csv",
|
|
90
|
+
rows: [
|
|
91
|
+
{
|
|
92
|
+
kind: "resourceGroup",
|
|
93
|
+
subscriptionId: "sub-1",
|
|
94
|
+
subscriptionName: "Subscription One",
|
|
95
|
+
resourceGroup: "rg-one",
|
|
96
|
+
owner: null,
|
|
97
|
+
confidence: "none",
|
|
98
|
+
source: "activity.lastModifier",
|
|
99
|
+
evidence: "alice@example.com (2026-05-01T10:00:00.000Z) [disabled]"
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("manual precheck can re-enable disabled owner candidates", () => {
|
|
106
|
+
const row = ownerRow([["alice@example.com", "2026-05-01T10:00:00.000Z"]]);
|
|
107
|
+
const key = getOwnerEvidenceKey(row, row.evidence[0]);
|
|
108
|
+
const disabledKeys = enableOwnerCandidate(disableOwnerCandidate(new Set(), key), key);
|
|
109
|
+
|
|
110
|
+
const report = applyOwnerManualPrecheck(ownerReport([row]), disabledKeys);
|
|
111
|
+
|
|
112
|
+
expect(report.owners[0].owner).toBe("alice@example.com");
|
|
113
|
+
expect(report.owners[0].evidence[0].disabled).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function ownerReport(owners: OwnerReportRow[]): OwnerReport {
|
|
117
|
+
return {
|
|
118
|
+
owners
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ownerRow(evidence: Array<[string, string]>): OwnerReportRow {
|
|
123
|
+
return {
|
|
124
|
+
targetKey: "resourceGroup:sub-1:rg-one",
|
|
125
|
+
kind: "resourceGroup",
|
|
126
|
+
subscriptionId: "sub-1",
|
|
127
|
+
subscriptionName: "Subscription One",
|
|
128
|
+
resourceGroup: "rg-one",
|
|
129
|
+
owner: evidence[0][0],
|
|
130
|
+
confidence: "low",
|
|
131
|
+
source: "activity.lastModifier",
|
|
132
|
+
evidence: evidence.map(([user, date]) => ({
|
|
133
|
+
user,
|
|
134
|
+
date
|
|
135
|
+
}))
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { ReportCsvRow, ReportExportArtifact, ReportExportFormat } from "./reportTypes";
|
|
2
|
+
import type { OwnerEvidence, OwnerReport, OwnerReportRow } from "./types";
|
|
3
|
+
|
|
4
|
+
export type DisabledOwnerKey = string;
|
|
5
|
+
|
|
6
|
+
export function getOwnerRowKey(row: Pick<OwnerReportRow, "targetKey">): string {
|
|
7
|
+
return row.targetKey;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function getOwnerEvidenceKey(
|
|
11
|
+
row: Pick<OwnerReportRow, "targetKey">,
|
|
12
|
+
evidence: Pick<OwnerEvidence, "user" | "date">
|
|
13
|
+
): DisabledOwnerKey {
|
|
14
|
+
return [getOwnerRowKey(row), normalizeEvidencePart(evidence.user), evidence.date ?? ""].join(":");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isActivityOwnerRow(row: Pick<OwnerReportRow, "source">): boolean {
|
|
18
|
+
return row.source.startsWith("activity.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function disableOwnerCandidate(disabledKeys: ReadonlySet<DisabledOwnerKey>, key: DisabledOwnerKey): Set<DisabledOwnerKey> {
|
|
22
|
+
return new Set([...disabledKeys, key]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function enableOwnerCandidate(disabledKeys: ReadonlySet<DisabledOwnerKey>, key: DisabledOwnerKey): Set<DisabledOwnerKey> {
|
|
26
|
+
const next = new Set(disabledKeys);
|
|
27
|
+
next.delete(key);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function applyOwnerManualPrecheck(report: OwnerReport, disabledKeys: ReadonlySet<DisabledOwnerKey>): OwnerReport {
|
|
32
|
+
return {
|
|
33
|
+
...report,
|
|
34
|
+
owners: report.owners.map((row) => applyOwnerManualPrecheckToRow(row, disabledKeys))
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildOwnerManualPrecheckExport(report: OwnerReport): OwnerReport {
|
|
39
|
+
return {
|
|
40
|
+
...report,
|
|
41
|
+
owners: report.owners.map((row) => {
|
|
42
|
+
const activeEvidence = row.evidence.filter((entry) => !entry.disabled);
|
|
43
|
+
|
|
44
|
+
if (isActivityOwnerRow(row) && activeEvidence.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
...row,
|
|
47
|
+
owner: null,
|
|
48
|
+
confidence: "none",
|
|
49
|
+
evidence: row.evidence
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return row;
|
|
54
|
+
})
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildOwnerManualPrecheckExportArtifact(
|
|
59
|
+
report: OwnerReport,
|
|
60
|
+
format: ReportExportFormat,
|
|
61
|
+
fileBaseName: string
|
|
62
|
+
): ReportExportArtifact {
|
|
63
|
+
const exportableReport = buildOwnerManualPrecheckExport(report);
|
|
64
|
+
|
|
65
|
+
if (format === "csv") {
|
|
66
|
+
return {
|
|
67
|
+
kind: "csv",
|
|
68
|
+
fileName: `${fileBaseName}.csv`,
|
|
69
|
+
rows: buildOwnerCsvRows(exportableReport.owners)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
kind: "json",
|
|
75
|
+
fileName: `${fileBaseName}.json`,
|
|
76
|
+
data: exportableReport
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function applyOwnerManualPrecheckToRow(
|
|
81
|
+
row: OwnerReportRow,
|
|
82
|
+
disabledKeys: ReadonlySet<DisabledOwnerKey>
|
|
83
|
+
): OwnerReportRow {
|
|
84
|
+
if (!isActivityOwnerRow(row)) {
|
|
85
|
+
return row;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const evidence = row.evidence.map((entry) => ({
|
|
89
|
+
...entry,
|
|
90
|
+
disabled: isDefaultDisabledOwnerEvidence(entry) || disabledKeys.has(getOwnerEvidenceKey(row, entry)) || undefined
|
|
91
|
+
}));
|
|
92
|
+
const activeEvidence = evidence.filter((entry) => !entry.disabled);
|
|
93
|
+
|
|
94
|
+
if (activeEvidence.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
...row,
|
|
97
|
+
owner: null,
|
|
98
|
+
confidence: "none",
|
|
99
|
+
evidence
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
...row,
|
|
105
|
+
owner: activeEvidence[0].user,
|
|
106
|
+
confidence: "low",
|
|
107
|
+
evidence
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeEvidencePart(value: string): string {
|
|
112
|
+
return value.trim().toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isDefaultDisabledOwnerEvidence(evidence: Pick<OwnerEvidence, "user">): boolean {
|
|
116
|
+
return !evidence.user.includes("@");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildOwnerCsvRows(rows: OwnerReportRow[]): ReportCsvRow[] {
|
|
120
|
+
return rows.map((row) => ({
|
|
121
|
+
kind: row.kind,
|
|
122
|
+
subscriptionId: row.subscriptionId,
|
|
123
|
+
subscriptionName: row.subscriptionName,
|
|
124
|
+
resourceGroup: row.resourceGroup,
|
|
125
|
+
owner: row.owner,
|
|
126
|
+
confidence: row.confidence,
|
|
127
|
+
source: row.source,
|
|
128
|
+
evidence: row.evidence
|
|
129
|
+
.map((entry) => `${entry.user}${entry.date ? ` (${entry.date})` : ""}${entry.disabled ? " [disabled]" : ""}`)
|
|
130
|
+
.join("; ")
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { applyCollectionControls } from "./applyCollectionControls.ts";
|
|
5
|
+
import { buildCollectionColumns } from "./buildCollectionColumns.tsx";
|
|
6
|
+
import type { ReportFieldDescriptor } from "./reportTypes.ts";
|
|
7
|
+
|
|
8
|
+
type Row = {
|
|
9
|
+
id: string;
|
|
10
|
+
owner: string;
|
|
11
|
+
risk: "high" | "low" | "none";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const rows: Row[] = [
|
|
15
|
+
{ id: "platform-high", owner: "platform-team@example.com", risk: "high" },
|
|
16
|
+
{ id: "platform-low", owner: "platform-team@example.com", risk: "low" },
|
|
17
|
+
{ id: "app-high", owner: "app-team@example.com", risk: "high" }
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const fields: ReportFieldDescriptor<Row>[] = [
|
|
21
|
+
{
|
|
22
|
+
id: "owner",
|
|
23
|
+
label: "Owner",
|
|
24
|
+
valueType: "text",
|
|
25
|
+
getValue: (row) => row.owner
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "risk",
|
|
29
|
+
label: "Permission risk",
|
|
30
|
+
valueType: "riskLevel",
|
|
31
|
+
getValue: (row) => row.risk,
|
|
32
|
+
filter: {
|
|
33
|
+
kind: "multiSelect",
|
|
34
|
+
options: ["high", "low", "none"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
test("generic filter engine applies provider-defined field filters", () => {
|
|
40
|
+
const filteredRows = applyCollectionControls(rows, fields, {
|
|
41
|
+
query: "platform",
|
|
42
|
+
filters: {
|
|
43
|
+
risk: { type: "values", values: ["high"] }
|
|
44
|
+
}
|
|
45
|
+
}).controlledRows;
|
|
46
|
+
|
|
47
|
+
expect(filteredRows.map((row) => row.id)).toEqual(["platform-high"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("generic column factory builds columns from field descriptors", () => {
|
|
51
|
+
const columns = buildCollectionColumns(fields);
|
|
52
|
+
|
|
53
|
+
expect(columns.map((column) => [column.id, column.label, column.filter])).toEqual([
|
|
54
|
+
["owner", "Owner", "auto"],
|
|
55
|
+
["risk", "Permission risk", "multiselect"]
|
|
56
|
+
]);
|
|
57
|
+
expect(columns[1]).not.toHaveProperty("getValue");
|
|
58
|
+
expect(columns[1].render).toEqual(expect.any(Function));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("generic column factory attaches help definitions by field id", () => {
|
|
62
|
+
const columns = buildCollectionColumns(fields, {
|
|
63
|
+
columnHelp: {
|
|
64
|
+
owner: {
|
|
65
|
+
source: "Computed from owner evidence.",
|
|
66
|
+
logic: ["Uses the resolved owner value."]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(columns[0].help).toEqual({
|
|
72
|
+
source: "Computed from owner evidence.",
|
|
73
|
+
logic: ["Uses the resolved owner value."]
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("Azure provider source does not contain UI rendering concepts", () => {
|
|
78
|
+
const providerSources = readSourceFiles(join(process.cwd(), "src/providers/azure"));
|
|
79
|
+
|
|
80
|
+
for (const [file, source] of providerSources) {
|
|
81
|
+
expect({ file, source }).toEqual({
|
|
82
|
+
file,
|
|
83
|
+
source: expect.not.stringMatching(/from\s+["'][^"']*\.tsx["']/)
|
|
84
|
+
});
|
|
85
|
+
expect({ file, source }).toEqual({
|
|
86
|
+
file,
|
|
87
|
+
source: expect.not.stringMatching(/from\s+["'][^"']*(components|ui)\/[^"']*["']/)
|
|
88
|
+
});
|
|
89
|
+
expect({ file, source }).toEqual({
|
|
90
|
+
file,
|
|
91
|
+
source: expect.not.stringMatching(/\bcell\s*:/)
|
|
92
|
+
});
|
|
93
|
+
expect({ file, source }).toEqual({
|
|
94
|
+
file,
|
|
95
|
+
source: expect.not.stringMatching(/<\s*(Badge|Table|DetailsCell)\b/)
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("generic report source does not import Azure directly", () => {
|
|
101
|
+
const reportSources = readSourceFiles(join(process.cwd(), "src/report"));
|
|
102
|
+
|
|
103
|
+
for (const [file, source] of reportSources) {
|
|
104
|
+
expect({ file, source }).toEqual({
|
|
105
|
+
file,
|
|
106
|
+
source: expect.not.stringMatching(/providers\/azure/)
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function readSourceFiles(root: string): Array<[string, string]> {
|
|
112
|
+
return walk(root)
|
|
113
|
+
.filter((file) => /\.(ts|tsx)$/.test(file))
|
|
114
|
+
.filter((file) => !/\.(test|spec)\.(ts|tsx)$/.test(file))
|
|
115
|
+
.map((file) => [relative(process.cwd(), file), readFileSync(file, "utf8")]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function walk(path: string): string[] {
|
|
119
|
+
const stat = statSync(path);
|
|
120
|
+
if (stat.isFile()) {
|
|
121
|
+
return [path];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return readdirSync(path).flatMap((entry) => walk(join(path, entry)));
|
|
125
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type ReportColumnHelp = {
|
|
2
|
+
source: string;
|
|
3
|
+
field?: string;
|
|
4
|
+
logic?: string[];
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ReportValueType =
|
|
8
|
+
| "text"
|
|
9
|
+
| "number"
|
|
10
|
+
| "date"
|
|
11
|
+
| "boolean"
|
|
12
|
+
| "list"
|
|
13
|
+
| "riskLevel"
|
|
14
|
+
| "ownerConfidence"
|
|
15
|
+
| "details";
|
|
16
|
+
|
|
17
|
+
export type ReportDetailsValue = {
|
|
18
|
+
title: string;
|
|
19
|
+
details: Array<{ label: string; value: string }>;
|
|
20
|
+
searchText?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ReportFilterDescriptor = {
|
|
24
|
+
kind: "text" | "multiSelect";
|
|
25
|
+
options?: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ReportFieldDescriptor<TRow> = {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
help?: ReportColumnHelp;
|
|
32
|
+
valueType: ReportValueType;
|
|
33
|
+
getValue: (row: TRow) => unknown;
|
|
34
|
+
getFilterValue?: (row: TRow) => unknown;
|
|
35
|
+
filterColumnId?: string;
|
|
36
|
+
searchable?: boolean;
|
|
37
|
+
filter?: ReportFilterDescriptor;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ReportExportFormat = "json" | "csv";
|
|
41
|
+
|
|
42
|
+
export type ReportExportArtifact =
|
|
43
|
+
| {
|
|
44
|
+
kind: "json";
|
|
45
|
+
fileName: string;
|
|
46
|
+
data: unknown;
|
|
47
|
+
}
|
|
48
|
+
| {
|
|
49
|
+
kind: "csv";
|
|
50
|
+
fileName: string;
|
|
51
|
+
rows: ReportCsvRow[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ReportCsvRow = Record<string, unknown>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { formatValue } from "../lib/utils";
|
|
2
|
+
import type { OwnerConfidence } from "./types";
|
|
3
|
+
import type { ReportDetailsValue, ReportFieldDescriptor } from "./reportTypes";
|
|
4
|
+
import { ConfidenceBadge } from "./components/ConfidenceBadge";
|
|
5
|
+
import { PermissionRiskBadge } from "./components/PermissionRiskBadge";
|
|
6
|
+
import type { PermissionRiskLevel } from "../core/risk/types";
|
|
7
|
+
|
|
8
|
+
export function renderReportValue<TRow>(
|
|
9
|
+
field: ReportFieldDescriptor<TRow>,
|
|
10
|
+
row: TRow
|
|
11
|
+
) {
|
|
12
|
+
const value = field.getValue(row);
|
|
13
|
+
|
|
14
|
+
if (field.valueType === "riskLevel") {
|
|
15
|
+
if (value === null || value === undefined || value === "") {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return <PermissionRiskBadge riskLevel={value as PermissionRiskLevel} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (field.valueType === "ownerConfidence") {
|
|
23
|
+
return <ConfidenceBadge confidence={value as OwnerConfidence} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (field.valueType === "details") {
|
|
27
|
+
return renderDetailsValue(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (field.valueType === "boolean") {
|
|
31
|
+
return typeof value === "boolean" ? (value ? "Yes" : "No") : formatValue(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (field.valueType === "list") {
|
|
35
|
+
return Array.isArray(value) ? value.map(formatValue).filter(Boolean).join(", ") : formatValue(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return formatValue(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderDetailsValue(value: unknown) {
|
|
42
|
+
const details = value as ReportDetailsValue;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div>
|
|
46
|
+
<div>{details.title}</div>
|
|
47
|
+
{details.details.map((detail) => (
|
|
48
|
+
<div key={detail.label} className="mt-1 text-xs text-muted-foreground">
|
|
49
|
+
{detail.label}: {detail.value}
|
|
50
|
+
</div>
|
|
51
|
+
))}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ColumnFilter, ColumnFilters } from "./components/reportTableControls";
|
|
2
|
+
|
|
3
|
+
export function appendRuntimeCollectionFilters(url: URL, filters: ColumnFilters): void {
|
|
4
|
+
Object.entries(filters).forEach(([column, filter], filterIndex) => {
|
|
5
|
+
const values = getRuntimeCollectionFilterValues(filter);
|
|
6
|
+
if (values.length === 0) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
url.searchParams.set(`filter[${filterIndex}][column]`, column);
|
|
11
|
+
values.forEach((value, valueIndex) => {
|
|
12
|
+
url.searchParams.append(`filter[${filterIndex}][value][${valueIndex}]`, value);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getRuntimeCollectionFilterValues(filter: ColumnFilter): string[] {
|
|
18
|
+
if (filter.type === "values") {
|
|
19
|
+
return filter.values;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return filter.value.trim() ? [filter.value] : [];
|
|
23
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type { OwnerConfidence, OwnerEvidence, OwnerResolution } from "../core/ownership/types";
|
|
2
|
+
import type { OwnerResolution } from "../core/ownership/types";
|
|
3
|
+
|
|
4
|
+
export type OwnerReportRow = OwnerResolution & {
|
|
5
|
+
kind: "subscription" | "resourceGroup";
|
|
6
|
+
resourceGroup: string | null;
|
|
7
|
+
subscriptionId: string;
|
|
8
|
+
subscriptionName: string;
|
|
9
|
+
targetKey: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type OwnerReport = {
|
|
13
|
+
owners: OwnerReportRow[];
|
|
14
|
+
};
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--color-background: #f4f6f8;
|
|
5
|
+
--color-foreground: #17202a;
|
|
6
|
+
--color-card: #ffffff;
|
|
7
|
+
--color-card-foreground: #17202a;
|
|
8
|
+
--color-primary: #184e77;
|
|
9
|
+
--color-primary-foreground: #ffffff;
|
|
10
|
+
--color-secondary: #eef2f6;
|
|
11
|
+
--color-secondary-foreground: #24313d;
|
|
12
|
+
--color-muted: #f7f9fb;
|
|
13
|
+
--color-muted-foreground: #5d6976;
|
|
14
|
+
--color-destructive: #b42318;
|
|
15
|
+
--color-destructive-foreground: #ffffff;
|
|
16
|
+
--color-border: #dce2e8;
|
|
17
|
+
--color-input: #cfd7df;
|
|
18
|
+
--color-ring: #184e77;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
:root {
|
|
22
|
+
background: #f4f6f8;
|
|
23
|
+
color: #17202a;
|
|
24
|
+
font-family:
|
|
25
|
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
26
|
+
font-synthesis: none;
|
|
27
|
+
text-rendering: optimizeLegibility;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
* {
|
|
31
|
+
box-sizing: border-box;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
body {
|
|
35
|
+
margin: 0;
|
|
36
|
+
min-width: 320px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
button,
|
|
40
|
+
input,
|
|
41
|
+
select {
|
|
42
|
+
font: inherit;
|
|
43
|
+
}
|