diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 5180a38..a4302ea 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -9,6 +9,11 @@ permissions: contents: write id-token: write +env: + MODULE_PATH: ./artifacts/powershell-package/OwnerLens + KEY_VAULT_URL: ${{ vars.KEY_VAULT_URL }} + SIGNING_CERT_NAME: ${{ vars.SIGNING_CERT_NAME }} + jobs: publish: runs-on: ubuntu-latest @@ -22,6 +27,9 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.org" + - run: npm ci + - run: npm run lint + - name: Set package version from tag id: set_version run: | @@ -34,7 +42,6 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" npm version "$VERSION" --no-git-tag-version --allow-same-version - - run: npm ci - run: npm test --if-present - run: npm run test:components --if-present - run: npm run build --if-present @@ -62,6 +69,76 @@ jobs: $version = "${{ needs.publish.outputs.version }}" ./scripts/package-powershell-module.ps1 -Version $version + - name: Azure login via OIDC + uses: azure/login@v2 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Install signing and publishing tools + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + dotnet tool install --global AzureSignTool + + Install-Module PowerShellGet -Force -Scope CurrentUser + Install-Module PackageManagement -Force -Scope CurrentUser + + - name: Sign PowerShell files + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $files = Get-ChildItem $env:MODULE_PATH -Recurse -File | + Where-Object { $_.Extension -in ".ps1", ".psm1", ".psd1" } + + if (-not $files) { + throw "No PowerShell files found to sign." + } + + foreach ($file in $files) { + Write-Host "Signing $($file.FullName)" + + azuresigntool sign ` + -kvu $env:KEY_VAULT_URL ` + -kvc $env:SIGNING_CERT_NAME ` + -kvm ` + -fd sha256 ` + -tr "http://timestamp.digicert.com" ` + -td sha256 ` + -v ` + $file.FullName + + $sig = Get-AuthenticodeSignature -FilePath $file.FullName + + if ($sig.Status -eq "NotSigned") { + throw "File was not signed: $($file.FullName)" + } + + if (-not $sig.TimeStamperCertificate) { + throw "Missing timestamp: $($file.FullName)" + } + + Write-Host "Signed: $($file.Name) / Status: $($sig.Status)" + } + + - name: Refresh signed PowerShell module release assets + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $version = "${{ needs.publish.outputs.version }}" + $zipPath = "artifacts/release/OwnerLens-$version-win-x64.zip" + $checksumPath = "$zipPath.sha256" + + Remove-Item -LiteralPath $zipPath, $checksumPath -Force -ErrorAction SilentlyContinue + Compress-Archive -Path $env:MODULE_PATH -DestinationPath $zipPath -CompressionLevel Optimal + + $hash = Get-FileHash -Path $zipPath -Algorithm SHA256 + Set-Content -Path $checksumPath -Value "$($hash.Hash.ToLowerInvariant()) $(Split-Path -Leaf $zipPath)" -Encoding ascii + - name: Upload PowerShell module release assets shell: pwsh env: @@ -84,3 +161,15 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Failed to upload PowerShell module release assets for $tag." } + + - name: Publish module to PowerShell Gallery + shell: pwsh + env: + PSGALLERY_API_KEY: ${{ secrets.PSGALLERY_API_KEY }} + run: | + $ErrorActionPreference = "Stop" + + Publish-Module ` + -Path $env:MODULE_PATH ` + -NuGetApiKey $env:PSGALLERY_API_KEY ` + -Verbose diff --git a/.infra/README.md b/.infra/README.md new file mode 100644 index 0000000..0159a61 --- /dev/null +++ b/.infra/README.md @@ -0,0 +1,45 @@ +# OwnerLens Signing Infrastructure + +This folder contains the one-time Azure Key Vault setup for OwnerLens code-signing assets. + +## Deploy Key Vault + +create rg +```bash +az group create -n rg-ownerlens-signing -l westeurope + +``` +create deployment + +```bash +az deployment group create \ + --resource-group rg-ownerlens-signing \ + --template-file infra/keyvault.bicep \ + --parameters keyVaultName=kv-ownerlens-signing +``` + +Assign access to the pipeline identity manually on the Key Vault. Minimum practical RBAC roles: + +- Key Vault Crypto User +- Key Vault Certificate User + +If using access policies instead of RBAC, the pipeline identity needs approximately: + +- certificates: get, list +- keys: get, sign, verify + +## Create Code-Signing Certificate + +Create the certificate once during bootstrap, not in every pipeline run: + +```powershell +./infra/create-code-signing-cert.ps1 ` + -VaultName "kv-ownerlens-signing" ` + -CertificateName "ownerlens-code-signing" +``` + +Check certificate creation status: + +```powershell +Get-AzKeyVaultCertificateOperation -VaultName "kv-ownerlens-signing" -Name "ownerlens-code-signing" +``` diff --git a/.infra/create-code-signing-cert.ps1 b/.infra/create-code-signing-cert.ps1 new file mode 100644 index 0000000..387ef1d --- /dev/null +++ b/.infra/create-code-signing-cert.ps1 @@ -0,0 +1,33 @@ +param( + [Parameter(Mandatory)] + [string]$VaultName, + + [Parameter(Mandatory)] + [string]$CertificateName, + + [string]$Subject = "CN=OwnerLens Code Signing" +) + +$ErrorActionPreference = "Stop" + +$codeSigningEku = "1.3.6.1.5.5.7.3.3" + +$policy = New-AzKeyVaultCertificatePolicy ` + -IssuerName "Self" ` + -SubjectName $Subject ` + -SecretContentType "application/x-pkcs12" ` + -KeyType RSA ` + -KeySize 4096 ` + -KeyUsage DigitalSignature ` + -Ekus $codeSigningEku ` + -ValidityInMonths 12 ` + -KeyNotExportable ` + -EmailAtPercentageLifetime 80 + +Add-AzKeyVaultCertificate ` + -VaultName $VaultName ` + -Name $CertificateName ` + -CertificatePolicy $policy + +Write-Host "Certificate creation started. Check completion:" +Write-Host "Get-AzKeyVaultCertificateOperation -VaultName $VaultName -Name $CertificateName" diff --git a/.infra/keyvault.bicep b/.infra/keyvault.bicep new file mode 100644 index 0000000..f926bd8 --- /dev/null +++ b/.infra/keyvault.bicep @@ -0,0 +1,29 @@ +param location string = resourceGroup().location +param keyVaultName string + +resource kv 'Microsoft.KeyVault/vaults@2024-11-01' = { + name: keyVaultName + location: location + properties: { + tenantId: tenant().tenantId + sku: { + family: 'A' + name: 'standard' + } + + // Access is assigned manually in Azure. + enableRbacAuthorization: true + + enableSoftDelete: true + softDeleteRetentionInDays: 90 + enablePurgeProtection: true + + publicNetworkAccess: 'Enabled' + networkAcls: { + bypass: 'AzureServices' + defaultAction: 'Allow' + } + } +} + +output keyVaultUri string = kv.properties.vaultUri diff --git a/contracts/runtime.openapi.json b/contracts/runtime.openapi.json index fe23282..73e4361 100644 --- a/contracts/runtime.openapi.json +++ b/contracts/runtime.openapi.json @@ -1998,14 +1998,14 @@ "additionalProperties": true, "required": [ "target", - "items" + "evidence" ], "properties": { "target": { "type": "object", "additionalProperties": true }, - "items": { + "evidence": { "type": "array", "items": { "type": "object", diff --git a/src/App.tsx b/src/App.tsx index 11be747..6e0069c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { AzureInventoryStats } from "./components/azure/AzureInventoryStats"; export default function App() { return (
-
+

OwnerLens

@@ -15,7 +15,9 @@ export default function App() {
- +
+ +
); diff --git a/src/components/azure/AzureComponent.test.tsx b/src/components/azure/AzureComponent.test.tsx index 78042eb..03dde73 100644 --- a/src/components/azure/AzureComponent.test.tsx +++ b/src/components/azure/AzureComponent.test.tsx @@ -1246,6 +1246,7 @@ test("sets resource group owner candidate status to inactive from the evidence t await waitForText(container, "rg-app"); await clickButton("Open ownership evidence for alice@example.test"); await waitForText(container, "Activity log"); + expect(queryButton("Toggle ownership evidence option")).toBeNull(); const evidenceRequest = fetchMock.mock.calls .map(([input]) => String(input)) @@ -1377,6 +1378,42 @@ test("opens ownership evidence for application owner evidence", async () => { }); } + if (requestUrl.startsWith("/api/data/azureRbac")) { + return jsonResponse({ + collectionId: "azureRbac", + columns: [], + count: 1, + page: 1, + pageSize: 20, + rows: [ + { + accessDisplayName: "Reader on application subscription", + accessRisk: "low", + accessResourceGroup: null, + accessResourceId: null, + accessScope: "/subscriptions/sub-1", + accessScopeType: "Subscription", + accessSubscriptionId: "sub-1", + canDelegate: false, + condition: null, + conditionVersion: null, + principalDisplayName: "Application owner app", + principalId: "application-object-id", + principalType: "ServicePrincipal", + roleAssignmentId: "assignment-application", + roleDefinitionId: "reader-role-id", + roleDefinitionName: "Reader", + scope: "/subscriptions/sub-1", + scopeSubscriptionId: "sub-1", + servicePrincipalId: "application-object-id", + signInName: null, + subscriptionId: "sub-1", + subscriptionName: "Platform" + } + ] + }); + } + return servicePrincipalOwnerResponse({ displayName: "Application owner app", type: "application" @@ -1389,6 +1426,20 @@ test("opens ownership evidence for application owner evidence", async () => { await waitForText(container, "Service principal app"); await clickButton("Open ownership evidence for Application owner app"); await waitForText(container, "Application owner"); + await clickButton("Open application Azure RBAC assignments for Application owner app"); + await waitForText(container, "Reader on application subscription"); + + const applicationRbacRequest = fetchMock.mock.calls + .map(([input]) => String(input)) + .find((requestUrl) => requestUrl.startsWith("/api/data/azureRbac")); + expect(applicationRbacRequest).toBeDefined(); + + const rbacUrl = new URL(applicationRbacRequest ?? "", window.location.origin); + expect(rbacUrl.searchParams.get("servicePrincipalId")).toBe("application-object-id"); + + await clickButton("Close Application owner app Azure RBAC tab"); + await waitForText(container, "Application owner"); + await clickButton("Open application ownership evidence for Application owner app"); await waitForText(container, "No ownership evidence was found."); diff --git a/src/components/azure/AzureComponent.tsx b/src/components/azure/AzureComponent.tsx index d76264a..b92e396 100644 --- a/src/components/azure/AzureComponent.tsx +++ b/src/components/azure/AzureComponent.tsx @@ -58,7 +58,7 @@ type PersistentTableControls = { type AzureRbacTab = AzureRbacPrincipalSelection & { kind: "servicePrincipal"; - returnView: Extract; + returnView: Extract; } | AzureRbacResourceGroupSelection & { kind: "resourceGroup"; returnView: Extract; @@ -133,7 +133,7 @@ export function AzureComponent() { function openAzureRbac( principal: AzureRbacPrincipalSelection, - returnView: Extract + returnView: Extract ) { setAzureRbacTab({ ...principal, kind: "servicePrincipal", returnView }); activateView("azureRbac"); @@ -201,6 +201,7 @@ export function AzureComponent() { } const ownershipEvidenceDisplayName = ownershipEvidenceTab ? getOwnershipEvidenceTabDisplayName(ownershipEvidenceTab) : null; + const showOwnershipEvidenceToggle = ownershipEvidenceTab ? isPrincipalOwnershipEvidenceTab(ownershipEvidenceTab) : false; return (
@@ -259,7 +260,7 @@ export function AzureComponent() { ) : null} - {activeView === "ownershipEvidence" ? ( + {activeView === "ownershipEvidence" && showOwnershipEvidenceToggle ? ( openAzureRbac(principal, "ownershipEvidence")} onOwnershipEvidenceClick={(selection) => openOwnershipEvidence(selection, ownershipEvidenceTab.returnView)} /> ) : null} @@ -411,6 +413,10 @@ function getOwnershipEvidenceTabDisplayName(tab: OwnershipEvidenceTab): string { return `${prefix}: ${tab.displayName}`; } +function isPrincipalOwnershipEvidenceTab(tab: OwnershipEvidenceTab): boolean { + return tab.target.kind === "servicePrincipal" || tab.target.kind === "managedIdentity"; +} + function getAzureRbacTabKey(tab: AzureRbacTab): string { return tab.kind === "servicePrincipal" ? tab.objectId diff --git a/src/components/azure/RemediationPackageComponent.tsx b/src/components/azure/RemediationPackageComponent.tsx index 7f4803f..e8e7030 100644 --- a/src/components/azure/RemediationPackageComponent.tsx +++ b/src/components/azure/RemediationPackageComponent.tsx @@ -7,7 +7,7 @@ import type { JsonValue, RemediationPackage, RemediationTask } from "../../core/ import type { PermissionRiskLevel } from "../../core/risk/types"; import { formatDate, formatValue } from "../../lib/utils"; import type { ReportColumnRenderers } from "../../report/buildCollectionColumns"; -import { SelectableGenericTable } from "../../report/components/SelectableGenericTable"; +import { SelectableGenericTable } from "../../report/components/table/SelectableGenericTable"; import { Badge } from "../../report/components/ui/badge"; import { Button } from "../../report/components/ui/button"; import { Card } from "../../report/components/ui/card"; @@ -207,6 +207,7 @@ export function RemediationPackageComponent({ {loadState.message} ) : null} `${row.permissionType}:${row.id}`} diff --git a/src/components/azure/identity/ManagedIdentityComponent.tsx b/src/components/azure/identity/ManagedIdentityComponent.tsx index a5ec198..aa9c855 100644 --- a/src/components/azure/identity/ManagedIdentityComponent.tsx +++ b/src/components/azure/identity/ManagedIdentityComponent.tsx @@ -7,7 +7,7 @@ import type { RemediationPackage } from "../../../core/runtime/remediation"; import { getTagNames } from "../../../core/azure/tags"; import { azureManagedIdentityColumnHelp } from "../azureReportConfig"; import { exportManagedIdentitiesCsv, readManagedIdentities, readRemediationPackage } from "../api"; -import { SelectableGenericTable } from "../../../report/components/SelectableGenericTable"; +import { SelectableGenericTable } from "../../../report/components/table/SelectableGenericTable"; import type { ColumnFilters, SortRule } from "../../../core/collectionControls"; import type { ReportFieldDescriptor } from "../../../report/reportTypes"; import { CsvSelectionActionBar } from "../CsvSelectionActionBar"; @@ -187,6 +187,7 @@ export function ManagedIdentityComponent({ ) : null} void; onOwnershipEvidenceClick?: (selection: { displayName: string; target: OwnershipEvidenceTarget }) => void; target: OwnershipEvidenceTarget; }) { @@ -149,12 +152,22 @@ export function OwnershipEvidenceComponent({ target: applicationTarget }) : undefined, + onApplicationRbacClick: onAzureRbacClick + ? (evidence, applicationTarget) => { + if (applicationTarget.kind === "servicePrincipal") { + onAzureRbacClick({ + displayName: evidence.ownerDisplayName, + objectId: applicationTarget.principalId + }); + } + } + : undefined, onUserGroupsClick: handleUserGroupsClick, onStatusChange: handleStatusChange, target, updatingEvidenceKeys }), - [handleStatusChange, handleUserGroupsClick, onOwnershipEvidenceClick, target, updatingEvidenceKeys] + [handleStatusChange, handleUserGroupsClick, onAzureRbacClick, onOwnershipEvidenceClick, target, updatingEvidenceKeys] ); if (loadState.status === "loading") { @@ -172,6 +185,7 @@ export function OwnershipEvidenceComponent({
{formatOwnershipEvidenceTarget(loadState.response)}
void; + onApplicationRbacClick?: (evidence: OwnershipEvidenceItem, target: OwnershipEvidenceTarget) => void; onUserGroupsClick: (evidence: OwnershipEvidenceItem, event: MouseEvent) => void; onStatusChange: (evidence: OwnershipEvidenceItem, status: EvidenceStatus) => void; target: OwnershipEvidenceTarget; @@ -63,6 +65,23 @@ export function buildOwnershipEvidenceFieldRenderers({