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.
Files changed (144) hide show
  1. package/LICENSE +183 -0
  2. package/README.md +209 -0
  3. package/bin/ownerlens.js +92 -0
  4. package/dist/assets/index-B9aAYpVl.css +1 -0
  5. package/dist/assets/index-BcwLk2bx.js +10 -0
  6. package/dist/index.html +13 -0
  7. package/package.json +73 -0
  8. package/src/App.tsx +18 -0
  9. package/src/components/azure/AzureComponent.test.tsx +625 -0
  10. package/src/components/azure/AzureComponent.tsx +189 -0
  11. package/src/components/azure/AzureRbacComponent.tsx +104 -0
  12. package/src/components/azure/ClosableAzureTab.tsx +42 -0
  13. package/src/components/azure/EntraPermissionsComponent.tsx +194 -0
  14. package/src/components/azure/ManagedIdentityComponent.test.tsx +324 -0
  15. package/src/components/azure/ManagedIdentityComponent.tsx +141 -0
  16. package/src/components/azure/ResourceGroupComponent.tsx +157 -0
  17. package/src/components/azure/ServicePrincipalComponent.test.tsx +457 -0
  18. package/src/components/azure/ServicePrincipalComponent.tsx +155 -0
  19. package/src/components/azure/ServicePrincipalFieldRenderers.tsx +140 -0
  20. package/src/components/azure/ZtaComponent.test.tsx +267 -0
  21. package/src/components/azure/ZtaComponent.tsx +276 -0
  22. package/src/components/azure/ZtaRemediationBadge.tsx +70 -0
  23. package/src/components/azure/api.ts +216 -0
  24. package/src/components/azure/azureReportConfig.ts +247 -0
  25. package/src/core/azure/azureRbac.ts +70 -0
  26. package/src/core/azure/entra/index.ts +1 -0
  27. package/src/core/azure/entra/managedIdentity.ts +21 -0
  28. package/src/core/azure/entra/servicePrincipal.ts +34 -0
  29. package/src/core/azure/entra/types.ts +56 -0
  30. package/src/core/azure/identityEnrichment.ts +65 -0
  31. package/src/core/azure/resources.ts +141 -0
  32. package/src/core/azure/ztaReport.ts +58 -0
  33. package/src/core/config.ts +39 -0
  34. package/src/core/ownership/OwnershipTarget.ts +32 -0
  35. package/src/core/ownership/resolveOwner.ts +5 -0
  36. package/src/core/ownership/types.ts +14 -0
  37. package/src/core/risk/types.ts +1 -0
  38. package/src/core/runtime/index.ts +1 -0
  39. package/src/core/runtime/localSnapshotFiles.ts +74 -0
  40. package/src/core/runtime/rest.ts +61 -0
  41. package/src/lib/searchFilterUtils.ts +17 -0
  42. package/src/lib/utils.ts +48 -0
  43. package/src/main.tsx +10 -0
  44. package/src/providers/azure/identities/azureIdentityTypes.ts +1 -0
  45. package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.test.ts +32 -0
  46. package/src/providers/azure/identities/buildAzureManagedIdentityAssignmentIndex.ts +35 -0
  47. package/src/providers/azure/identities/userAssignedIdentityAssignments.ts +52 -0
  48. package/src/providers/azure/inputTransferObject/entra/EntraAppRoleAssignment.ts +10 -0
  49. package/src/providers/azure/inputTransferObject/entra/EntraApplication.ts +27 -0
  50. package/src/providers/azure/inputTransferObject/entra/EntraOAuth2PermissionGrant.ts +8 -0
  51. package/src/providers/azure/inputTransferObject/entra/EntraServicePrincipal.ts +43 -0
  52. package/src/providers/azure/inputTransferObject/entra/EntraSnapshot.ts +13 -0
  53. package/src/providers/azure/inputTransferObject/entra/EntraSnapshotMeta.ts +12 -0
  54. package/src/providers/azure/inputTransferObject/resources/AzureActivityLog.ts +1 -0
  55. package/src/providers/azure/inputTransferObject/resources/AzureResource.ts +1 -0
  56. package/src/providers/azure/inputTransferObject/resources/AzureResourceGroup.ts +1 -0
  57. package/src/providers/azure/inputTransferObject/resources/AzureRoleAssignment.ts +1 -0
  58. package/src/providers/azure/inputTransferObject/resources/AzureSnapshot.ts +1 -0
  59. package/src/providers/azure/inputTransferObject/resources/AzureSnapshotMeta.ts +1 -0
  60. package/src/providers/azure/inputTransferObject/resources/AzureSubscription.ts +1 -0
  61. package/src/providers/azure/inputTransferObject/resources/AzureUserAssignedManagedIdentity.ts +1 -0
  62. package/src/providers/azure/ownership/azureActivityOwnershipEvidence.ts +60 -0
  63. package/src/providers/azure/ownership/azureOwnerReportTypes.ts +13 -0
  64. package/src/providers/azure/ownership/azureOwnershipConfig.ts +21 -0
  65. package/src/providers/azure/ownership/azureOwnershipTypes.ts +46 -0
  66. package/src/providers/azure/ownership/buildAzureOwnershipReport.test.ts +99 -0
  67. package/src/providers/azure/ownership/buildAzureOwnershipReport.ts +90 -0
  68. package/src/providers/azure/ownership/buildAzureOwnershipTargets.test.ts +87 -0
  69. package/src/providers/azure/ownership/buildAzureOwnershipTargets.ts +42 -0
  70. package/src/providers/azure/ownership/resolveAzureOwner.ts +146 -0
  71. package/src/providers/azure/runtime/DisabledEvidenceStore.ts +34 -0
  72. package/src/providers/azure/runtime/EnrichmentService.ts +35 -0
  73. package/src/providers/azure/runtime/LocalReportRuntime.test.ts +2318 -0
  74. package/src/providers/azure/runtime/LocalReportRuntime.ts +302 -0
  75. package/src/providers/azure/runtime/RuntimeHost.ts +60 -0
  76. package/src/providers/azure/runtime/SnapshotImporter.ts +44 -0
  77. package/src/providers/azure/runtime/enrichment/azureIdentityEnrichment.ts +523 -0
  78. package/src/providers/azure/runtime/enrichment/azureScopeClassifier.ts +30 -0
  79. package/src/providers/azure/runtime/enrichment/evaluateAzureRoleAssignmentRisk.ts +88 -0
  80. package/src/providers/azure/runtime/entra/EntraCollectionQueryService.ts +307 -0
  81. package/src/providers/azure/runtime/entra/LocalEntraReportRuntime.ts +227 -0
  82. package/src/providers/azure/runtime/entra/appRoleAssignmentsTable.ts +52 -0
  83. package/src/providers/azure/runtime/entra/applicationsTable.ts +175 -0
  84. package/src/providers/azure/runtime/entra/entraServicePrincipalMapper.ts +63 -0
  85. package/src/providers/azure/runtime/entra/localReportRuntimeRest.ts +41 -0
  86. package/src/providers/azure/runtime/entra/oauth2PermissionGrantsTable.ts +48 -0
  87. package/src/providers/azure/runtime/entra/principalProjection.ts +173 -0
  88. package/src/providers/azure/runtime/entra/servicePrincipalsTable.ts +149 -0
  89. package/src/providers/azure/runtime/entra/snapshotMetadataTable.ts +18 -0
  90. package/src/providers/azure/runtime/entra/snapshotStore.ts +102 -0
  91. package/src/providers/azure/runtime/localReportCollections.ts +101 -0
  92. package/src/providers/azure/runtime/localReportRuntimeRest.ts +71 -0
  93. package/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +145 -0
  94. package/src/providers/azure/runtime/resources/LocalAzureResourcesReportRuntime.ts +114 -0
  95. package/src/providers/azure/runtime/resources/disabledOwnerEvidenceTable.ts +60 -0
  96. package/src/providers/azure/runtime/resources/localReportRuntimeRest.ts +81 -0
  97. package/src/providers/azure/runtime/resources/resourceGroupOwnership.ts +90 -0
  98. package/src/providers/azure/runtime/resources/snapshotMetadataTable.ts +19 -0
  99. package/src/providers/azure/runtime/resources/snapshotStore.ts +128 -0
  100. package/src/providers/azure/runtime/resources/tables.ts +441 -0
  101. package/src/providers/azure/runtime/runtimeRestQuery.ts +46 -0
  102. package/src/providers/azure/runtime/runtimeSqlSchema.ts +357 -0
  103. package/src/providers/azure/runtime/zta/Discovery.ts +141 -0
  104. package/src/providers/azure/runtime/zta/LocalZeroTrustAssessmentReportRuntime.ts +86 -0
  105. package/src/providers/azure/runtime/zta/ZeroTrustAssessmentQueryService.ts +124 -0
  106. package/src/providers/azure/runtime/zta/localReportRuntimeRest.ts +15 -0
  107. package/src/providers/azure/runtime/zta/snapshotMetadataTable.ts +77 -0
  108. package/src/providers/azure/runtime/zta/snapshotStore.ts +112 -0
  109. package/src/providers/azure/runtime/zta/tables.ts +361 -0
  110. package/src/providers/azure/runtime/zta/types.ts +7 -0
  111. package/src/providers/azure/runtime/zta/ztaReportMapper.ts +12 -0
  112. package/src/report/applyCollectionControls.ts +289 -0
  113. package/src/report/buildCollectionColumns.tsx +38 -0
  114. package/src/report/components/ConfidenceBadge.tsx +10 -0
  115. package/src/report/components/EvidenceList.test.ts +25 -0
  116. package/src/report/components/EvidenceList.tsx +52 -0
  117. package/src/report/components/GenericTable.tsx +373 -0
  118. package/src/report/components/PermissionRiskBadge.tsx +19 -0
  119. package/src/report/components/reportTableControls.test.ts +175 -0
  120. package/src/report/components/reportTableControls.tsx +483 -0
  121. package/src/report/components/ui/badge.tsx +35 -0
  122. package/src/report/components/ui/button.tsx +38 -0
  123. package/src/report/components/ui/card.tsx +23 -0
  124. package/src/report/components/ui/input.tsx +15 -0
  125. package/src/report/components/ui/table.tsx +44 -0
  126. package/src/report/components/ui/tabs.tsx +29 -0
  127. package/src/report/export/csv.ts +34 -0
  128. package/src/report/ownerManualPrecheck.test.ts +137 -0
  129. package/src/report/ownerManualPrecheck.ts +132 -0
  130. package/src/report/reportArchitecture.test.ts +125 -0
  131. package/src/report/reportTypes.ts +54 -0
  132. package/src/report/reportValueRenderers.tsx +54 -0
  133. package/src/report/runtimeCollectionQuery.ts +23 -0
  134. package/src/report/types.ts +14 -0
  135. package/src/styles.css +43 -0
  136. package/tools/README.md +108 -0
  137. package/tools/azure-activity-check.ps1 +164 -0
  138. package/tools/collect-azure.ps1 +54 -0
  139. package/tools/collect-entra.ps1 +47 -0
  140. package/tools/collect-scripts.test.ts +22 -0
  141. package/tools/prepare-entra-snapshot.ps1 +403 -0
  142. package/tools/prepare-entra-snapshot.test.ts +14 -0
  143. package/tools/prepare-resource-snapshot.ps1 +345 -0
  144. 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
+ }