Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 51 additions & 24 deletions .github/workflows/publish-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ permissions:

env:
MODULE_PATH: ./artifacts/powershell-package/OwnerLens
KEY_VAULT_URL: ${{ vars.KEY_VAULT_URL }}
SIGNING_CERT_NAME: ${{ vars.SIGNING_CERT_NAME }}

jobs:
publish:
Expand Down Expand Up @@ -56,6 +54,7 @@ jobs:
package-powershell-module:
runs-on: windows-latest
needs: publish
environment: package-signing
steps:
- uses: actions/checkout@v6

Expand All @@ -70,23 +69,21 @@ jobs:
./scripts/package-powershell-module.ps1 -Version $version

- name: Azure login via OIDC
uses: azure/login@v2
uses: azure/login@v3
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Install signing and publishing tools
- name: Install 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
- name: Validate PowerShell files to sign
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Expand All @@ -98,23 +95,53 @@ jobs:
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
$files | ForEach-Object { Write-Host "Will sign $($_.FullName)" }

- name: Validate Artifact Signing configuration
shell: pwsh
env:
ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }}
ARTIFACT_SIGNING_ACCOUNT_NAME: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }}
ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}
run: |
$ErrorActionPreference = "Stop"

$missing = @(
"ARTIFACT_SIGNING_ENDPOINT",
"ARTIFACT_SIGNING_ACCOUNT_NAME",
"ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME"
) | Where-Object { [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($_)) }

if ($missing) {
throw "Missing required package-signing environment secret(s): $($missing -join ', '). Configure them in GitHub before running the publish workflow."
}

- name: Sign PowerShell files with Artifact Signing
uses: azure/artifact-signing-action@v2
with:
endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }}
signing-account-name: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ secrets.ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME }}
files-folder: ${{ github.workspace }}\artifacts\powershell-package\OwnerLens
files-folder-filter: ps1,psm1,psd1
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256

- name: Verify PowerShell signatures
shell: pwsh
run: |
$ErrorActionPreference = "Stop"

$files = Get-ChildItem $env:MODULE_PATH -Recurse -File |
Where-Object { $_.Extension -in ".ps1", ".psm1", ".psd1" }

foreach ($file in $files) {
$sig = Get-AuthenticodeSignature -FilePath $file.FullName

if ($sig.Status -eq "NotSigned") {
throw "File was not signed: $($file.FullName)"
if ($sig.Status -ne "Valid") {
throw "Authenticode signature is not valid for $($file.FullName): $($sig.Status) - $($sig.StatusMessage)"
}

if (-not $sig.TimeStamperCertificate) {
Expand Down
92 changes: 71 additions & 21 deletions .infra/README.md
Original file line number Diff line number Diff line change
@@ -1,45 +1,95 @@
# OwnerLens Signing Infrastructure

This folder contains the one-time Azure Key Vault setup for OwnerLens code-signing assets.
This folder contains one-time Azure signing infrastructure for OwnerLens release assets.

## Deploy Key Vault
The package publishing workflow signs PowerShell release files with Azure
Artifact Signing through the GitHub Artifact Signing action, which uses Windows
SignTool with the Artifact Signing client.

## Deploy Artifact Signing

Register the resource provider once per subscription:

create rg
```bash
az group create -n rg-ownerlens-signing -l westeurope
az provider register --namespace Microsoft.CodeSigning
az provider show --namespace Microsoft.CodeSigning --query registrationState -o tsv
```

Create or reuse a resource group in a supported Artifact Signing region:

```bash
az group create -n rg-ownerlens-signing -l northeurope
```
create deployment

Deploy the Artifact Signing account:

```bash
az deployment group create \
--resource-group rg-ownerlens-signing \
--template-file infra/keyvault.bicep \
--parameters keyVaultName=kv-ownerlens-signing
--template-file .infra/artifact-signing.bicep \
--parameters codeSigningAccountName=<globally-unique-account-name>
```

Assign access to the pipeline identity manually on the Key Vault. Minimum practical RBAC roles:
Complete Public Trust identity validation in the Azure portal:

- Key Vault Crypto User
- Key Vault Certificate User
1. Open the Artifact Signing account.
2. Go to Identity validations.
3. Create an Organization/Public identity validation.
4. Wait until validation is completed.
5. Copy the Identity validation Id.

If using access policies instead of RBAC, the pipeline identity needs approximately:
Create the Public Trust certificate profile and grant GitHub Actions signing access:

- certificates: get, list
- keys: get, sign, verify
```bash
az deployment group create \
--resource-group rg-ownerlens-signing \
--template-file .infra/artifact-signing.bicep \
--parameters \
codeSigningAccountName=<globally-unique-account-name> \
identityValidationId=<completed-identity-validation-id> \
signerPrincipalId=<github-oidc-app-service-principal-object-id>
```

## Create Code-Signing Certificate
The Bicep assigns `Artifact Signing Certificate Profile Signer` on the
certificate profile when `signerPrincipalId` is provided. To let a user or group
complete identity validation, pass `identityVerifierPrincipalId`; the Bicep
assigns `Artifact Signing Identity Verifier` on the account.

Create the certificate once during bootstrap, not in every pipeline run:
Configure the `package-signing` GitHub environment secrets used by
`.github/workflows/publish-package.yml`:

```powershell
./infra/create-code-signing-cert.ps1 `
-VaultName "kv-ownerlens-signing" `
-CertificateName "ownerlens-code-signing"
- `AZURE_CLIENT_ID`
- `AZURE_TENANT_ID`
- `AZURE_SUBSCRIPTION_ID`
- `ARTIFACT_SIGNING_ENDPOINT`, for example `https://neu.codesigning.azure.net/`
- `ARTIFACT_SIGNING_ACCOUNT_NAME`
- `ARTIFACT_SIGNING_CERTIFICATE_PROFILE_NAME`
- `PSGALLERY_API_KEY`

## Deploy Key Vault

Key Vault signing is retained only for local/private signing experiments. The
publishing workflow does not use Key Vault.

Create a resource group:

```bash
az group create -n rg-ownerlens-signing -l westeurope
```

Check certificate creation status:
Deploy the Key Vault:

```bash
az deployment group create \
--resource-group rg-ownerlens-signing \
--template-file .infra/keyvault.bicep \
--parameters keyVaultName=kv-ownerlens-signing
```

Create the certificate once during bootstrap, not in every pipeline run:

```powershell
Get-AzKeyVaultCertificateOperation -VaultName "kv-ownerlens-signing" -Name "ownerlens-code-signing"
./.infra/create-code-signing-cert.ps1 `
-VaultName "kv-ownerlens-signing" `
-CertificateName "ownerlens-code-signing"
```
125 changes: 125 additions & 0 deletions .infra/artifact-signing.bicep
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
@description('Azure region for the Artifact Signing account. Use a region supported by Microsoft.CodeSigning.')
@allowed([
'brazilsouth'
'centralus'
'eastus'
'japaneast'
'koreacentral'
'northcentralus'
'northeurope'
'westus'
'westus2'
])
param location string = 'northeurope'

@description('Globally unique Artifact Signing account name. Must be 3-24 alphanumeric characters, start with a letter, and not start with "one".')
param codeSigningAccountName string = 'olenssign${uniqueString(subscription().id, resourceGroup().id)}'

@description('Artifact Signing pricing tier.')
@allowed([
'Basic'
'Premium'
])
param skuName string = 'Basic'

@description('Optional Public Trust certificate profile name. The profile is created only when identityValidationId is set.')
param certificateProfileName string = 'OwnerLensPublicTrust'

@description('Identity validation ID copied from the Artifact Signing account after the portal-only Public Trust identity validation is completed.')
param identityValidationId string = ''

@description('Include street address in the public trust certificate subject.')
param includeStreetAddress bool = false

@description('Include postal code in the public trust certificate subject.')
param includePostalCode bool = false

@description('Optional Microsoft Entra object ID for the GitHub Actions federated credential service principal. When set, it gets signer access on the certificate profile.')
param signerPrincipalId string = ''

@description('Principal type for signerPrincipalId.')
@allowed([
'ServicePrincipal'
'User'
'Group'
])
param signerPrincipalType string = 'ServicePrincipal'

@description('Optional Microsoft Entra object ID for the human or group that will complete identity validation in the Azure portal.')
param identityVerifierPrincipalId string = ''

@description('Principal type for identityVerifierPrincipalId.')
@allowed([
'ServicePrincipal'
'User'
'Group'
])
param identityVerifierPrincipalType string = 'User'

@description('Resource tags.')
param tags object = {
app: 'OwnerLens'
workload: 'code-signing'
}

var createCertificateProfile = identityValidationId != ''
var assignSignerRole = createCertificateProfile && signerPrincipalId != ''
var assignIdentityVerifierRole = identityVerifierPrincipalId != ''
var certificateProfileSignerRoleDefinitionId = subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'2837e146-70d7-4cfd-ad55-7efa6464f958'
)
var identityVerifierRoleDefinitionId = subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'4339b7cf-9826-4e41-b4ed-c7f4505dac08'
)

resource account 'Microsoft.CodeSigning/codeSigningAccounts@2026-05-15-preview' = {
name: codeSigningAccountName
location: location
tags: tags
properties: {
sku: {
name: skuName
}
}
}

resource profile 'Microsoft.CodeSigning/codeSigningAccounts/certificateProfiles@2026-05-15-preview' = if (createCertificateProfile) {
parent: account
name: certificateProfileName
properties: {
identityValidationId: identityValidationId
includeCity: false
includeCountry: false
includePostalCode: includePostalCode
includeState: false
includeStreetAddress: includeStreetAddress
profileType: 'PublicTrust'
}
}

resource identityVerifierAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (assignIdentityVerifierRole) {
name: guid(account.id, identityVerifierPrincipalId, identityVerifierRoleDefinitionId)
scope: account
properties: {
principalId: identityVerifierPrincipalId
principalType: identityVerifierPrincipalType
roleDefinitionId: identityVerifierRoleDefinitionId
}
}

resource signerAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (assignSignerRole) {
name: guid(profile.id, signerPrincipalId, certificateProfileSignerRoleDefinitionId)
scope: profile
properties: {
principalId: signerPrincipalId
principalType: signerPrincipalType
roleDefinitionId: certificateProfileSignerRoleDefinitionId
}
}

output accountName string = account.name
output accountResourceId string = account.id
output certificateProfileName string = createCertificateProfile ? profile.name : ''
output certificateProfileResourceId string = createCertificateProfile ? profile.id : ''
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ OwnerLens helps split actionable remediations by the
most likely accountable owners and provides traceable evidence for why each
remediation was assigned.

The app runs locally with Vite. Snapshot files exported by OwnerLens collector
commands stay on your machine and are read from the local `data` directory.
```mermaid

flowchart TD
Expand All @@ -44,7 +42,6 @@ flowchart TD

➡️ Export resolved ownership results to CSV and JSON files for resource groups, service principals, and managed identities.

➡️ Switch between snapshot files found in `./data`.

## Requirements

Expand Down Expand Up @@ -106,6 +103,11 @@ directory or port, pass them explicitly:
Start-OwnerLens -DataPath C:\OwnerLensData -Port 4174
```

Open browser - even localhost is secured with token
```powershell
Open-OwnerLens
```

Create the resource snapshot:

```powershell
Expand Down
Loading
Loading