From fc37a33718c9f1005a73193fd1e60c6fe491aaec Mon Sep 17 00:00:00 2001
From: Colby Farley
Date: Sat, 25 Apr 2026 02:25:20 -0500
Subject: [PATCH] Add artifact-backed session reuse
---
README.md | 429 ++++++------------
internal/artifacts/session.go | 229 ++++++++++
internal/artifacts/session_test.go | 277 +++++++++++
internal/cli/app.go | 17 +-
internal/cli/app_test.go | 35 ++
internal/commands/api_mgmt.go | 2 +-
internal/commands/app_services.go | 2 +-
internal/commands/appinsights.go | 2 +-
internal/commands/automation.go | 7 +-
internal/commands/chains.go | 88 +++-
internal/commands/chains_compute_control.go | 10 +-
internal/commands/chains_deployment.go | 28 +-
internal/commands/chains_escalation.go | 10 +-
internal/commands/dcr.go | 2 +-
internal/commands/diagnostic_settings.go | 2 +-
internal/commands/evasion_appinsights.go | 22 +-
internal/commands/evasion_dcr.go | 22 +-
.../commands/evasion_diagnostic_settings.go | 22 +-
internal/commands/event_grid.go | 2 +-
.../commands/grouped_command_output_test.go | 172 +++++++
internal/commands/grouped_family.go | 189 +++++++-
internal/commands/identity_control_helpers.go | 71 +++
internal/commands/keyvault.go | 2 +-
internal/commands/logic_apps.go | 2 +-
internal/commands/logic_apps_test.go | 10 +-
internal/commands/managed_identities.go | 46 +-
internal/commands/metadata.go | 123 +++++
internal/commands/monitoring_sinks.go | 73 ++-
internal/commands/partial_consumers_test.go | 200 ++++++++
internal/commands/path_masking_api_mgmt.go | 9 +-
internal/commands/path_masking_logic_apps.go | 22 +-
internal/commands/path_masking_relay.go | 22 +-
internal/commands/permissions.go | 73 ++-
internal/commands/persistence.go | 26 +-
internal/commands/persistence_app_service.go | 26 +-
internal/commands/persistence_azure_ml.go | 21 +-
.../persistence_container_apps_jobs.go | 2 +-
internal/commands/persistence_functions.go | 21 +-
internal/commands/persistence_logic_apps.go | 26 +-
internal/commands/persistence_shared.go | 30 +-
.../commands/persistence_vm_extensions.go | 9 +-
internal/commands/persistence_webjobs.go | 26 +-
internal/commands/principals.go | 107 ++++-
internal/commands/privesc.go | 139 +++++-
internal/commands/rbac.go | 12 +-
internal/commands/relay.go | 2 +-
.../commands/resource_hijacking_api_mgmt.go | 22 +-
.../commands/resource_hijacking_automation.go | 22 +-
.../commands/resource_hijacking_logic_apps.go | 22 +-
internal/commands/resource_trusts.go | 43 +-
internal/commands/storage.go | 2 +-
internal/commands/vm_extensions.go | 2 +-
internal/commands/vms.go | 2 +-
internal/commands/whoami.go | 17 +-
internal/models/automation.go | 15 +-
internal/models/common.go | 70 ++-
internal/models/principals.go | 18 +-
internal/providers/azure.go | 34 +-
internal/providers/azure_api_mgmt.go | 1 +
internal/providers/azure_appinsights.go | 11 +-
internal/providers/azure_automation.go | 18 +-
internal/providers/azure_compute_network.go | 9 +-
internal/providers/azure_dcr.go | 9 +-
internal/providers/azure_devops.go | 14 +-
.../providers/azure_diagnostic_settings.go | 9 +-
internal/providers/azure_event_grid.go | 9 +-
internal/providers/azure_identity.go | 92 +++-
internal/providers/azure_keyvault.go | 18 +-
internal/providers/azure_live_cache.go | 132 +-----
internal/providers/azure_logic_apps.go | 18 +-
internal/providers/azure_monitoring_sinks.go | 39 +-
internal/providers/azure_principals.go | 6 +-
internal/providers/azure_relay.go | 9 +-
internal/providers/azure_storage.go | 18 +-
internal/providers/azure_vm_extensions.go | 9 +-
internal/providers/principals.go | 13 +-
internal/providers/principals_test.go | 2 +-
internal/providers/privesc.go | 8 +-
internal/providers/privesc_test.go | 8 +-
internal/providers/resource_trusts.go | 57 ++-
internal/providers/resource_trusts_test.go | 28 ++
internal/providers/static.go | 104 ++++-
internal/providers/static_appinsights.go | 5 +-
internal/providers/static_automation.go | 5 +-
internal/providers/static_dcr.go | 5 +-
.../providers/static_diagnostic_settings.go | 5 +-
internal/providers/static_event_grid.go | 5 +-
internal/providers/static_logic_apps.go | 9 +-
internal/providers/static_monitoring_sinks.go | 23 +-
internal/providers/static_principals.go | 11 +-
internal/providers/static_relay.go | 5 +-
internal/providers/static_resources.go | 15 +-
internal/providers/static_vm_extensions.go | 5 +-
testdata/api-mgmt.golden.json | 12 +-
testdata/app-services.golden.json | 12 +-
testdata/appinsights.golden.json | 12 +-
testdata/automation.golden.json | 12 +-
testdata/dcr.golden.json | 12 +-
testdata/diagnostic-settings.golden.json | 12 +-
testdata/event-grid.golden.json | 12 +-
testdata/keyvault.golden.json | 12 +-
testdata/logic-apps.golden.json | 12 +-
testdata/managed-identities.golden.json | 13 +-
testdata/monitoring-sinks.golden.json | 12 +-
testdata/permissions.golden.json | 13 +-
testdata/principals.golden.json | 13 +-
testdata/rbac.golden.json | 12 +-
testdata/relay.golden.json | 12 +-
testdata/resource-trusts.golden.json | 12 +-
testdata/storage.golden.json | 12 +-
testdata/vm-extensions.golden.json | 12 +-
testdata/vms.golden.json | 12 +-
testdata/whoami.golden.json | 11 +-
113 files changed, 3000 insertions(+), 914 deletions(-)
create mode 100644 internal/artifacts/session.go
create mode 100644 internal/artifacts/session_test.go
create mode 100644 internal/commands/identity_control_helpers.go
create mode 100644 internal/commands/partial_consumers_test.go
create mode 100644 internal/providers/resource_trusts_test.go
diff --git a/README.md b/README.md
index a43d3a3..bb85817 100644
--- a/README.md
+++ b/README.md
@@ -4,112 +4,132 @@
-Find attack paths, pivot opportunities, and movement across Azure before you drown in inventory.
+HarrierOps Azure is an Azure reconnaissance CLI for offensive security professionals who want to see
+how ordinary Azure control-plane features become persistence, evasion, resource hijacking, path
+masking, and chained movement opportunities.
-Most Azure tools tell you what exists.
-HarrierOps Azure tells you how an identity can move between those resources.
-Most Azure tools dump permissions.
-HarrierOps Azure highlights which relationships, pivots, and escalation paths matter first.
+It helps you move past inventory and expose the uncomfortable part of cloud security: the same
+automation, identity, telemetry, and routing features defenders rely on can also become the paths an
+operator needs to understand first.
-The shipped CLI binary is `ho-azure`.
+Try it in the release container:
-## Why This Matters
-
-You have:
+```bash
+docker run --rm ghcr.io/harriersecurity/ho-azure:v1.2.0 help
+```
-- a compromised user
-- service principal access
-- a managed identity foothold
-- partial subscription visibility
+HarrierOps Azure helps you answer:
-You need to answer quickly:
+- Where could someone leave a durable way back in through Automation, App Services, Logic Apps,
+ WebJobs, or other trusted Azure runtimes?
+- Where could logging be turned down, rerouted, filtered, or quietly made less useful?
+- Which existing Azure resources could be repurposed instead of creating something suspicious and
+ new?
+- Which trusted workflows, gateways, relays, or connectors could make activity look like it came
+ from somewhere else?
+- Which identities, permissions, and resource relationships make those moves possible from the
+ current foothold?
+- Where does the evidence stop because of permissions, Azure visibility, or source boundaries?
-- What identity am I actually holding?
-- What can it control right now?
-- Where can it pivot next?
-- Which path is most likely to become privilege escalation or broader Azure control?
+## Operator Focus
-HarrierOps Azure is built for that workflow.
+HarrierOps Azure is operator-forward rather than inventory-first. It pulls the interesting Azure
+control paths to the top so you are not stuck sorting raw resources before you know what matters.
-## Why This Is Different
+In cloud environments, the useful path is not always a classic persistence trick or a loud new
+resource. It can be a runbook that already looks like maintenance, an app that already has a trusted
+identity, a workflow that already talks to downstream services, a logging route that can be made
+less useful, or a relay/gateway path that makes activity look like normal platform plumbing.
-- Attack-path thinking, not inventory-first reporting
-- Pivot-first workflow, not isolated command output
-- Identity and permission relationships, not just raw role listings
-- Operator guidance that points to the next path worth investigating
-- Broader than a foothold check: useful for movement, consequence, and follow-on access across Azure
+Flat commands collect the evidence. Grouped command families turn that evidence into paths:
+privilege escalation, persistence, evasion, resource hijacking, path masking, and chained Azure
+movement.
-## Core Capabilities
+The goal is not to claim more than Azure proves. Output is shaped around truth boundaries: what the
+current identity can defend, what is merely visible, and where reduced visibility should stop the
+story instead of becoming a misleading empty result.
-- Show the active Azure identity, token context, and scope you are operating from
-- Surface high-impact RBAC and permission relationships that change what the current identity can do
-- Map identity trust, service principal ownership, federated credentials, and cross-tenant edges
-- Highlight pivot paths through workloads, managed identities, deployment systems, and secret-bearing configuration
-- Expose escalation opportunities and likely next steps instead of leaving you to sort raw Azure data
+If you want both sides of the story, run the companion proof lab: use HarrierOps Azure to see the
+operator path, then review the Azure-side logs the lab generates to understand what defenders can
+and cannot see.
## Install
-Build from source:
+Option 1: run the release container:
```bash
-go build -o ho-azure ./cmd/azurefox
+docker run --rm ghcr.io/harriersecurity/ho-azure:v1.2.0 help
+```
+
+Replace `v1.2.0` with the latest release tag when a newer release is available.
+
+Option 2: download the latest binary release for your platform:
+
+[HarrierOps Azure releases](https://github.com/HarrierSecurity/HarrierOps-Azure/releases/latest)
+
+Option 3: install with the HarrierOps Homebrew tap:
+
+```bash
+brew install harriersecurity/ho-azure/ho-azure
```
-If you prefer to run without creating a local binary first:
+Option 4: build from source:
```bash
-go run ./cmd/azurefox help
+git clone https://github.com/HarrierSecurity/HarrierOps-Azure.git
+cd HarrierOps-Azure
+go build -o ho-azure ./cmd/azurefox
+./ho-azure help
```
+See [Getting Started](https://github.com/HarrierSecurity/HarrierOps-Azure/wiki/Getting-Started#1-install)
+for install profile notes and development commands.
+
## Operator Workflow
Start with the identity you have, then work outward toward movement and consequence.
Typical flow:
-- `whoami`: confirm the current foothold, token context, and subscription scope
-- `permissions`: identify where that identity already has meaningful control
-- `privesc`: surface direct abuse or escalation paths rooted in the current access
-- `role-trusts` and `cross-tenant`: find identity-control transforms and tenant boundary pivots
-- `tokens-credentials` and `chains`: follow token, secret, and deployment clues toward the next usable path
+```bash
+ho-azure whoami
+ho-azure permissions
+ho-azure privesc
+ho-azure persistence automation
+ho-azure evasion dcr
+```
+
+- `whoami`: confirm the current foothold, token context, and subscription scope.
+- `permissions`: identify where that identity already has meaningful control.
+- `privesc`: surface direct abuse or escalation paths rooted in the current access.
+- `persistence automation`: check whether trusted runbooks, schedules, webhooks, identities, or
+ worker context can preserve or re-trigger access.
+- `evasion dcr`: check whether Data Collection Rules can reshape collection, routing,
+ destinations, associations, or transformations from the management plane.
## Operator Outcome
After one pass, you should know:
-- which identity matters
-- what access is real versus merely visible
-- where the best pivot opportunities are
-- which attack path deserves follow-up first
+- which identity you are actually holding
+- what that identity can control right now
+- whether privilege escalation is already visible
+- whether durable automation can preserve or re-trigger access
+- whether telemetry collection can be quietly reshaped from visible management-plane control
HarrierOps Azure reduces noise by ranking consequence, not just returning Azure objects.
-## Use Cases
-
-- Triage a compromised user, service principal, or managed identity and determine what Azure control it enables
-- Assess whether a service principal or application relationship creates a pivot or escalation path
-- Work outward from subscription or tenant visibility to identify cross-resource and cross-tenant movement
-
-## Run It
-
-Start with the current Azure identity and the strongest visible control paths:
-
-```bash
-ho-azure whoami
-ho-azure permissions
-```
-
## Currently Supported Azure Commands
### Orchestration
| Grouped Command | Live Families |
| --- | --- |
-| `chains`
Grouped path views that pull the strongest Azure pivot stories to the top. | `credential-path`
Turns exposed secret and token clues into the downstream target most likely to widen access.
`deployment-path`
Surfaces the build, pipeline, and automation paths most likely to let an attacker change Azure next.
`escalation-path`
Highlights the clearest visible route from the current foothold to stronger Azure control.
`compute-control`
Finds workloads that can already mint identity-backed access and pivot into broader control. |
-| `persistence`
Service-specific persistence walkthroughs that stay focused on what the current identity can do end to end. | `app-service`
Walks the current identity through App Service deployment, configuration, code replacement, and reachable reuse posture.
`automation`
Walks the current identity through Azure Automation account control, runbook changes, execution context, triggers, and the current state already in place.
`azure-ml`
Walks the current identity through Azure ML reusable compute, jobs, schedules, endpoints, and identity-backed runtime context.
`container-apps-jobs`
Walks the current identity through Container Apps Jobs stored definitions, trigger mode, image/command clues, execution settings, identity, and rerun posture.
`functions`
Walks the current identity through Function App code, identity, config, and trigger reuse posture.
`logic-apps`
Walks the current identity through Logic Apps workflow control, trigger posture, execution context, and durable workflow reuse paths.
`vm-extensions`
Walks the current identity through Azure-side VM Extension attachment, script or command source, settings posture, VM agent delivery, and rerun paths.
`webjobs`
Walks the current identity through App Service WebJobs background code, mode, inherited app context, and rerun paths. |
-| `evasion`
Service-specific evasion walkthroughs that rank visible posture by quiet defender-truth disruption. | `appinsights`
Walks the current identity through Application Insights instrumentation, sampling, filtering, and logging-level posture clues without claiming runtime telemetry loss from posture alone.
`dcr`
Walks the current identity through Data Collection Rule collection, stream, destination, association, and transformation levers without claiming log-content loss or detector failure from posture alone.
`diagnostic-settings`
Walks the current identity through source resources, exported categories, metrics, and destination sinks without claiming sink contents or detector failure from posture alone. |
-| `resourcehijacking`
Service-specific takeover walkthroughs that rank visible posture by commandeering, redirect, replacement, or repurposing value over existing trusted resources. | `api-mgmt`
Walks the current identity through API Management gateway, backend, subscription, named-value, and routing-control posture without claiming live traffic capture or backend ownership from management-plane posture alone.
`automation`
Walks the current identity through Automation runbook, schedule, webhook, identity, hybrid worker, and secure-asset posture without claiming job execution or script output from management-plane posture alone.
`logic-apps`
Walks the current identity through Logic App workflow, trigger, downstream action, connector, and identity posture without claiming run execution or connector data access from management-plane posture alone. |
-| `pathmasking`
Service-specific relay/proxy walkthroughs that rank visible posture by path ambiguity and attribution-blur value. | `api-mgmt`
Walks the current identity through API Management gateway, backend, hostname, subscription, and route-control posture without claiming live traffic flow or backend ownership from management-plane posture alone.
`logic-apps`
Walks the current identity through Logic App trigger, downstream action, connector, and identity posture that can relay activity through a trusted workflow without claiming run execution or payload access by default.
`relay`
Walks the current identity through Azure Relay namespaces, Hybrid Connections, authorization-rule posture, and listener-count clues without claiming backend process identity or traffic contents from management-plane posture alone. |
+| `chains`
Grouped path views that pull the strongest Azure pivot stories to the top. | `credential-path`, `deployment-path`, `escalation-path`, `compute-control` |
+| `persistence`
Service-specific persistence walkthroughs focused on what the current identity can preserve, trigger, or reuse. | `app-service`, `automation`, `azure-ml`, `container-apps-jobs`, `functions`, `logic-apps`, `vm-extensions`, `webjobs` |
+| `evasion`
Service-specific views of quiet Azure-native defender-truth disruption. | `appinsights`, `dcr`, `diagnostic-settings` |
+| `resourcehijacking`
Service-specific takeover views for commandeering, redirecting, replacing, or repurposing trusted resources. | `api-mgmt`, `automation`, `logic-apps` |
+| `pathmasking`
Service-specific relay, proxy, and workflow views for path ambiguity and attribution blur. | `api-mgmt`, `logic-apps`, `relay` |
### Flat Commands
@@ -144,42 +164,6 @@ ho-azure --output json --outdir ./ho-azure-demo dns
Use `ho-azure --help` or `ho-azure help ` for command-specific help.
-## Install Profiles
-
-HarrierOps Azure builds the live Azure runtime path by default, so a normal source build is ready
-for real Azure command execution.
-
-For a local binary:
-
-```bash
-go build -o ho-azure ./cmd/azurefox
-```
-
-For direct execution from a checkout:
-
-```bash
-go run ./cmd/azurefox whoami
-```
-
-For local development:
-
-```bash
-go test ./...
-```
-
-HarrierOps Azure is intended to work on macOS, Linux, and Windows. The command examples below use
-portable relative paths like `./ho-azure-demo`; shell syntax mainly differs for environment-variable
-export and binary invocation.
-
-Live operator guidance is built into `ho-azure help` and `ho-azure help `.
-
-- `go build -o ho-azure ./cmd/azurefox`
- builds the normal operator binary from a local checkout
-- `go run ./cmd/azurefox ...`
- runs the same live Azure command profile directly from source
-- `go test ./...`
- runs the contributor validation baseline for the Go repo
-
## Auth Precedence
1. Azure CLI credential
@@ -201,197 +185,9 @@ HarrierOps Azure does not launch its own browser or managed-identity login flow.
- `AzureCliCredential` for the active Azure CLI sign-in state
- `EnvironmentCredential` for supported service principal environment variables
-### Interactive user via Azure CLI
-
-If you want web-based authentication, run `az login` first outside HarrierOps Azure, then run
-`ho-azure`.
-
-Azure CLI example:
-
-```bash
-az login
-az account set --subscription
-ho-azure inventory --subscription
-```
-
-### Service principal via Azure CLI
-
-This is useful for headless automation that still wants Azure CLI to hold the active login state.
-
-With a client secret:
-
-```bash
-az login --service-principal \
- --username \
- --password \
- --tenant
-az account set --subscription
-ho-azure whoami --subscription
-```
-
-With a certificate:
-
-```bash
-az login --service-principal \
- --username \
- --certificate /path/to/certificate.pem \
- --tenant
-az account set --subscription
-ho-azure whoami --subscription
-```
-
-### Service principal via environment client secret
-
-If you do not want to use Azure CLI login state, set service principal environment variables and
-pass CLI flags for tenant or subscription targeting.
-
-Environment client-secret example:
-
-```bash
-# macOS/Linux
-export AZURE_TENANT_ID=
-export AZURE_CLIENT_ID=
-export AZURE_CLIENT_SECRET=
-export AZUREFOX_DEVOPS_ORG= # only needed for the devops command
-ho-azure whoami --tenant --subscription
-```
-
-```powershell
-# Windows PowerShell
-$env:AZURE_TENANT_ID=""
-$env:AZURE_CLIENT_ID=""
-$env:AZURE_CLIENT_SECRET=""
-$env:AZUREFOX_DEVOPS_ORG="" # only needed for the devops command
-ho-azure whoami --tenant --subscription
-```
-
-### Service principal via environment certificate
-
-```bash
-# macOS/Linux
-export AZURE_TENANT_ID=
-export AZURE_CLIENT_ID=
-export AZURE_CLIENT_CERTIFICATE_PATH=/path/to/certificate.pem
-export AZURE_CLIENT_CERTIFICATE_PASSWORD=
-ho-azure whoami --tenant --subscription
-```
-
-```powershell
-# Windows PowerShell
-$env:AZURE_TENANT_ID=""
-$env:AZURE_CLIENT_ID=""
-$env:AZURE_CLIENT_CERTIFICATE_PATH="C:\\path\\to\\certificate.pem"
-$env:AZURE_CLIENT_CERTIFICATE_PASSWORD=""
-ho-azure whoami --tenant --subscription
-```
-
-### Azure-hosted managed identity via Azure CLI
-
-This works when you are running on an Azure resource that already has a managed identity attached.
-
-```bash
-az login --identity
-az account set --subscription
-ho-azure whoami --subscription
-```
-
-For a user-assigned managed identity:
-
-```bash
-az login --identity --client-id
-az account set --subscription
-ho-azure whoami --subscription
-```
-
-`AZUREFOX_DEVOPS_ORG` is only needed when running the `devops` command. The identity used for
-`devops` still needs access to the Azure DevOps organization, not just ARM access to the tenant or
-subscription.
-
-## Output Modes
-
-- `--output table` (default)
-- `--output json`
-- `--output csv`
-
-All commands write artifacts under `/`:
-
-- `loot/.json`
-- `json/.json`
-- `table/.txt`
-- `csv/.csv`
-
-Artifact intent:
-
-- `json/` is the full structured command record
-- `loot/` is the smaller high-value handoff, focused on the top-ranked targets for quick operator
- follow-up and later chain-oriented workflows
-- `table/` and `csv/` are convenience views rendered from the same underlying command result
-
-## Sections And Grouped Commands
-
-HarrierOps Azure keeps flat standalone commands and also supports grouped execution through `chains`,
-`persistence`, `evasion`, `resourcehijacking`, and `pathmasking`.
-
-For narrower current work:
-
-- run the flat commands directly when you already know the lane you want
-- use `chains` when you want a higher-value grouped answer instead of every source command on its own
-- use `persistence` when you want a service-specific end-to-end persistence walkthrough from the
- current identity
-- use `evasion` when you want a service-specific view of quiet Azure-native truth degradation from
- the current identity
-- use `resourcehijacking` when you want to know which existing Azure resource can most directly be
- commandeered, redirected, replaced, or repurposed from the current identity
-- use `pathmasking` when you want to know which Azure-native proxy, relay, or workflow layer most
- blurs the path between caller, cloud surface, and backend from the current identity
-
-Current section mappings:
-
-- `identity`: `whoami`, `rbac`, `principals`, `permissions`, `privesc`, `role-trusts`, `lighthouse`, `cross-tenant`, `auth-policies`, `managed-identities`
-- `config`: `arm-deployments`, `env-vars`
-- `secrets`: `keyvault`, `tokens-credentials`
-- `resource`: `automation`, `devops`, `acr`, `api-mgmt`, `appinsights`, `databases`, `dcr`, `diagnostic-settings`, `monitoring-sinks`, `resource-trusts`
-- `storage`: `storage`
-- `network`: `application-gateway`, `nics`, `dns`, `endpoints`, `network-effective`, `network-ports`, `relay`
-- `compute`: `workloads`, `app-services`, `functions`, `container-apps`, `container-apps-jobs`, `container-instances`, `aks`, `vms`, `vm-extensions`, `vmss`, `snapshots-disks`
-- `core`: `inventory`
-- `orchestration`: `chains`, `persistence`, `evasion`, `resourcehijacking`, `pathmasking`
-
-Current `chains` families:
-
-- `credential-path`
-- `deployment-path`
-- `escalation-path`
-- `compute-control`
-
-Current `persistence` surfaces:
-
-- `app-service`
-- `automation`
-- `azure-ml`
-- `container-apps-jobs`
-- `functions`
-- `logic-apps`
-- `vm-extensions`
-- `webjobs`
-
-Current `evasion` surfaces:
-
-- `appinsights`
-- `dcr`
-- `diagnostic-settings`
-
-Current `resourcehijacking` surfaces:
-
-- `api-mgmt`
-- `automation`
-- `logic-apps`
-
-Current `pathmasking` surfaces:
-
-- `api-mgmt`
-- `logic-apps`
-- `relay`
+See the [Getting Started auth section](https://github.com/HarrierSecurity/HarrierOps-Azure/wiki/Getting-Started#2-authenticate)
+for setup examples for Azure CLI users, service principals, environment credentials, managed
+identities, and Azure DevOps organization targeting.
## Help
@@ -443,9 +239,56 @@ bash scripts/setup_local_guardrails.sh
CI should cover the deterministic command surfaces before release-gated changes move forward.
-## Attribution
+## FAQ
+
+### Is HarrierOps Azure read-only?
+
+Yes. HarrierOps Azure is built as a read-only reconnaissance tool. It queries Azure and related
+control-plane surfaces to show identity, permission, resource, and path relationships. It does not
+create, update, delete, or execute Azure resources.
+
+### What makes HarrierOps Azure different from normal inventory tools?
+
+The focus is operator movement, not object counting. Flat commands expose useful Azure evidence,
+while command families such as `persistence`, `evasion`, `resourcehijacking`, `pathmasking`, and
+`chains` turn related helper output into higher-level views of what the current identity can
+control, preserve, redirect, blur, or follow next.
+
+### What happens when my identity has limited permissions?
+
+Output should stay truth-boundary aware. A clean empty result should not mean the same thing as
+reduced visibility. When Azure, Azure DevOps, Graph, or the current permission set prevents a
+stronger answer, HarrierOps Azure should stop at the evidence it can defend and avoid implying that
+an unseen path does not exist.
+
+### Where do output artifacts go?
+
+Use `--outdir` to choose a run directory. If `--outdir` is not provided, artifacts are written in
+the current directory. For ad hoc work, a dedicated path such as `--outdir ./ho-azure-demo` keeps
+JSON, table, CSV, and future output changes out of the repo root.
+
+### What is artifact-backed session reuse?
+
+When helper artifacts from the same active workspace match the current tenant, subscription,
+principal, auth context, tool/schema version, command options, and freshness window, grouped
+commands can reuse that data instead of asking Azure the same question again. Reused artifacts are
+session reuse with provenance, not fresh Azure truth.
+
+### How was AI used to create HarrierOps Azure?
+
+AI assisted with rapid prototyping, code generation, documentation drafts, and review passes during
+development. HarrierOps Azure is not a one-shot vibe-coded project. The shipped tool is shaped by
+planning notes, command contracts, system design decisions, deterministic fixtures, unit tests,
+golden output checks, live-lab follow-up, release smoke tests, and repeated human review.
+
+Development also included sustained review of what makes a recon tool useful in practice:
+operator workflow, OPSEC, performance, output truth boundaries, reduced-visibility handling,
+artifact provenance, packaging, and whether a command is actually worth shipping as a first-class
+surface instead of just being an interesting idea.
-HarrierOps Azure builds on the AzureFox porting work and is inspired by [CloudFox](https://github.com/BishopFox/cloudfox), created by Bishop Fox.
+The goal is for HarrierOps Azure to stand on its own as a serious operator tool: useful output,
+clear truth boundaries, reproducible tests, and command behavior that can survive review instead of
+just looking impressive in a demo.
## License
diff --git a/internal/artifacts/session.go b/internal/artifacts/session.go
new file mode 100644
index 0000000..e9abce6
--- /dev/null
+++ b/internal/artifacts/session.go
@@ -0,0 +1,229 @@
+package artifacts
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "time"
+
+ "harrierops-azure/internal/models"
+)
+
+var errInvalidSessionArtifact = errors.New("invalid session artifact")
+
+type ExpectedSession struct {
+ Command string
+ SchemaVersion string
+ ToolVersion string
+ TenantID string
+ SubscriptionID string
+ CurrentPrincipal models.ArtifactPrincipal
+ AuthMode string
+ TokenSource string
+ CommandOptions map[string]string
+ MaxAge time.Duration
+ Now time.Time
+}
+
+type SessionLoadResult[T any] struct {
+ Payload T
+ Source models.SessionArtifact
+}
+
+type SessionAnchor struct {
+ TenantID string
+ SubscriptionID string
+ CurrentPrincipal models.ArtifactPrincipal
+ AuthMode string
+ TokenSource string
+}
+
+type sessionMetadata struct {
+ AuthMode *string `json:"auth_mode"`
+ Command string `json:"command"`
+ GeneratedAt string `json:"generated_at"`
+ SchemaVersion string `json:"schema_version"`
+ SubscriptionID *string `json:"subscription_id"`
+ TenantID *string `json:"tenant_id"`
+ TokenSource *string `json:"token_source"`
+ ArtifactContext *models.ArtifactContext `json:"artifact_context"`
+}
+
+func LoadSessionArtifact[T any](workspace string, expected ExpectedSession) (SessionLoadResult[T], bool, error) {
+ var zero SessionLoadResult[T]
+ if workspace == "" {
+ workspace = "."
+ }
+ for _, path := range candidatePaths(workspace, expected.Command) {
+ result, ok, err := loadCandidate[T](path, expected)
+ if err != nil {
+ if errors.Is(err, errInvalidSessionArtifact) {
+ continue
+ }
+ return zero, false, err
+ }
+ if ok {
+ return result, true, nil
+ }
+ }
+ return zero, false, nil
+}
+
+func HasSessionArtifact(workspace string, command string) bool {
+ if workspace == "" {
+ workspace = "."
+ }
+ for _, path := range candidatePaths(workspace, command) {
+ if _, err := os.Stat(path); err == nil {
+ return true
+ }
+ }
+ return false
+}
+
+func LoadSessionAnchor(workspace string, schemaVersion string, toolVersion string, maxAge time.Duration, now time.Time) (SessionAnchor, bool, error) {
+ return LoadSessionAnchorFromCommands(workspace, []string{"whoami"}, schemaVersion, toolVersion, maxAge, now)
+}
+
+func LoadSessionAnchorFromCommands(workspace string, commands []string, schemaVersion string, toolVersion string, maxAge time.Duration, now time.Time) (SessionAnchor, bool, error) {
+ var zero SessionAnchor
+ if workspace == "" {
+ workspace = "."
+ }
+ for _, command := range commands {
+ for _, path := range candidatePaths(workspace, command) {
+ _, metadata, ok, err := loadCandidateDataAndMetadata(path)
+ if err != nil {
+ if errors.Is(err, errInvalidSessionArtifact) {
+ continue
+ }
+ return zero, false, err
+ }
+ if !ok {
+ continue
+ }
+ if _, ok := validSessionAnchorMetadata(metadata, command, schemaVersion, toolVersion, maxAge, now); !ok {
+ continue
+ }
+ return SessionAnchor{
+ TenantID: stringPtrValue(metadata.TenantID),
+ SubscriptionID: stringPtrValue(metadata.SubscriptionID),
+ CurrentPrincipal: metadata.ArtifactContext.CurrentPrincipal,
+ AuthMode: stringPtrValue(metadata.AuthMode),
+ TokenSource: stringPtrValue(metadata.TokenSource),
+ }, true, nil
+ }
+ }
+ return zero, false, nil
+}
+
+func candidatePaths(workspace string, command string) []string {
+ return []string{
+ filepath.Join(workspace, "json", command+".json"),
+ filepath.Join(workspace, "loot", command+".json"),
+ }
+}
+
+func loadCandidate[T any](path string, expected ExpectedSession) (SessionLoadResult[T], bool, error) {
+ var zero SessionLoadResult[T]
+ data, metadata, ok, err := loadCandidateDataAndMetadata(path)
+ if err != nil || !ok {
+ return zero, false, err
+ }
+ generatedAt, ok := validSessionMetadata(metadata, expected)
+ if !ok {
+ return zero, false, nil
+ }
+
+ var payload T
+ if err := json.Unmarshal(data, &payload); err != nil {
+ return zero, false, fmt.Errorf("%w: read session artifact payload %s: %v", errInvalidSessionArtifact, path, err)
+ }
+ return SessionLoadResult[T]{
+ Payload: payload,
+ Source: models.SessionArtifact{
+ Command: expected.Command,
+ Path: path,
+ GeneratedAt: generatedAt.Format(time.RFC3339),
+ AgeSeconds: int(expected.Now.Sub(generatedAt).Seconds()),
+ Context: "same tenant, subscription, principal, command options",
+ },
+ }, true, nil
+}
+
+func loadCandidateDataAndMetadata(path string) ([]byte, sessionMetadata, bool, error) {
+ var zero sessionMetadata
+ data, err := os.ReadFile(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, zero, false, nil
+ }
+ if err != nil {
+ return nil, zero, false, err
+ }
+
+ var header struct {
+ Metadata sessionMetadata `json:"metadata"`
+ }
+ if err := json.Unmarshal(data, &header); err != nil {
+ return nil, zero, false, fmt.Errorf("%w: read session artifact metadata %s: %v", errInvalidSessionArtifact, path, err)
+ }
+ return data, header.Metadata, true, nil
+}
+
+func validSessionAnchorMetadata(metadata sessionMetadata, command string, schemaVersion string, toolVersion string, maxAge time.Duration, now time.Time) (time.Time, bool) {
+ generatedAt, err := time.Parse(time.RFC3339, metadata.GeneratedAt)
+ if err != nil {
+ return time.Time{}, false
+ }
+ if now.IsZero() || now.Sub(generatedAt) < 0 || now.Sub(generatedAt) > maxAge {
+ return time.Time{}, false
+ }
+ if metadata.Command != command ||
+ metadata.SchemaVersion != schemaVersion ||
+ stringPtrValue(metadata.TenantID) == "" ||
+ stringPtrValue(metadata.SubscriptionID) == "" ||
+ stringPtrValue(metadata.AuthMode) == "" ||
+ stringPtrValue(metadata.TokenSource) == "" ||
+ metadata.ArtifactContext == nil ||
+ metadata.ArtifactContext.ToolVersion != toolVersion ||
+ metadata.ArtifactContext.CurrentPrincipal.ID == "" ||
+ metadata.ArtifactContext.CurrentPrincipal.TenantID == "" ||
+ !reflect.DeepEqual(metadata.ArtifactContext.CommandOptions, map[string]string{}) {
+ return time.Time{}, false
+ }
+ return generatedAt, true
+}
+
+func validSessionMetadata(metadata sessionMetadata, expected ExpectedSession) (time.Time, bool) {
+ generatedAt, err := time.Parse(time.RFC3339, metadata.GeneratedAt)
+ if err != nil {
+ return time.Time{}, false
+ }
+ if expected.Now.IsZero() || expected.Now.Sub(generatedAt) < 0 || expected.Now.Sub(generatedAt) > expected.MaxAge {
+ return time.Time{}, false
+ }
+ if metadata.Command != expected.Command ||
+ metadata.SchemaVersion != expected.SchemaVersion ||
+ stringPtrValue(metadata.TenantID) != expected.TenantID ||
+ stringPtrValue(metadata.SubscriptionID) != expected.SubscriptionID ||
+ stringPtrValue(metadata.AuthMode) != expected.AuthMode ||
+ stringPtrValue(metadata.TokenSource) != expected.TokenSource ||
+ metadata.ArtifactContext == nil ||
+ metadata.ArtifactContext.ToolVersion != expected.ToolVersion ||
+ metadata.ArtifactContext.CurrentPrincipal.ID != expected.CurrentPrincipal.ID ||
+ metadata.ArtifactContext.CurrentPrincipal.TenantID != expected.CurrentPrincipal.TenantID ||
+ !reflect.DeepEqual(metadata.ArtifactContext.CommandOptions, expected.CommandOptions) {
+ return time.Time{}, false
+ }
+ return generatedAt, true
+}
+
+func stringPtrValue(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
diff --git a/internal/artifacts/session_test.go b/internal/artifacts/session_test.go
new file mode 100644
index 0000000..535825b
--- /dev/null
+++ b/internal/artifacts/session_test.go
@@ -0,0 +1,277 @@
+package artifacts
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "harrierops-azure/internal/contracts"
+ "harrierops-azure/internal/models"
+)
+
+type testArtifactPayload struct {
+ Metadata models.Metadata `json:"metadata"`
+ Value string `json:"value"`
+}
+
+func TestLoadSessionArtifactMatchesStrictContext(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeTestArtifact(t, workspace, "rbac", now.Add(-38*time.Minute), "1111", "2222", "3333")
+
+ result, ok, err := LoadSessionArtifact[testArtifactPayload](workspace, ExpectedSession{
+ Command: "rbac",
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ ToolVersion: "dev",
+ TenantID: "1111",
+ SubscriptionID: "2222",
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: "3333",
+ TenantID: "1111",
+ },
+ AuthMode: "fixture",
+ TokenSource: "fixture",
+ CommandOptions: map[string]string{},
+ MaxAge: time.Hour,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("load session artifact: %v", err)
+ }
+ if !ok {
+ t.Fatalf("expected artifact reuse")
+ }
+ if result.Payload.Value != "from-artifact" {
+ t.Fatalf("payload value = %q", result.Payload.Value)
+ }
+ if result.Source.Command != "rbac" || result.Source.AgeSeconds != int((38*time.Minute).Seconds()) {
+ t.Fatalf("unexpected source: %#v", result.Source)
+ }
+}
+
+func TestLoadSessionArtifactRejectsMismatchedContext(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeTestArtifact(t, workspace, "rbac", now.Add(-10*time.Minute), "1111", "2222", "3333")
+
+ cases := []struct {
+ name string
+ tenant string
+ sub string
+ principal string
+ maxAge time.Duration
+ options map[string]string
+ }{
+ {name: "tenant", tenant: "other", sub: "2222", principal: "3333", maxAge: time.Hour, options: map[string]string{}},
+ {name: "subscription", tenant: "1111", sub: "other", principal: "3333", maxAge: time.Hour, options: map[string]string{}},
+ {name: "principal", tenant: "1111", sub: "2222", principal: "other", maxAge: time.Hour, options: map[string]string{}},
+ {name: "options", tenant: "1111", sub: "2222", principal: "3333", maxAge: time.Hour, options: map[string]string{"mode": "full"}},
+ {name: "freshness", tenant: "1111", sub: "2222", principal: "3333", maxAge: time.Minute, options: map[string]string{}},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, ok, err := LoadSessionArtifact[testArtifactPayload](workspace, ExpectedSession{
+ Command: "rbac",
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ ToolVersion: "dev",
+ TenantID: tc.tenant,
+ SubscriptionID: tc.sub,
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: tc.principal,
+ TenantID: tc.tenant,
+ },
+ AuthMode: "fixture",
+ TokenSource: "fixture",
+ CommandOptions: tc.options,
+ MaxAge: tc.maxAge,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("load session artifact: %v", err)
+ }
+ if ok {
+ t.Fatalf("expected mismatched artifact to be rejected")
+ }
+ })
+ }
+}
+
+func TestLoadSessionArtifactFallsBackFromMalformedJSONToValidLoot(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeRawTestArtifact(t, workspace, "json", "rbac", []byte(`{`))
+ writeTestArtifactLane(t, workspace, "loot", "rbac", now.Add(-10*time.Minute), "1111", "2222", "3333")
+
+ result, ok, err := LoadSessionArtifact[testArtifactPayload](workspace, ExpectedSession{
+ Command: "rbac",
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ ToolVersion: "dev",
+ TenantID: "1111",
+ SubscriptionID: "2222",
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: "3333",
+ TenantID: "1111",
+ },
+ AuthMode: "fixture",
+ TokenSource: "fixture",
+ CommandOptions: map[string]string{},
+ MaxAge: time.Hour,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("load session artifact: %v", err)
+ }
+ if !ok {
+ t.Fatalf("expected valid loot artifact to be reused")
+ }
+ if result.Source.Path != filepath.Join(workspace, "loot", "rbac.json") {
+ t.Fatalf("expected loot source path, got %q", result.Source.Path)
+ }
+}
+
+func TestLoadSessionArtifactIgnoresMalformedArtifactWhenNoValidCandidateExists(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeRawTestArtifact(t, workspace, "json", "rbac", []byte(`{`))
+
+ _, ok, err := LoadSessionArtifact[testArtifactPayload](workspace, ExpectedSession{
+ Command: "rbac",
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ ToolVersion: "dev",
+ TenantID: "1111",
+ SubscriptionID: "2222",
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: "3333",
+ TenantID: "1111",
+ },
+ AuthMode: "fixture",
+ TokenSource: "fixture",
+ CommandOptions: map[string]string{},
+ MaxAge: time.Hour,
+ Now: now,
+ })
+ if err != nil {
+ t.Fatalf("malformed candidate should fall through to live refresh, got error: %v", err)
+ }
+ if ok {
+ t.Fatalf("expected malformed artifact not to be reused")
+ }
+}
+
+func TestLoadSessionAnchorUsesFreshWhoamiArtifact(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeTestArtifact(t, workspace, "whoami", now.Add(-29*time.Minute), "1111", "2222", "3333")
+
+ anchor, ok, err := LoadSessionAnchor(workspace, contracts.AzureFoxSchemaVersion, "dev", 30*time.Minute, now)
+ if err != nil {
+ t.Fatalf("load session anchor: %v", err)
+ }
+ if !ok {
+ t.Fatalf("expected fresh whoami anchor")
+ }
+ if anchor.TenantID != "1111" || anchor.SubscriptionID != "2222" || anchor.CurrentPrincipal.ID != "3333" {
+ t.Fatalf("unexpected anchor: %#v", anchor)
+ }
+}
+
+func TestLoadSessionAnchorRejectsStaleWhoamiArtifact(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeTestArtifact(t, workspace, "whoami", now.Add(-31*time.Minute), "1111", "2222", "3333")
+
+ _, ok, err := LoadSessionAnchor(workspace, contracts.AzureFoxSchemaVersion, "dev", 30*time.Minute, now)
+ if err != nil {
+ t.Fatalf("load session anchor: %v", err)
+ }
+ if ok {
+ t.Fatalf("expected stale whoami anchor to be rejected")
+ }
+}
+
+func TestLoadSessionAnchorCanUseFreshHelperArtifact(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeTestArtifact(t, workspace, "permissions", now.Add(-12*time.Minute), "1111", "2222", "3333")
+
+ anchor, ok, err := LoadSessionAnchorFromCommands(workspace, []string{"whoami", "permissions"}, contracts.AzureFoxSchemaVersion, "dev", 30*time.Minute, now)
+ if err != nil {
+ t.Fatalf("load session anchor: %v", err)
+ }
+ if !ok {
+ t.Fatalf("expected fresh helper artifact anchor")
+ }
+ if anchor.TenantID != "1111" || anchor.SubscriptionID != "2222" || anchor.CurrentPrincipal.ID != "3333" {
+ t.Fatalf("unexpected anchor: %#v", anchor)
+ }
+}
+
+func writeTestArtifact(t *testing.T, workspace string, command string, generatedAt time.Time, tenant string, subscription string, principal string) {
+ t.Helper()
+ writeTestArtifactLane(t, workspace, "json", command, generatedAt, tenant, subscription, principal)
+}
+
+func writeTestArtifactLane(t *testing.T, workspace string, lane string, command string, generatedAt time.Time, tenant string, subscription string, principal string) {
+ t.Helper()
+ payload := testArtifactPayload{
+ Metadata: models.Metadata{
+ AuthMode: models.StringPtr("fixture"),
+ Command: command,
+ GeneratedAt: generatedAt.Format(time.RFC3339),
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ SubscriptionID: models.StringPtr(subscription),
+ TenantID: models.StringPtr(tenant),
+ TokenSource: models.StringPtr("fixture"),
+ ArtifactContext: &models.ArtifactContext{
+ ToolVersion: "dev",
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: principal,
+ TenantID: tenant,
+ },
+ CommandOptions: map[string]string{},
+ },
+ },
+ Value: "from-artifact",
+ }
+ content := `{
+ "metadata": {
+ "auth_mode": "` + *payload.Metadata.AuthMode + `",
+ "command": "` + payload.Metadata.Command + `",
+ "generated_at": "` + payload.Metadata.GeneratedAt + `",
+ "schema_version": "` + payload.Metadata.SchemaVersion + `",
+ "subscription_id": "` + *payload.Metadata.SubscriptionID + `",
+ "tenant_id": "` + *payload.Metadata.TenantID + `",
+ "token_source": "` + *payload.Metadata.TokenSource + `",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "` + payload.Metadata.ArtifactContext.CurrentPrincipal.ID + `",
+ "tenant_id": "` + payload.Metadata.ArtifactContext.CurrentPrincipal.TenantID + `"
+ },
+ "command_options": {}
+ }
+ },
+ "value": "from-artifact"
+}
+`
+ path := filepath.Join(workspace, lane, command+".json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir artifact dir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write artifact: %v", err)
+ }
+}
+
+func writeRawTestArtifact(t *testing.T, workspace string, lane string, command string, content []byte) {
+ t.Helper()
+ path := filepath.Join(workspace, lane, command+".json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir artifact dir: %v", err)
+ }
+ if err := os.WriteFile(path, content, 0o644); err != nil {
+ t.Fatalf("write raw artifact: %v", err)
+ }
+}
diff --git a/internal/cli/app.go b/internal/cli/app.go
index 2b92cc2..bfa0f36 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -237,14 +237,12 @@ func (app *App) Run(args []string, stdout io.Writer, stderr io.Writer) int {
return 1
}
- if options.OutDir != "" {
- if _, err := artifacts.Write(commandName, response.Payload, options.OutDir, models.RenderContext{
- Tenant: options.Tenant,
- Subscription: options.Subscription,
- }); err != nil {
- fmt.Fprintf(stderr, "error: %s\n", err)
- return 1
- }
+ if _, err := artifacts.Write(commandName, response.Payload, options.OutDir, models.RenderContext{
+ Tenant: options.Tenant,
+ Subscription: options.Subscription,
+ }); err != nil {
+ fmt.Fprintf(stderr, "error: %s\n", err)
+ return 1
}
rendered, err := output.RenderWithContext(options.Output, commandName, response.Payload, models.RenderContext{
@@ -284,6 +282,7 @@ func parseOptions(commandName string, args []string, stderr io.Writer) (Options,
options := Options{
Output: models.OutputTable,
RoleTrustsMode: models.RoleTrustsModeFast,
+ OutDir: ".",
DevOpsOrganization: strings.TrimSpace(os.Getenv("AZUREFOX_DEVOPS_ORG")),
}
@@ -299,7 +298,7 @@ func parseOptions(commandName string, args []string, stderr io.Writer) (Options,
}
return nil
})
- flags.StringVar(&options.OutDir, "outdir", "", "Output directory for emitted artifacts")
+ flags.StringVar(&options.OutDir, "outdir", options.OutDir, "Output directory for emitted artifacts")
flags.BoolVar(&options.Debug, "debug", false, "Enable verbose error output")
for _, commandFlag := range contract.Flags {
diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go
index 37ed16c..fd2b346 100644
--- a/internal/cli/app_test.go
+++ b/internal/cli/app_test.go
@@ -415,8 +415,34 @@ func TestArtifactGenerationWritesAllFormats(t *testing.T) {
}
}
+func TestDefaultArtifactWorkspaceIsCurrentDirectory(t *testing.T) {
+ t.Chdir(t.TempDir())
+ app := newTestApp()
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ exitCode := app.Run([]string{"rbac", "--output", "json"}, &stdout, &stderr)
+ if exitCode != 0 {
+ t.Fatalf("expected exit code 0, got %d with stderr %q", exitCode, stderr.String())
+ }
+
+ if !strings.Contains(stdout.String(), `"artifact_context"`) {
+ t.Fatalf("expected stdout JSON to carry artifact context")
+ }
+ for _, path := range []string{
+ filepath.Join("json", "rbac.json"),
+ filepath.Join("loot", "rbac.json"),
+ filepath.Join("csv", "rbac.csv"),
+ filepath.Join("table", "rbac.txt"),
+ } {
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("expected default artifact %s: %v", path, err)
+ }
+ }
+}
+
func runSuccess(t *testing.T, args ...string) (string, string) {
t.Helper()
+ defer cleanupDefaultArtifacts(t)
app := newTestApp()
var stdout bytes.Buffer
@@ -430,6 +456,15 @@ func runSuccess(t *testing.T, args ...string) (string, string) {
return stdout.String(), stderr.String()
}
+func cleanupDefaultArtifacts(t *testing.T) {
+ t.Helper()
+ for _, dir := range []string{"csv", "json", "loot", "table"} {
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatalf("cleanup generated artifact directory %s: %v", dir, err)
+ }
+ }
+}
+
func assertSchemaVersion(t *testing.T, content string) {
t.Helper()
diff --git a/internal/commands/api_mgmt.go b/internal/commands/api_mgmt.go
index ff9ced4..48c4c42 100644
--- a/internal/commands/api_mgmt.go
+++ b/internal/commands/api_mgmt.go
@@ -21,7 +21,7 @@ func apiMgmtHandler(provider providers.Provider, now func() time.Time) Handler {
ApiManagementServices: services,
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("api-mgmt", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("api-mgmt", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
}, nil
}
}
diff --git a/internal/commands/app_services.go b/internal/commands/app_services.go
index 1bc5bdb..e770614 100644
--- a/internal/commands/app_services.go
+++ b/internal/commands/app_services.go
@@ -22,7 +22,7 @@ func appServicesHandler(provider providers.Provider, now func() time.Time) Handl
AppServices: appServices,
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("app-services", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("app-services", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
}, nil
}
}
diff --git a/internal/commands/appinsights.go b/internal/commands/appinsights.go
index bca71ce..61b49db 100644
--- a/internal/commands/appinsights.go
+++ b/internal/commands/appinsights.go
@@ -28,7 +28,7 @@ func appInsightsHandler(provider providers.Provider, now func() time.Time) Handl
Targets: targets,
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("appinsights", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("appinsights", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
}, nil
}
}
diff --git a/internal/commands/automation.go b/internal/commands/automation.go
index 631ca5d..2d8371c 100644
--- a/internal/commands/automation.go
+++ b/internal/commands/automation.go
@@ -17,14 +17,15 @@ func automationHandler(provider providers.Provider, now func() time.Time) Handle
}
return models.AutomationOutput{
- Metadata: models.AutomationMetadata{
+ Metadata: withAutomationArtifactContext(models.AutomationMetadata{
SchemaVersion: contracts.AzureFoxSchemaVersion,
Command: "automation",
GeneratedAt: now().UTC().Format(time.RFC3339),
TenantID: models.StringPtr(facts.TenantID),
SubscriptionID: models.StringPtr(facts.SubscriptionID),
- TokenSource: nil,
- },
+ TokenSource: models.StringPtr(facts.TokenSource),
+ AuthMode: models.StringPtr(facts.AuthMode),
+ }, request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
AutomationAccounts: sortedByLess(facts.AutomationAccounts, automationLess),
Findings: []models.Finding{},
Issues: facts.Issues,
diff --git a/internal/commands/chains.go b/internal/commands/chains.go
index 4c2bc20..4c7b217 100644
--- a/internal/commands/chains.go
+++ b/internal/commands/chains.go
@@ -10,6 +10,7 @@ import (
"sync"
"time"
+ "harrierops-azure/internal/artifacts"
"harrierops-azure/internal/contracts"
"harrierops-azure/internal/models"
"harrierops-azure/internal/providers"
@@ -259,9 +260,10 @@ type asyncCommandOutput[T any] struct {
}
type commandOutputCall struct {
- done chan struct{}
- value any
- err error
+ done chan struct{}
+ value any
+ source *models.SessionArtifact
+ err error
}
type commandOutputGroup struct {
@@ -304,17 +306,91 @@ func runGroupedCommandOutput[T any](group commandOutputGroup, ctx context.Contex
return asyncCommandOutput[T]{call: call, name: name}
}
+func runGroupedCommandOutputWithArtifact[T any](group commandOutputGroup, ctx context.Context, request Request, handler Handler, expected artifacts.ExpectedSession) asyncCommandOutput[T] {
+ group.mu.Lock()
+ if call, ok := group.calls[expected.Command]; ok {
+ group.mu.Unlock()
+ return asyncCommandOutput[T]{call: call, name: expected.Command}
+ }
+ call := &commandOutputCall{done: make(chan struct{})}
+ group.calls[expected.Command] = call
+ group.mu.Unlock()
+
+ go func() {
+ group.limiter <- struct{}{}
+ defer func() {
+ <-group.limiter
+ }()
+ if result, ok, err := artifacts.LoadSessionArtifact[T](artifactWorkspace(request.OutDir), expected); err != nil {
+ call.err = err
+ close(call.done)
+ return
+ } else if ok {
+ call.value = result.Payload
+ call.source = &result.Source
+ close(call.done)
+ return
+ }
+
+ value, err := runCommandOutput[T](ctx, request, handler, expected.Command)
+ if err == nil {
+ _, err = artifacts.Write(expected.Command, value, artifactWorkspace(request.OutDir), models.RenderContext{
+ Tenant: request.Tenant,
+ Subscription: request.Subscription,
+ })
+ }
+ call.value = value
+ call.err = err
+ close(call.done)
+ }()
+ return asyncCommandOutput[T]{call: call, name: expected.Command}
+}
+
+func runGroupedCommandOutputWritingArtifact[T any](group commandOutputGroup, ctx context.Context, request Request, handler Handler, name string) asyncCommandOutput[T] {
+ group.mu.Lock()
+ if call, ok := group.calls[name]; ok {
+ group.mu.Unlock()
+ return asyncCommandOutput[T]{call: call, name: name}
+ }
+ call := &commandOutputCall{done: make(chan struct{})}
+ group.calls[name] = call
+ group.mu.Unlock()
+
+ go func() {
+ group.limiter <- struct{}{}
+ defer func() {
+ <-group.limiter
+ }()
+ value, err := runCommandOutput[T](ctx, request, handler, name)
+ if err == nil {
+ _, err = artifacts.Write(name, value, artifactWorkspace(request.OutDir), models.RenderContext{
+ Tenant: request.Tenant,
+ Subscription: request.Subscription,
+ })
+ }
+ call.value = value
+ call.err = err
+ close(call.done)
+ }()
+ return asyncCommandOutput[T]{call: call, name: name}
+}
+
func (future asyncCommandOutput[T]) wait() (T, error) {
+ value, _, err := future.waitWithSource()
+ return value, err
+}
+
+func (future asyncCommandOutput[T]) waitWithSource() (T, *models.SessionArtifact, error) {
var zero T
<-future.call.done
if future.call.err != nil {
- return zero, future.call.err
+ return zero, nil, future.call.err
}
value, ok := future.call.value.(T)
if !ok {
- return zero, fmt.Errorf("unexpected payload type for %s: %T", future.name, future.call.value)
+ return zero, nil, fmt.Errorf("unexpected payload type for %s: %T", future.name, future.call.value)
}
- return value, nil
+ return value, future.call.source, nil
}
func buildDatabaseTargetView(output models.DatabasesOutput) credentialPathTargetView {
diff --git a/internal/commands/chains_compute_control.go b/internal/commands/chains_compute_control.go
index 738af58..79a92c0 100644
--- a/internal/commands/chains_compute_control.go
+++ b/internal/commands/chains_compute_control.go
@@ -48,7 +48,7 @@ func buildComputeControlOutput(
tokenSurfacesFuture := runGroupedCommandOutput[models.TokensCredentialsOutput](group, ctx, request, tokensCredentialsHandler(provider, now), "tokens-credentials")
envVarsFuture := runGroupedCommandOutput[models.EnvVarsOutput](group, ctx, request, envVarsHandler(provider, now), "env-vars")
managedIdentitiesFuture := runGroupedCommandOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
+ permissionsFuture := startPermissionsFuture(group, ctx, provider, now, request)
workloadsFuture := runGroupedCommandOutput[models.WorkloadsOutput](group, ctx, request, workloadsHandler(provider, now), "workloads")
tokenSurfaces, err := tokenSurfacesFuture.wait()
@@ -63,7 +63,7 @@ func buildComputeControlOutput(
if err != nil {
return models.ChainsOutput{}, err
}
- permissions, err := permissionsFuture.wait()
+ permissions, permissionsSource, err := permissionsFuture.waitWithSource()
if err != nil {
return models.ChainsOutput{}, err
}
@@ -177,9 +177,13 @@ func buildComputeControlOutput(
issues = append(issues, managedIdentities.Issues...)
issues = append(issues, permissions.Issues...)
issues = append(issues, workloads.Issues...)
+ sessionArtifacts := []models.SessionArtifact{}
+ if permissionsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *permissionsSource)
+ }
return models.ChainsOutput{
- Metadata: scopedMetadata(now, request, request.Tenant, request.Subscription, "chains"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, request.Tenant, request.Subscription, "chains"), sessionArtifacts),
GroupedCommandName: "chains",
Family: family.Name,
InputMode: "live",
diff --git a/internal/commands/chains_deployment.go b/internal/commands/chains_deployment.go
index d57a20e..a242d36 100644
--- a/internal/commands/chains_deployment.go
+++ b/internal/commands/chains_deployment.go
@@ -77,29 +77,25 @@ func buildDeploymentPathOutput(
) (models.ChainsOutput, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
devopsFuture := runGroupedCommandOutput[models.DevopsOutput](group, ctx, request, devopsHandler(provider, now), "devops")
- automationFuture := runGroupedCommandOutput[models.AutomationOutput](group, ctx, request, automationHandler(provider, now), "automation")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "automation", "app-services", "permissions", "rbac")
+ automationFuture := runHelperOutput[models.AutomationOutput](group, ctx, request, automationHandler(provider, now), "automation", expected)
+ identityControlFutures := startIdentityControlFuturesWithExpected(group, ctx, provider, now, request, expected)
roleTrustsFuture := runGroupedCommandOutput[models.RoleTrustsOutput](group, ctx, request, roleTrustsHandler(provider, now), "role-trusts")
keyvaultFuture := runGroupedCommandOutput[models.KeyVaultOutput](group, ctx, request, keyVaultHandler(provider, now), "keyvault")
armDeploymentsFuture := runGroupedCommandOutput[models.ArmDeploymentsOutput](group, ctx, request, armDeploymentsHandler(provider, now), "arm-deployments")
aksFuture := runGroupedCommandOutput[models.AksOutput](group, ctx, request, aksHandler(provider, now), "aks")
functionsFuture := runGroupedCommandOutput[models.FunctionsOutput](group, ctx, request, functionsHandler(provider, now), "functions")
- appServicesFuture := runGroupedCommandOutput[models.AppServicesOutput](group, ctx, request, appServicesHandler(provider, now), "app-services")
+ appServicesFuture := runHelperOutput[models.AppServicesOutput](group, ctx, request, appServicesHandler(provider, now), "app-services", expected)
devops, err := devopsFuture.wait()
if err != nil {
return models.ChainsOutput{}, err
}
- automation, err := automationFuture.wait()
+ automation, automationSource, err := automationFuture.waitWithSource()
if err != nil {
return models.ChainsOutput{}, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return models.ChainsOutput{}, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return models.ChainsOutput{}, err
}
@@ -123,7 +119,7 @@ func buildDeploymentPathOutput(
if err != nil {
return models.ChainsOutput{}, err
}
- appServices, err := appServicesFuture.wait()
+ appServices, appServicesSource, err := appServicesFuture.waitWithSource()
if err != nil {
return models.ChainsOutput{}, err
}
@@ -149,7 +145,7 @@ func buildDeploymentPathOutput(
permissionsByPrincipal := map[string]models.PermissionRow{}
currentIdentityPrincipals := map[string]struct{}{}
- for _, permission := range permissions.Permissions {
+ for _, permission := range identityControl.permissions.Permissions {
permissionsByPrincipal[permission.PrincipalID] = permission
if permission.IsCurrentIdentity {
currentIdentityPrincipals[permission.PrincipalID] = struct{}{}
@@ -157,7 +153,7 @@ func buildDeploymentPathOutput(
}
currentIdentityAssignments := make([]models.RoleAssignment, 0)
- for _, assignment := range rbac.RoleAssignments {
+ for _, assignment := range identityControl.rbac.RoleAssignments {
if _, ok := currentIdentityPrincipals[assignment.PrincipalID]; ok {
currentIdentityAssignments = append(currentIdentityAssignments, assignment)
}
@@ -235,8 +231,8 @@ func buildDeploymentPathOutput(
issues := append([]models.Issue{}, devops.Issues...)
issues = append(issues, automation.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
issues = append(issues, roleTrusts.Issues...)
issues = append(issues, keyvault.Issues...)
issues = append(issues, armDeployments.Issues...)
@@ -245,7 +241,7 @@ func buildDeploymentPathOutput(
issues = append(issues, appServices.Issues...)
return models.ChainsOutput{
- Metadata: scopedMetadata(now, request, request.Tenant, request.Subscription, "chains"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, request.Tenant, request.Subscription, "chains"), appendSessionArtifact(appendSessionArtifact(identityControl.sessionArtifacts, automationSource), appServicesSource)),
GroupedCommandName: "chains",
Family: family.Name,
InputMode: "live",
diff --git a/internal/commands/chains_escalation.go b/internal/commands/chains_escalation.go
index abc9b77..8df95bc 100644
--- a/internal/commands/chains_escalation.go
+++ b/internal/commands/chains_escalation.go
@@ -33,10 +33,10 @@ func buildEscalationPathOutput(
family contracts.FamilyContract,
) (models.ChainsOutput, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
+ permissionsFuture := startPermissionsFuture(group, ctx, provider, now, request)
roleTrustsFuture := runGroupedCommandOutput[models.RoleTrustsOutput](group, ctx, request, roleTrustsHandler(provider, now), "role-trusts")
- permissions, err := permissionsFuture.wait()
+ permissions, permissionsSource, err := permissionsFuture.waitWithSource()
if err != nil {
return models.ChainsOutput{}, err
}
@@ -67,9 +67,13 @@ func buildEscalationPathOutput(
issues := append([]models.Issue{}, permissions.Issues...)
issues = append(issues, roleTrusts.Issues...)
+ sessionArtifacts := []models.SessionArtifact{}
+ if permissionsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *permissionsSource)
+ }
return models.ChainsOutput{
- Metadata: scopedMetadata(now, request, request.Tenant, request.Subscription, "chains"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, request.Tenant, request.Subscription, "chains"), sessionArtifacts),
GroupedCommandName: "chains",
Family: family.Name,
InputMode: "live",
diff --git a/internal/commands/dcr.go b/internal/commands/dcr.go
index 4cef08c..6f52f7d 100644
--- a/internal/commands/dcr.go
+++ b/internal/commands/dcr.go
@@ -21,7 +21,7 @@ func dcrHandler(provider providers.Provider, now func() time.Time) Handler {
DCRs: dcrs,
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("dcr", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("dcr", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
}, nil
}
}
diff --git a/internal/commands/diagnostic_settings.go b/internal/commands/diagnostic_settings.go
index c10f341..78d36ae 100644
--- a/internal/commands/diagnostic_settings.go
+++ b/internal/commands/diagnostic_settings.go
@@ -29,7 +29,7 @@ func diagnosticSettingsHandler(provider providers.Provider, now func() time.Time
Sources: sources,
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("diagnostic-settings", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("diagnostic-settings", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
}, nil
}
}
diff --git a/internal/commands/evasion_appinsights.go b/internal/commands/evasion_appinsights.go
index 38109d0..4476ae4 100644
--- a/internal/commands/evasion_appinsights.go
+++ b/internal/commands/evasion_appinsights.go
@@ -29,10 +29,11 @@ func buildEvasionAppInsightsOutput(
contract contracts.EvasionSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- appInsightsFuture := runGroupedCommandOutput[models.AppInsightsOutput](group, ctx, request, appInsightsHandler(provider, now), "appinsights")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "appinsights", "permissions", "rbac")
+ appInsightsFuture := runHelperOutput[models.AppInsightsOutput](group, ctx, request, appInsightsHandler(provider, now), "appinsights", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- appInsights, err := appInsightsFuture.wait()
+ appInsights, appInsightsSource, err := appInsightsFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -70,12 +71,15 @@ func buildEvasionAppInsightsOutput(
issues := familyIssues(appInsights.Issues, evidence)
return models.EvasionAppInsightsOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(appInsights.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(appInsights.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "evasion",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(appInsights.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(appInsights.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "evasion",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, appInsightsSource),
),
GroupedCommandName: "evasion",
Surface: contract.Name,
diff --git a/internal/commands/evasion_dcr.go b/internal/commands/evasion_dcr.go
index 883b205..fe3c5b1 100644
--- a/internal/commands/evasion_dcr.go
+++ b/internal/commands/evasion_dcr.go
@@ -95,10 +95,11 @@ func buildEvasionDCROutput(
contract contracts.EvasionSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- dcrFuture := runGroupedCommandOutput[models.DCROutput](group, ctx, request, dcrHandler(provider, now), "dcr")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "dcr", "permissions", "rbac")
+ dcrFuture := runHelperOutput[models.DCROutput](group, ctx, request, dcrHandler(provider, now), "dcr", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- dcrOutput, err := dcrFuture.wait()
+ dcrOutput, dcrSource, err := dcrFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -143,12 +144,15 @@ func buildEvasionDCROutput(
issues := familyIssues(dcrOutput.Issues, evidence)
return models.EvasionDCROutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(dcrOutput.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(dcrOutput.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "evasion",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(dcrOutput.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(dcrOutput.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "evasion",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, dcrSource),
),
GroupedCommandName: "evasion",
Surface: contract.Name,
diff --git a/internal/commands/evasion_diagnostic_settings.go b/internal/commands/evasion_diagnostic_settings.go
index 3ce2b47..80cc638 100644
--- a/internal/commands/evasion_diagnostic_settings.go
+++ b/internal/commands/evasion_diagnostic_settings.go
@@ -78,10 +78,11 @@ func buildEvasionDiagnosticSettingsOutput(
contract contracts.EvasionSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- settingsFuture := runGroupedCommandOutput[models.DiagnosticSettingsOutput](group, ctx, request, diagnosticSettingsHandler(provider, now), "diagnostic-settings")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "diagnostic-settings", "permissions", "rbac")
+ settingsFuture := runHelperOutput[models.DiagnosticSettingsOutput](group, ctx, request, diagnosticSettingsHandler(provider, now), "diagnostic-settings", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- settings, err := settingsFuture.wait()
+ settings, settingsSource, err := settingsFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -121,12 +122,15 @@ func buildEvasionDiagnosticSettingsOutput(
issues := familyIssues(settings.Issues, evidence)
return models.EvasionDiagnosticSettingsOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(settings.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(settings.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "evasion",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(settings.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(settings.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "evasion",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, settingsSource),
),
GroupedCommandName: "evasion",
Surface: contract.Name,
diff --git a/internal/commands/event_grid.go b/internal/commands/event_grid.go
index d2ce522..9b55f61 100644
--- a/internal/commands/event_grid.go
+++ b/internal/commands/event_grid.go
@@ -24,7 +24,7 @@ func eventGridHandler(provider providers.Provider, now func() time.Time) Handler
return models.EventGridOutput{
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("event-grid", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("event-grid", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
Routes: routes,
}, nil
}
diff --git a/internal/commands/grouped_command_output_test.go b/internal/commands/grouped_command_output_test.go
index 0ebbb35..5cd43a5 100644
--- a/internal/commands/grouped_command_output_test.go
+++ b/internal/commands/grouped_command_output_test.go
@@ -2,7 +2,16 @@ package commands
import (
"context"
+ "encoding/json"
+ "os"
+ "path/filepath"
"testing"
+ "time"
+
+ "harrierops-azure/internal/artifacts"
+ "harrierops-azure/internal/contracts"
+ "harrierops-azure/internal/models"
+ "harrierops-azure/internal/providers"
)
func TestRunGroupedCommandOutputReusesCommandResult(t *testing.T) {
@@ -31,3 +40,166 @@ func TestRunGroupedCommandOutputReusesCommandResult(t *testing.T) {
t.Fatalf("expected one backing call, got %d", calls)
}
}
+
+func TestRunGroupedCommandOutputUsesMatchingSessionArtifact(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeRbacArtifact(t, workspace, now.Add(-20*time.Minute), "1111", "2222", "3333")
+
+ group := newCommandOutputGroup(chainsFanoutLimit)
+ calls := 0
+ handler := func(context.Context, Request) (any, error) {
+ calls++
+ return models.RbacOutput{}, nil
+ }
+
+ future := runGroupedCommandOutputWithArtifact[models.RbacOutput](
+ group,
+ context.Background(),
+ Request{OutDir: workspace},
+ handler,
+ artifacts.ExpectedSession{
+ Command: "rbac",
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ ToolVersion: toolVersion,
+ TenantID: "1111",
+ SubscriptionID: "2222",
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: "3333",
+ TenantID: "1111",
+ },
+ AuthMode: "fixture",
+ TokenSource: "fixture",
+ CommandOptions: map[string]string{},
+ MaxAge: time.Hour,
+ Now: now,
+ },
+ )
+
+ output, source, err := future.waitWithSource()
+ if err != nil {
+ t.Fatalf("wait failed: %v", err)
+ }
+ if calls != 0 {
+ t.Fatalf("expected artifact reuse to suppress live handler call, got %d calls", calls)
+ }
+ if source == nil || source.Command != "rbac" {
+ t.Fatalf("expected source artifact, got %#v", source)
+ }
+ if output.Metadata.Command != "rbac" {
+ t.Fatalf("expected rbac payload, got %#v", output.Metadata)
+ }
+}
+
+func TestHelperArtifactExpectedSessionsIgnoresFinalGroupedOutputArtifacts(t *testing.T) {
+ workspace := t.TempDir()
+ path := filepath.Join(workspace, "json", "resourcehijacking.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir grouped artifact dir: %v", err)
+ }
+ if err := os.WriteFile(path, []byte(`{"metadata":{"command":"resourcehijacking"}}`), 0o644); err != nil {
+ t.Fatalf("write grouped artifact: %v", err)
+ }
+
+ expected := helperArtifactExpectedSessions(
+ context.Background(),
+ Request{OutDir: workspace},
+ providers.NewStaticProvider(),
+ func() time.Time { return time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC) },
+ "permissions",
+ "rbac",
+ )
+ if expected != nil {
+ t.Fatalf("expected final grouped output artifact to be ignored as a helper reuse source, got %#v", expected)
+ }
+}
+
+func TestHelperArtifactExpectedSessionsUsesCurrentDirectoryWhenOutdirEmpty(t *testing.T) {
+ now := time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ workspace := t.TempDir()
+ writeRbacArtifact(t, workspace, now.Add(-10*time.Minute), "1111", "2222", "3333")
+
+ originalDir, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("get cwd: %v", err)
+ }
+ if err := os.Chdir(workspace); err != nil {
+ t.Fatalf("chdir workspace: %v", err)
+ }
+ t.Cleanup(func() {
+ if err := os.Chdir(originalDir); err != nil {
+ t.Fatalf("restore cwd: %v", err)
+ }
+ })
+
+ expected := helperArtifactExpectedSessions(
+ context.Background(),
+ Request{},
+ providers.NewStaticProvider(),
+ func() time.Time { return now },
+ "rbac",
+ )
+ if expected == nil {
+ t.Fatalf("expected current-directory artifact to create expected session")
+ }
+ if expected["rbac"].SubscriptionID != "2222" || expected["rbac"].CurrentPrincipal.ID != "3333" {
+ t.Fatalf("unexpected expected session: %#v", expected["rbac"])
+ }
+}
+
+func TestRunGroupedCommandOutputWritingArtifactReturnsWriteError(t *testing.T) {
+ group := newCommandOutputGroup(chainsFanoutLimit)
+ handler := func(context.Context, Request) (any, error) {
+ return models.RbacOutput{}, nil
+ }
+
+ future := runGroupedCommandOutputWritingArtifact[models.RbacOutput](
+ group,
+ context.Background(),
+ Request{OutDir: string([]byte{0})},
+ handler,
+ "rbac",
+ )
+ _, err := future.wait()
+ if err == nil {
+ t.Fatalf("expected artifact write error")
+ }
+}
+
+func writeRbacArtifact(t *testing.T, workspace string, generatedAt time.Time, tenant string, subscription string, principal string) {
+ t.Helper()
+ output := models.RbacOutput{
+ Metadata: models.Metadata{
+ AuthMode: models.StringPtr("fixture"),
+ Command: "rbac",
+ GeneratedAt: generatedAt.Format(time.RFC3339),
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ SubscriptionID: models.StringPtr(subscription),
+ TenantID: models.StringPtr(tenant),
+ TokenSource: models.StringPtr("fixture"),
+ ArtifactContext: &models.ArtifactContext{
+ ToolVersion: toolVersion,
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: principal,
+ TenantID: tenant,
+ },
+ CommandOptions: map[string]string{},
+ },
+ },
+ Issues: []models.Issue{},
+ Principals: []models.Principal{},
+ RoleAssignments: []models.RoleAssignment{},
+ Scopes: []models.ScopeRef{},
+ }
+ content, err := json.MarshalIndent(output, "", " ")
+ if err != nil {
+ t.Fatalf("marshal artifact: %v", err)
+ }
+ path := filepath.Join(workspace, "json", "rbac.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatalf("mkdir artifact dir: %v", err)
+ }
+ if err := os.WriteFile(path, append(content, '\n'), 0o644); err != nil {
+ t.Fatalf("write artifact: %v", err)
+ }
+}
diff --git a/internal/commands/grouped_family.go b/internal/commands/grouped_family.go
index 6acaffa..0c64f7b 100644
--- a/internal/commands/grouped_family.go
+++ b/internal/commands/grouped_family.go
@@ -6,6 +6,7 @@ import (
"strings"
"time"
+ "harrierops-azure/internal/artifacts"
"harrierops-azure/internal/contracts"
"harrierops-azure/internal/models"
"harrierops-azure/internal/providers"
@@ -40,34 +41,187 @@ type familyEvidenceFutures struct {
}
type familyEvidence struct {
- permissions models.PermissionsOutput
- rbac models.RbacOutput
- principal persistencePrincipalEvidence
+ permissions models.PermissionsOutput
+ rbac models.RbacOutput
+ principal persistencePrincipalEvidence
+ sessionArtifacts []models.SessionArtifact
+}
+
+var helperArtifactAnchorCommands = []string{
+ "whoami",
+ "permissions",
+ "rbac",
+ "principals",
+ "automation",
+ "logic-apps",
+ "api-mgmt",
+ "relay",
+ "dcr",
+ "diagnostic-settings",
+ "appinsights",
+ "monitoring-sinks",
+ "app-services",
+ "vm-extensions",
+ "event-grid",
+ "storage",
+ "keyvault",
+ "managed-identities",
+ "vms",
}
func runFamilyEvidence(group commandOutputGroup, ctx context.Context, request Request, provider providers.Provider, now func() time.Time) familyEvidenceFutures {
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "permissions", "rbac")
+ return runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
+}
+
+func runFamilyEvidenceWithExpected(group commandOutputGroup, ctx context.Context, request Request, provider providers.Provider, now func() time.Time, expected map[string]artifacts.ExpectedSession) familyEvidenceFutures {
return familyEvidenceFutures{
- permissions: runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions"),
- rbac: runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac"),
+ permissions: runPermissionsOutput(group, ctx, request, provider, now, expected),
+ rbac: runRBACOutput(group, ctx, request, provider, now, expected),
}
}
func (futures familyEvidenceFutures) wait() (familyEvidence, error) {
- permissions, err := futures.permissions.wait()
- if err != nil {
- return familyEvidence{}, err
- }
- rbac, err := futures.rbac.wait()
+ permissions, rbac, principal, sessionArtifacts, err := waitPermissionsRBACBundle(futures.permissions, futures.rbac)
if err != nil {
return familyEvidence{}, err
}
return familyEvidence{
- permissions: permissions,
- rbac: rbac,
- principal: buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments),
+ permissions: permissions,
+ rbac: rbac,
+ principal: principal,
+ sessionArtifacts: sessionArtifacts,
}, nil
}
+func waitPermissionsRBACBundle(
+ permissionsFuture asyncCommandOutput[models.PermissionsOutput],
+ rbacFuture asyncCommandOutput[models.RbacOutput],
+) (models.PermissionsOutput, models.RbacOutput, persistencePrincipalEvidence, []models.SessionArtifact, error) {
+ permissions, permissionsSource, err := permissionsFuture.waitWithSource()
+ if err != nil {
+ return models.PermissionsOutput{}, models.RbacOutput{}, persistencePrincipalEvidence{}, nil, err
+ }
+ rbac, rbacSource, err := rbacFuture.waitWithSource()
+ if err != nil {
+ return models.PermissionsOutput{}, models.RbacOutput{}, persistencePrincipalEvidence{}, nil, err
+ }
+ sessionArtifacts := []models.SessionArtifact{}
+ if permissionsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *permissionsSource)
+ }
+ if rbacSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *rbacSource)
+ }
+ return permissions, rbac, buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments), sessionArtifacts, nil
+}
+
+func helperArtifactExpectedSessions(ctx context.Context, request Request, provider providers.Provider, now func() time.Time, commands ...string) map[string]artifacts.ExpectedSession {
+ workspace := artifactWorkspace(request.OutDir)
+ hasCandidate := false
+ for _, command := range commands {
+ if artifacts.HasSessionArtifact(workspace, command) {
+ hasCandidate = true
+ break
+ }
+ }
+ if !hasCandidate {
+ return nil
+ }
+
+ anchor, ok := loadHelperArtifactAnchor(ctx, request, provider, now)
+ if !ok {
+ return nil
+ }
+ expected := make(map[string]artifacts.ExpectedSession, len(commands))
+ for _, command := range commands {
+ expected[command] = artifacts.ExpectedSession{
+ Command: command,
+ SchemaVersion: contracts.AzureFoxSchemaVersion,
+ ToolVersion: toolVersion,
+ TenantID: anchor.TenantID,
+ SubscriptionID: anchor.SubscriptionID,
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: anchor.CurrentPrincipal.ID,
+ PrincipalType: anchor.CurrentPrincipal.PrincipalType,
+ TenantID: anchor.CurrentPrincipal.TenantID,
+ },
+ AuthMode: anchor.AuthMode,
+ TokenSource: anchor.TokenSource,
+ CommandOptions: artifactCommandOptions(command, request),
+ MaxAge: 60 * time.Minute,
+ Now: now().UTC(),
+ }
+ }
+ return expected
+}
+
+func loadHelperArtifactAnchor(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (artifacts.SessionAnchor, bool) {
+ workspace := artifactWorkspace(request.OutDir)
+ anchor, ok, err := artifacts.LoadSessionAnchorFromCommands(workspace, helperArtifactAnchorCommands, contracts.AzureFoxSchemaVersion, toolVersion, 30*time.Minute, now().UTC())
+ if err == nil && ok {
+ return anchor, true
+ }
+
+ whoami, err := runCommandOutput[models.WhoAmIOutput](ctx, request, whoAmIHandler(provider, now), "whoami")
+ if err != nil {
+ return artifacts.SessionAnchor{}, false
+ }
+ if _, err := artifacts.Write("whoami", whoami, workspace, models.RenderContext{
+ Tenant: whoami.TenantID,
+ Subscription: whoami.Subscription.ID,
+ }); err != nil {
+ return artifacts.SessionAnchor{}, false
+ }
+ return artifacts.SessionAnchor{
+ TenantID: whoami.TenantID,
+ SubscriptionID: whoami.Subscription.ID,
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: whoami.Principal.ID,
+ PrincipalType: whoami.Principal.PrincipalType,
+ TenantID: whoami.Principal.TenantID,
+ },
+ AuthMode: stringPtrValue(whoami.Metadata.AuthMode),
+ TokenSource: stringPtrValue(whoami.Metadata.TokenSource),
+ }, true
+}
+
+func runPermissionsOutput(
+ group commandOutputGroup,
+ ctx context.Context,
+ request Request,
+ provider providers.Provider,
+ now func() time.Time,
+ expected map[string]artifacts.ExpectedSession,
+) asyncCommandOutput[models.PermissionsOutput] {
+ return runHelperOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions", expected)
+}
+
+func runRBACOutput(
+ group commandOutputGroup,
+ ctx context.Context,
+ request Request,
+ provider providers.Provider,
+ now func() time.Time,
+ expected map[string]artifacts.ExpectedSession,
+) asyncCommandOutput[models.RbacOutput] {
+ return runHelperOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac", expected)
+}
+
+func runHelperOutput[T any](
+ group commandOutputGroup,
+ ctx context.Context,
+ request Request,
+ handler Handler,
+ command string,
+ expected map[string]artifacts.ExpectedSession,
+) asyncCommandOutput[T] {
+ if session, ok := expected[command]; ok {
+ return runGroupedCommandOutputWithArtifact[T](group, ctx, request, handler, session)
+ }
+ return runGroupedCommandOutputWritingArtifact[T](group, ctx, request, handler, command)
+}
+
func familyIssues(base []models.Issue, evidence familyEvidence) []models.Issue {
issues := append([]models.Issue{}, base...)
issues = append(issues, evidence.permissions.Issues...)
@@ -75,6 +229,15 @@ func familyIssues(base []models.Issue, evidence familyEvidence) []models.Issue {
return issues
}
+func appendSessionArtifact(base []models.SessionArtifact, source *models.SessionArtifact) []models.SessionArtifact {
+ if source == nil {
+ return append([]models.SessionArtifact{}, base...)
+ }
+ items := append([]models.SessionArtifact{}, base...)
+ items = append(items, *source)
+ return items
+}
+
func groupedFamilyHandler(provider providers.Provider, now func() time.Time, config groupedFamilyConfig) Handler {
return func(ctx context.Context, request Request) (any, error) {
surface := strings.TrimSpace(config.Selector(request))
diff --git a/internal/commands/identity_control_helpers.go b/internal/commands/identity_control_helpers.go
new file mode 100644
index 0000000..c2048ff
--- /dev/null
+++ b/internal/commands/identity_control_helpers.go
@@ -0,0 +1,71 @@
+package commands
+
+import (
+ "context"
+ "time"
+
+ "harrierops-azure/internal/artifacts"
+ "harrierops-azure/internal/models"
+ "harrierops-azure/internal/providers"
+)
+
+type identityControlFutures struct {
+ permissions asyncCommandOutput[models.PermissionsOutput]
+ rbac asyncCommandOutput[models.RbacOutput]
+}
+
+type identityControlData struct {
+ permissions models.PermissionsOutput
+ rbac models.RbacOutput
+ evidence persistencePrincipalEvidence
+ sessionArtifacts []models.SessionArtifact
+}
+
+func startIdentityControlFutures(
+ group commandOutputGroup,
+ ctx context.Context,
+ provider providers.Provider,
+ now func() time.Time,
+ request Request,
+) identityControlFutures {
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "permissions", "rbac")
+ return startIdentityControlFuturesWithExpected(group, ctx, provider, now, request, expected)
+}
+
+func startIdentityControlFuturesWithExpected(
+ group commandOutputGroup,
+ ctx context.Context,
+ provider providers.Provider,
+ now func() time.Time,
+ request Request,
+ expected map[string]artifacts.ExpectedSession,
+) identityControlFutures {
+ return identityControlFutures{
+ permissions: runPermissionsOutput(group, ctx, request, provider, now, expected),
+ rbac: runRBACOutput(group, ctx, request, provider, now, expected),
+ }
+}
+
+func (futures identityControlFutures) wait() (identityControlData, error) {
+ permissions, rbac, evidence, sessionArtifacts, err := waitPermissionsRBACBundle(futures.permissions, futures.rbac)
+ if err != nil {
+ return identityControlData{}, err
+ }
+ return identityControlData{
+ permissions: permissions,
+ rbac: rbac,
+ evidence: evidence,
+ sessionArtifacts: sessionArtifacts,
+ }, nil
+}
+
+func startPermissionsFuture(
+ group commandOutputGroup,
+ ctx context.Context,
+ provider providers.Provider,
+ now func() time.Time,
+ request Request,
+) asyncCommandOutput[models.PermissionsOutput] {
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "permissions")
+ return runPermissionsOutput(group, ctx, request, provider, now, expected)
+}
diff --git a/internal/commands/keyvault.go b/internal/commands/keyvault.go
index 1be4027..02467a6 100644
--- a/internal/commands/keyvault.go
+++ b/internal/commands/keyvault.go
@@ -21,7 +21,7 @@ func keyVaultHandler(provider providers.Provider, now func() time.Time) Handler
Findings: keyVaultFindings(vaults),
Issues: facts.Issues,
KeyVaults: vaults,
- Metadata: commandMetadata("keyvault", now, request, facts.TenantID, facts.SubscriptionID, ""),
+ Metadata: withArtifactContext(commandMetadata("keyvault", now, request, facts.TenantID, facts.SubscriptionID, facts.TokenSource), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
}, nil
}
}
diff --git a/internal/commands/logic_apps.go b/internal/commands/logic_apps.go
index 97b0684..1847437 100644
--- a/internal/commands/logic_apps.go
+++ b/internal/commands/logic_apps.go
@@ -25,7 +25,7 @@ func logicAppsHandler(provider providers.Provider, now func() time.Time) Handler
return models.LogicAppsOutput{
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("logic-apps", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("logic-apps", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
Workflows: workflows,
}, nil
}
diff --git a/internal/commands/logic_apps_test.go b/internal/commands/logic_apps_test.go
index 0ade668..101e9e0 100644
--- a/internal/commands/logic_apps_test.go
+++ b/internal/commands/logic_apps_test.go
@@ -171,7 +171,7 @@ func TestBuildPersistenceAzureMLOutputResolvesVisibleExecutionContextRoleContext
context.Background(),
providers.NewStaticProvider(),
func() time.Time { return time.Unix(0, 0) },
- Request{},
+ Request{OutDir: t.TempDir()},
contract,
)
if err != nil {
@@ -215,7 +215,7 @@ func TestBuildPersistenceAzureMLOutputResolvesVisibleExecutionContextRoleContext
func TestManagedIdentitiesOutputIncludesAzureMLAttachments(t *testing.T) {
outputAny, err := managedIdentitiesHandler(providers.NewStaticProvider(), func() time.Time { return time.Unix(0, 0) })(
context.Background(),
- Request{},
+ Request{OutDir: t.TempDir()},
)
if err != nil {
t.Fatalf("managedIdentitiesHandler returned error: %v", err)
@@ -252,7 +252,7 @@ func TestBuildPersistenceFunctionsOutputResolvesVisibleExecutionContextRoleConte
context.Background(),
providers.NewStaticProvider(),
func() time.Time { return time.Unix(0, 0) },
- Request{},
+ Request{OutDir: t.TempDir()},
contract,
)
if err != nil {
@@ -327,7 +327,7 @@ func TestBuildPersistenceLogicAppsOutputResolvesVisibleExecutionContextRoleConte
context.Background(),
providers.NewStaticProvider(),
func() time.Time { return time.Unix(0, 0) },
- Request{},
+ Request{OutDir: t.TempDir()},
contract,
)
if err != nil {
@@ -386,7 +386,7 @@ func TestBuildPersistenceWebJobsOutputResolvesInheritedExecutionContext(t *testi
context.Background(),
providers.NewStaticProvider(),
func() time.Time { return time.Unix(0, 0) },
- Request{},
+ Request{OutDir: t.TempDir()},
contract,
)
if err != nil {
diff --git a/internal/commands/managed_identities.go b/internal/commands/managed_identities.go
index fc683bc..db0e568 100644
--- a/internal/commands/managed_identities.go
+++ b/internal/commands/managed_identities.go
@@ -8,15 +8,19 @@ import (
"harrierops-azure/internal/providers"
)
+type managedIdentitiesSourceProvider interface {
+ ManagedIdentitiesFromSources(context.Context, string, string, *providers.RBACFacts) (providers.ManagedIdentitiesFacts, error)
+}
+
func managedIdentitiesHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
- facts, err := provider.ManagedIdentities(ctx, request.Tenant, request.Subscription)
+ facts, sessionArtifacts, err := managedIdentitiesFacts(ctx, request, provider, now)
if err != nil {
return nil, err
}
return models.ManagedIdentitiesOutput{
- Metadata: scopedMetadata(now, request, facts.TenantID, facts.SubscriptionID, "managed-identities"),
+ Metadata: withSessionArtifacts(withScopedArtifactContext(scopedMetadata(now, request, facts.TenantID, facts.SubscriptionID, "managed-identities"), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource), sessionArtifacts),
Identities: facts.Identities,
RoleAssignments: facts.RoleAssignments,
Findings: facts.Findings,
@@ -24,3 +28,41 @@ func managedIdentitiesHandler(provider providers.Provider, now func() time.Time)
}, nil
}
}
+
+func managedIdentitiesFacts(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (providers.ManagedIdentitiesFacts, []models.SessionArtifact, error) {
+ sourceProvider, ok := provider.(managedIdentitiesSourceProvider)
+ if !ok {
+ facts, err := provider.ManagedIdentities(ctx, request.Tenant, request.Subscription)
+ return facts, nil, err
+ }
+
+ group := newCommandOutputGroup(1)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "rbac")
+ rbacFuture := runHelperOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac", expected)
+
+ rbac, rbacSource, err := rbacFuture.waitWithSource()
+ if err != nil {
+ return providers.ManagedIdentitiesFacts{}, nil, err
+ }
+ rbacFacts := rbacFactsFromOutput(rbac)
+ facts, err := sourceProvider.ManagedIdentitiesFromSources(ctx, request.Tenant, request.Subscription, &rbacFacts)
+ if err != nil {
+ return providers.ManagedIdentitiesFacts{}, nil, err
+ }
+ return facts, appendSessionArtifact(nil, rbacSource), nil
+}
+
+func rbacFactsFromOutput(output models.RbacOutput) providers.RBACFacts {
+ identity := artifactIdentityFactsFromMetadata(output.Metadata)
+ return providers.RBACFacts{
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ CurrentPrincipal: identity.CurrentPrincipal,
+ TokenSource: identity.TokenSource,
+ AuthMode: identity.AuthMode,
+ Principals: append([]models.Principal{}, output.Principals...),
+ Scopes: append([]models.ScopeRef{}, output.Scopes...),
+ RoleAssignments: append([]models.RoleAssignment{}, output.RoleAssignments...),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
diff --git a/internal/commands/metadata.go b/internal/commands/metadata.go
index 6d8e2b0..8b38522 100644
--- a/internal/commands/metadata.go
+++ b/internal/commands/metadata.go
@@ -5,8 +5,11 @@ import (
"harrierops-azure/internal/contracts"
"harrierops-azure/internal/models"
+ "harrierops-azure/internal/providers"
)
+const toolVersion = "dev"
+
func commandMetadata(
command string,
now func() time.Time,
@@ -90,3 +93,123 @@ func networkMetadata(
TokenSource: nil,
}
}
+
+func withArtifactContext(metadata models.Metadata, request Request, principal models.Principal, authMode string, tokenSource string) models.Metadata {
+ metadata.ArtifactContext = artifactContext(metadata.Command, request, principal)
+ metadata.AuthMode = models.StringPtr(authMode)
+ metadata.TokenSource = models.StringPtr(tokenSource)
+ return metadata
+}
+
+func withScopedArtifactContext(metadata models.ScopedCommandMetadata, request Request, principal models.Principal, authMode string, tokenSource string) models.ScopedCommandMetadata {
+ metadata.ArtifactContext = artifactContext(metadata.Command, request, principal)
+ metadata.AuthMode = models.StringPtr(authMode)
+ metadata.TokenSource = models.StringPtr(tokenSource)
+ return metadata
+}
+
+func withPrincipalsArtifactContext(metadata models.PrincipalsMetadata, request Request, principal models.Principal, authMode string, tokenSource string) models.PrincipalsMetadata {
+ metadata.ArtifactContext = artifactContext(metadata.Command, request, principal)
+ metadata.AuthMode = models.StringPtr(authMode)
+ metadata.TokenSource = models.StringPtr(tokenSource)
+ return metadata
+}
+
+func withRuntimeArtifactContext(metadata models.RuntimeCommandMetadata, request Request, principal models.Principal, authMode string, tokenSource string) models.RuntimeCommandMetadata {
+ metadata.ArtifactContext = artifactContext(metadata.Command, request, principal)
+ metadata.AuthMode = models.StringPtr(authMode)
+ metadata.TokenSource = models.StringPtr(tokenSource)
+ return metadata
+}
+
+func withAutomationArtifactContext(metadata models.AutomationMetadata, request Request, principal models.Principal, authMode string, tokenSource string) models.AutomationMetadata {
+ metadata.ArtifactContext = artifactContext(metadata.Command, request, principal)
+ metadata.AuthMode = models.StringPtr(authMode)
+ metadata.TokenSource = models.StringPtr(tokenSource)
+ return metadata
+}
+
+func withSessionArtifacts(metadata models.ScopedCommandMetadata, artifacts []models.SessionArtifact) models.ScopedCommandMetadata {
+ if len(artifacts) == 0 {
+ return metadata
+ }
+ metadata.SessionArtifacts = append([]models.SessionArtifact{}, artifacts...)
+ return metadata
+}
+
+func withRuntimeSessionArtifacts(metadata models.RuntimeCommandMetadata, artifacts []models.SessionArtifact) models.RuntimeCommandMetadata {
+ if len(artifacts) == 0 {
+ return metadata
+ }
+ metadata.SessionArtifacts = append([]models.SessionArtifact{}, artifacts...)
+ return metadata
+}
+
+func withMetadataSessionArtifacts(metadata models.Metadata, artifacts []models.SessionArtifact) models.Metadata {
+ if len(artifacts) == 0 {
+ return metadata
+ }
+ metadata.SessionArtifacts = append([]models.SessionArtifact{}, artifacts...)
+ return metadata
+}
+
+func artifactContext(command string, request Request, principal models.Principal) *models.ArtifactContext {
+ return &models.ArtifactContext{
+ ToolVersion: toolVersion,
+ CurrentPrincipal: models.ArtifactPrincipal{
+ ID: principal.ID,
+ PrincipalType: principal.PrincipalType,
+ TenantID: principal.TenantID,
+ },
+ CommandOptions: artifactCommandOptions(command, request),
+ }
+}
+
+func artifactIdentityFactsFromContext(context *models.ArtifactContext, authMode *string, tokenSource *string) providers.ArtifactIdentityFacts {
+ principal := models.Principal{}
+ if context != nil {
+ principal = models.Principal{
+ ID: context.CurrentPrincipal.ID,
+ PrincipalType: context.CurrentPrincipal.PrincipalType,
+ TenantID: context.CurrentPrincipal.TenantID,
+ }
+ }
+ return providers.ArtifactIdentityFacts{
+ CurrentPrincipal: principal,
+ AuthMode: stringPtrValue(authMode),
+ TokenSource: stringPtrValue(tokenSource),
+ }
+}
+
+func artifactCommandOptions(command string, request Request) map[string]string {
+ options := map[string]string{}
+ if command == "devops" && request.DevOpsOrganization != "" {
+ options["devops_organization"] = request.DevOpsOrganization
+ }
+ if command == "role-trusts" && request.RoleTrustsMode != "" {
+ options["role_trusts_mode"] = string(request.RoleTrustsMode.Semantic())
+ }
+ if command == "chains" && request.ChainFamily != "" {
+ options["chain_family"] = request.ChainFamily
+ }
+ if command == "persistence" && request.PersistenceSurface != "" {
+ options["persistence_surface"] = request.PersistenceSurface
+ }
+ if command == "evasion" && request.EvasionSurface != "" {
+ options["evasion_surface"] = request.EvasionSurface
+ }
+ if command == "resourcehijacking" && request.ResourceHijackingSurface != "" {
+ options["resourcehijacking_surface"] = request.ResourceHijackingSurface
+ }
+ if command == "pathmasking" && request.PathMaskingSurface != "" {
+ options["pathmasking_surface"] = request.PathMaskingSurface
+ }
+ return options
+}
+
+func artifactWorkspace(outDir string) string {
+ if outDir == "" {
+ return "."
+ }
+ return outDir
+}
diff --git a/internal/commands/monitoring_sinks.go b/internal/commands/monitoring_sinks.go
index 35262c7..2227603 100644
--- a/internal/commands/monitoring_sinks.go
+++ b/internal/commands/monitoring_sinks.go
@@ -8,9 +8,13 @@ import (
"harrierops-azure/internal/providers"
)
+type monitoringSinksSourceProvider interface {
+ MonitoringSinksFromSources(context.Context, string, string, *providers.DCRFacts, *providers.DiagnosticSettingsFacts) (providers.MonitoringSinksFacts, error)
+}
+
func monitoringSinksHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
- facts, err := provider.MonitoringSinks(ctx, request.Tenant, request.Subscription)
+ facts, sessionArtifacts, err := monitoringSinksFacts(ctx, request, provider, now)
if err != nil {
return nil, err
}
@@ -18,11 +22,76 @@ func monitoringSinksHandler(provider providers.Provider, now func() time.Time) H
Sinks: sortedByLess(facts.Sinks, monitoringSinkLess),
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("monitoring-sinks", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeSessionArtifacts(
+ withRuntimeArtifactContext(runtimeCommandMetadata("monitoring-sinks", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
+ sessionArtifacts,
+ ),
}, nil
}
}
+func monitoringSinksFacts(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (providers.MonitoringSinksFacts, []models.SessionArtifact, error) {
+ sourceProvider, ok := provider.(monitoringSinksSourceProvider)
+ if !ok {
+ facts, err := provider.MonitoringSinks(ctx, request.Tenant, request.Subscription)
+ return facts, nil, err
+ }
+
+ group := newCommandOutputGroup(2)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "dcr", "diagnostic-settings")
+ dcrFuture := runHelperOutput[models.DCROutput](group, ctx, request, dcrHandler(provider, now), "dcr", expected)
+ diagnosticFuture := runHelperOutput[models.DiagnosticSettingsOutput](group, ctx, request, diagnosticSettingsHandler(provider, now), "diagnostic-settings", expected)
+
+ dcr, dcrSource, err := dcrFuture.waitWithSource()
+ if err != nil {
+ return providers.MonitoringSinksFacts{}, nil, err
+ }
+ diagnosticSettings, diagnosticSource, err := diagnosticFuture.waitWithSource()
+ if err != nil {
+ return providers.MonitoringSinksFacts{}, nil, err
+ }
+
+ dcrFacts := dcrFactsFromOutput(dcr)
+ diagnosticFacts := diagnosticSettingsFactsFromOutput(diagnosticSettings)
+ facts, err := sourceProvider.MonitoringSinksFromSources(ctx, request.Tenant, request.Subscription, &dcrFacts, &diagnosticFacts)
+ if err != nil {
+ return providers.MonitoringSinksFacts{}, nil, err
+ }
+
+ sessionArtifacts := []models.SessionArtifact{}
+ if dcrSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *dcrSource)
+ }
+ if diagnosticSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *diagnosticSource)
+ }
+ return facts, sessionArtifacts, nil
+}
+
+func dcrFactsFromOutput(output models.DCROutput) providers.DCRFacts {
+ return providers.DCRFacts{
+ ArtifactIdentityFacts: artifactIdentityFactsFromRuntimeMetadata(output.Metadata),
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ DCRs: append([]models.DCRAsset{}, output.DCRs...),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func diagnosticSettingsFactsFromOutput(output models.DiagnosticSettingsOutput) providers.DiagnosticSettingsFacts {
+ return providers.DiagnosticSettingsFacts{
+ ArtifactIdentityFacts: artifactIdentityFactsFromRuntimeMetadata(output.Metadata),
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ Sources: append([]models.DiagnosticSettingsSource{}, output.Sources...),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func artifactIdentityFactsFromRuntimeMetadata(metadata models.RuntimeCommandMetadata) providers.ArtifactIdentityFacts {
+ return artifactIdentityFactsFromContext(metadata.ArtifactContext, metadata.AuthMode, metadata.TokenSource)
+}
+
func monitoringSinkLess(left models.MonitoringSinkAsset, right models.MonitoringSinkAsset) bool {
if left.ReferenceCount != right.ReferenceCount {
return left.ReferenceCount > right.ReferenceCount
diff --git a/internal/commands/partial_consumers_test.go b/internal/commands/partial_consumers_test.go
new file mode 100644
index 0000000..0ecfeed
--- /dev/null
+++ b/internal/commands/partial_consumers_test.go
@@ -0,0 +1,200 @@
+package commands
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "harrierops-azure/internal/artifacts"
+ "harrierops-azure/internal/models"
+ "harrierops-azure/internal/providers"
+)
+
+func TestPartialConsumerMonitoringSinksUsesSourceArtifacts(t *testing.T) {
+ workspace := t.TempDir()
+ now := fixedArtifactTestTime()
+ provider := providers.NewStaticProvider()
+ writeCommandArtifact(t, workspace, "dcr", dcrHandler(provider, now))
+ writeCommandArtifact(t, workspace, "diagnostic-settings", diagnosticSettingsHandler(provider, now))
+
+ payload, err := monitoringSinksHandler(blockingMonitoringSinksProvider{StaticProvider: provider}, now)(context.Background(), Request{OutDir: workspace})
+ if err != nil {
+ t.Fatalf("monitoring-sinks handler: %v", err)
+ }
+ output := payload.(models.MonitoringSinksOutput)
+ assertSessionArtifactCommands(t, output.Metadata.SessionArtifacts, "dcr", "diagnostic-settings")
+}
+
+func TestPartialConsumerResourceTrustsUsesSourceArtifacts(t *testing.T) {
+ workspace := t.TempDir()
+ now := fixedArtifactTestTime()
+ provider := providers.NewStaticProvider()
+ writeCommandArtifact(t, workspace, "storage", storageHandler(provider, now))
+ writeCommandArtifact(t, workspace, "keyvault", keyVaultHandler(provider, now))
+
+ payload, err := resourceTrustsHandler(blockingResourceTrustsProvider{StaticProvider: provider}, now)(context.Background(), Request{OutDir: workspace})
+ if err != nil {
+ t.Fatalf("resource-trusts handler: %v", err)
+ }
+ output := payload.(models.ResourceTrustsOutput)
+ assertSessionArtifactCommands(t, output.Metadata.SessionArtifacts, "storage", "keyvault")
+}
+
+func TestPartialConsumerIdentityCommandsUseSourceArtifacts(t *testing.T) {
+ workspace := t.TempDir()
+ now := fixedArtifactTestTime()
+ provider := providers.NewStaticProvider()
+ writeCommandArtifact(t, workspace, "rbac", rbacHandler(provider, now))
+ writeCommandArtifact(t, workspace, "whoami", whoAmIHandler(provider, now))
+ writeCommandArtifact(t, workspace, "managed-identities", managedIdentitiesHandler(provider, now))
+
+ request := Request{OutDir: workspace}
+ principalsPayload, err := principalsHandler(blockingIdentityProvider{StaticProvider: provider}, now)(context.Background(), request)
+ if err != nil {
+ t.Fatalf("principals handler: %v", err)
+ }
+ principalsOutput := principalsPayload.(models.PrincipalsOutput)
+ assertSessionArtifactCommands(t, principalsOutput.Metadata.SessionArtifacts, "rbac", "whoami", "managed-identities")
+
+ permissionsPayload, err := permissionsHandler(blockingIdentityProvider{StaticProvider: provider}, now)(context.Background(), request)
+ if err != nil {
+ t.Fatalf("permissions handler: %v", err)
+ }
+ permissionsOutput := permissionsPayload.(models.PermissionsOutput)
+ assertSessionArtifactCommands(t, permissionsOutput.Metadata.SessionArtifacts, "rbac", "whoami", "managed-identities")
+}
+
+func TestPartialConsumerPrivescUsesSourceArtifacts(t *testing.T) {
+ workspace := t.TempDir()
+ now := fixedArtifactTestTime()
+ provider := providers.NewStaticProvider()
+ writeCommandArtifact(t, workspace, "permissions", permissionsHandler(provider, now))
+ writeCommandArtifact(t, workspace, "principals", principalsHandler(provider, now))
+ writeCommandArtifact(t, workspace, "managed-identities", managedIdentitiesHandler(provider, now))
+ writeCommandArtifact(t, workspace, "vms", vmsHandler(provider, now))
+
+ payload, err := privescHandler(blockingPrivescProvider{StaticProvider: provider}, now)(context.Background(), Request{OutDir: workspace})
+ if err != nil {
+ t.Fatalf("privesc handler: %v", err)
+ }
+ output := payload.(models.PrivescOutput)
+ assertSessionArtifactCommands(t, output.Metadata.SessionArtifacts, "permissions", "principals", "managed-identities", "vms")
+}
+
+func TestPartialConsumerRejectsMismatchedSourceArtifact(t *testing.T) {
+ workspace := t.TempDir()
+ now := fixedArtifactTestTime()
+ provider := providers.NewStaticProvider()
+ writeCommandArtifact(t, workspace, "dcr", dcrHandler(provider, now))
+ writeCommandArtifactWithRequest(t, workspace, "diagnostic-settings", diagnosticSettingsHandler(provider, now), Request{
+ OutDir: workspace,
+ Subscription: "different-subscription",
+ })
+
+ payload, err := monitoringSinksHandler(blockingMonitoringSinksProvider{StaticProvider: provider}, now)(context.Background(), Request{OutDir: workspace})
+ if err != nil {
+ t.Fatalf("monitoring-sinks handler: %v", err)
+ }
+ output := payload.(models.MonitoringSinksOutput)
+ assertSessionArtifactCommands(t, output.Metadata.SessionArtifacts, "dcr")
+}
+
+type blockingMonitoringSinksProvider struct {
+ providers.StaticProvider
+}
+
+func (provider blockingMonitoringSinksProvider) MonitoringSinks(_ context.Context, _ string, _ string) (providers.MonitoringSinksFacts, error) {
+ panic("monitoring-sinks should compose from source artifacts")
+}
+
+func (provider blockingMonitoringSinksProvider) MonitoringSinksFromSources(ctx context.Context, tenant string, subscription string, dcrFacts *providers.DCRFacts, diagnosticFacts *providers.DiagnosticSettingsFacts) (providers.MonitoringSinksFacts, error) {
+ if dcrFacts == nil || diagnosticFacts == nil {
+ panic("monitoring-sinks missing source facts")
+ }
+ return provider.StaticProvider.MonitoringSinksFromSources(ctx, tenant, subscription, dcrFacts, diagnosticFacts)
+}
+
+type blockingResourceTrustsProvider struct {
+ providers.StaticProvider
+}
+
+func (provider blockingResourceTrustsProvider) ResourceTrusts(_ context.Context, _ string, _ string) (providers.ResourceTrustsFacts, error) {
+ panic("resource-trusts should compose from source artifacts")
+}
+
+type blockingIdentityProvider struct {
+ providers.StaticProvider
+}
+
+func (provider blockingIdentityProvider) Principals(_ context.Context, _ string, _ string) (providers.PrincipalsFacts, error) {
+ panic("principals should compose from source artifacts")
+}
+
+func (provider blockingIdentityProvider) PrincipalsFromSources(ctx context.Context, tenant string, subscription string, rbacFacts providers.RBACFacts, whoamiFacts providers.WhoAmIFacts, managedIdentityFacts providers.ManagedIdentitiesFacts) (providers.PrincipalsFacts, error) {
+ return providers.PrincipalsFactsFromSources(tenant, subscription, rbacFacts, whoamiFacts, managedIdentityFacts), nil
+}
+
+func (provider blockingIdentityProvider) Permissions(_ context.Context, _ string, _ string) (providers.PermissionsFacts, error) {
+ panic("permissions should compose from source artifacts")
+}
+
+func (provider blockingIdentityProvider) PermissionsFromSources(ctx context.Context, tenant string, subscription string, rbacFacts providers.RBACFacts, whoamiFacts providers.WhoAmIFacts, managedIdentityFacts providers.ManagedIdentitiesFacts) (providers.PermissionsFacts, error) {
+ return providers.PermissionsFactsFromSources(tenant, subscription, rbacFacts, whoamiFacts, managedIdentityFacts), nil
+}
+
+type blockingPrivescProvider struct {
+ providers.StaticProvider
+}
+
+func (provider blockingPrivescProvider) Privesc(_ context.Context, _ string, _ string) (providers.PrivescFacts, error) {
+ panic("privesc should compose from source artifacts")
+}
+
+func (provider blockingPrivescProvider) PrivescFromSources(ctx context.Context, permissionsFacts providers.PermissionsFacts, principalsFacts providers.PrincipalsFacts, managedIdentityFacts providers.ManagedIdentitiesFacts, vmFacts providers.VMsFacts) (providers.PrivescFacts, error) {
+ return providers.PrivescFactsFromSources(permissionsFacts, principalsFacts, managedIdentityFacts, vmFacts), nil
+}
+
+func fixedArtifactTestTime() func() time.Time {
+ return func() time.Time {
+ return time.Date(2026, time.April, 25, 18, 0, 0, 0, time.UTC)
+ }
+}
+
+func writeCommandArtifact(t *testing.T, workspace string, command string, handler Handler) {
+ t.Helper()
+ writeCommandArtifactWithRequest(t, workspace, command, handler, Request{OutDir: workspace})
+}
+
+func writeCommandArtifactWithRequest(t *testing.T, workspace string, command string, handler Handler, request Request) {
+ t.Helper()
+ payload, err := handler(context.Background(), request)
+ if err != nil {
+ t.Fatalf("%s handler: %v", command, err)
+ }
+ if _, err := artifacts.Write(command, payload, workspace, models.RenderContext{
+ Tenant: request.Tenant,
+ Subscription: request.Subscription,
+ }); err != nil {
+ t.Fatalf("write %s artifact: %v", command, err)
+ }
+}
+
+func assertSessionArtifactCommands(t *testing.T, artifacts []models.SessionArtifact, expected ...string) {
+ t.Helper()
+ if len(artifacts) != len(expected) {
+ t.Fatalf("expected %d source artifact(s), got %d: %#v", len(expected), len(artifacts), artifacts)
+ }
+ seen := map[string]bool{}
+ for _, artifact := range artifacts {
+ seen[artifact.Command] = true
+ if !strings.Contains(artifact.Context, "same tenant") {
+ t.Fatalf("unexpected artifact context for %s: %q", artifact.Command, artifact.Context)
+ }
+ }
+ for _, command := range expected {
+ if !seen[command] {
+ t.Fatalf("missing source artifact %q in %#v", command, artifacts)
+ }
+ }
+}
diff --git a/internal/commands/path_masking_api_mgmt.go b/internal/commands/path_masking_api_mgmt.go
index aa64432..d81826d 100644
--- a/internal/commands/path_masking_api_mgmt.go
+++ b/internal/commands/path_masking_api_mgmt.go
@@ -28,10 +28,11 @@ func buildPathMaskingAPIMOutput(
contract contracts.PathMaskingSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- apiMgmtFuture := runGroupedCommandOutput[models.ApiMgmtOutput](group, ctx, request, apiMgmtHandler(provider, now), "api-mgmt")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "api-mgmt", "permissions", "rbac")
+ apiMgmtFuture := runHelperOutput[models.ApiMgmtOutput](group, ctx, request, apiMgmtHandler(provider, now), "api-mgmt", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- apiMgmt, err := apiMgmtFuture.wait()
+ apiMgmt, apiMgmtSource, err := apiMgmtFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -69,7 +70,7 @@ func buildPathMaskingAPIMOutput(
issues := familyIssues(apiMgmt.Issues, evidence)
return models.PathMaskingAPIMOutput{
- Metadata: scopedMetadata(now, request, firstNonEmpty(request.Tenant, stringPtrValue(apiMgmt.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)), firstNonEmpty(request.Subscription, stringPtrValue(apiMgmt.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)), "pathmasking"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, firstNonEmpty(request.Tenant, stringPtrValue(apiMgmt.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)), firstNonEmpty(request.Subscription, stringPtrValue(apiMgmt.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)), "pathmasking"), appendSessionArtifact(evidence.sessionArtifacts, apiMgmtSource)),
GroupedCommandName: "pathmasking",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/path_masking_logic_apps.go b/internal/commands/path_masking_logic_apps.go
index da8956e..cdaf1d6 100644
--- a/internal/commands/path_masking_logic_apps.go
+++ b/internal/commands/path_masking_logic_apps.go
@@ -28,10 +28,11 @@ func buildPathMaskingLogicAppsOutput(
contract contracts.PathMaskingSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- logicAppsFuture := runGroupedCommandOutput[models.LogicAppsOutput](group, ctx, request, logicAppsHandler(provider, now), "logic-apps")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "logic-apps", "permissions", "rbac")
+ logicAppsFuture := runHelperOutput[models.LogicAppsOutput](group, ctx, request, logicAppsHandler(provider, now), "logic-apps", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- logicApps, err := logicAppsFuture.wait()
+ logicApps, logicAppsSource, err := logicAppsFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -69,12 +70,15 @@ func buildPathMaskingLogicAppsOutput(
issues := familyIssues(logicApps.Issues, evidence)
return models.PathMaskingLogicAppsOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(logicApps.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(logicApps.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "pathmasking",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(logicApps.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(logicApps.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "pathmasking",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, logicAppsSource),
),
GroupedCommandName: "pathmasking",
Surface: contract.Name,
diff --git a/internal/commands/path_masking_relay.go b/internal/commands/path_masking_relay.go
index 71e3d1a..f3be06a 100644
--- a/internal/commands/path_masking_relay.go
+++ b/internal/commands/path_masking_relay.go
@@ -28,10 +28,11 @@ func buildPathMaskingRelayOutput(
contract contracts.PathMaskingSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- relayFuture := runGroupedCommandOutput[models.RelayOutput](group, ctx, request, relayHandler(provider, now), "relay")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "relay", "permissions", "rbac")
+ relayFuture := runHelperOutput[models.RelayOutput](group, ctx, request, relayHandler(provider, now), "relay", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- relay, err := relayFuture.wait()
+ relay, relaySource, err := relayFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -69,12 +70,15 @@ func buildPathMaskingRelayOutput(
issues := familyIssues(relay.Issues, evidence)
return models.PathMaskingRelayOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(relay.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(relay.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "pathmasking",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(relay.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(relay.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "pathmasking",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, relaySource),
),
GroupedCommandName: "pathmasking",
Surface: contract.Name,
diff --git a/internal/commands/permissions.go b/internal/commands/permissions.go
index 1d5db79..e8755dd 100644
--- a/internal/commands/permissions.go
+++ b/internal/commands/permissions.go
@@ -11,9 +11,13 @@ import (
"harrierops-azure/internal/providers"
)
+type permissionsSourceProvider interface {
+ PermissionsFromSources(context.Context, string, string, providers.RBACFacts, providers.WhoAmIFacts, providers.ManagedIdentitiesFacts) (providers.PermissionsFacts, error)
+}
+
func permissionsHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
- facts, err := provider.Permissions(ctx, request.Tenant, request.Subscription)
+ facts, sessionArtifacts, err := permissionsFacts(ctx, request, provider, now)
if err != nil {
return nil, err
}
@@ -24,13 +28,78 @@ func permissionsHandler(provider providers.Provider, now func() time.Time) Handl
}
return models.PermissionsOutput{
- Metadata: scopedMetadata(now, request, facts.TenantID, subscriptionID, "permissions"),
+ Metadata: withSessionArtifacts(
+ withScopedArtifactContext(
+ scopedMetadata(now, request, facts.TenantID, subscriptionID, "permissions"),
+ request,
+ facts.CurrentPrincipal,
+ facts.AuthMode,
+ facts.TokenSource,
+ ),
+ sessionArtifacts,
+ ),
Permissions: enrichPermissionRows(facts.Permissions, facts.Principals),
Issues: facts.Issues,
}, nil
}
}
+func permissionsFacts(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (providers.PermissionsFacts, []models.SessionArtifact, error) {
+ sourceProvider, ok := provider.(permissionsSourceProvider)
+ if !ok {
+ facts, err := provider.Permissions(ctx, request.Tenant, request.Subscription)
+ return facts, nil, err
+ }
+
+ group := newCommandOutputGroup(3)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "rbac", "whoami", "managed-identities")
+ rbacFuture := runHelperOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac", expected)
+ whoamiFuture := runHelperOutput[models.WhoAmIOutput](group, ctx, request, whoAmIHandler(provider, now), "whoami", expected)
+ managedIdentityFuture := runHelperOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities", expected)
+
+ rbac, rbacSource, err := rbacFuture.waitWithSource()
+ if err != nil {
+ return providers.PermissionsFacts{}, nil, err
+ }
+ whoami, whoamiSource, err := whoamiFuture.waitWithSource()
+ if err != nil {
+ return providers.PermissionsFacts{}, nil, err
+ }
+ managedIdentities, managedIdentitiesSource, err := managedIdentityFuture.waitWithSource()
+ if err != nil {
+ return providers.PermissionsFacts{}, nil, err
+ }
+
+ rbacFacts := rbacFactsFromOutput(rbac)
+ whoamiFacts := whoAmIFactsFromOutput(whoami)
+ managedIdentityFacts := managedIdentitiesFactsFromOutput(managedIdentities)
+ tenantID := firstNonEmpty(rbacFacts.TenantID, whoamiFacts.TenantID, managedIdentityFacts.TenantID)
+ subscriptionID := firstNonEmpty(rbacFacts.SubscriptionID, whoamiFacts.Subscription.ID, managedIdentityFacts.SubscriptionID)
+ facts, err := sourceProvider.PermissionsFromSources(
+ ctx,
+ tenantID,
+ subscriptionID,
+ rbacFacts,
+ whoamiFacts,
+ managedIdentityFacts,
+ )
+ if err != nil {
+ return providers.PermissionsFacts{}, nil, err
+ }
+
+ sessionArtifacts := []models.SessionArtifact{}
+ if rbacSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *rbacSource)
+ }
+ if whoamiSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *whoamiSource)
+ }
+ if managedIdentitiesSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *managedIdentitiesSource)
+ }
+ return facts, sessionArtifacts, nil
+}
+
type enrichedPermissionRow struct {
row models.PermissionRow
workloadPivotRank int
diff --git a/internal/commands/persistence.go b/internal/commands/persistence.go
index 05d7f70..1c0ebf5 100644
--- a/internal/commands/persistence.go
+++ b/internal/commands/persistence.go
@@ -105,19 +105,15 @@ func buildPersistenceAutomationOutput(
contract contracts.PersistenceSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- automationFuture := runGroupedCommandOutput[models.AutomationOutput](group, ctx, request, automationHandler(provider, now), "automation")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "automation", "permissions", "rbac")
+ automationFuture := runHelperOutput[models.AutomationOutput](group, ctx, request, automationHandler(provider, now), "automation", expected)
+ identityControlFutures := startIdentityControlFuturesWithExpected(group, ctx, provider, now, request, expected)
- automation, err := automationFuture.wait()
+ automation, automationSource, err := automationFuture.waitWithSource()
if err != nil {
return nil, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return nil, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return nil, err
}
@@ -125,15 +121,15 @@ func buildPersistenceAutomationOutput(
subscriptionID := firstNonEmpty(
request.Subscription,
stringPtrValue(automation.Metadata.SubscriptionID),
- stringPtrValue(permissions.Metadata.SubscriptionID),
+ stringPtrValue(identityControl.permissions.Metadata.SubscriptionID),
)
tenantID := firstNonEmpty(
request.Tenant,
stringPtrValue(automation.Metadata.TenantID),
- stringPtrValue(permissions.Metadata.TenantID),
+ stringPtrValue(identityControl.permissions.Metadata.TenantID),
)
- evidence := buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments)
+ evidence := identityControl.evidence
accounts := make([]models.PersistenceAutomationAccount, 0, len(automation.AutomationAccounts))
for _, account := range automation.AutomationAccounts {
@@ -181,11 +177,11 @@ func buildPersistenceAutomationOutput(
}
issues := append([]models.Issue{}, automation.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
return models.PersistenceAutomationOutput{
- Metadata: scopedMetadata(now, request, tenantID, subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, tenantID, subscriptionID, "persistence"), appendSessionArtifact(identityControl.sessionArtifacts, automationSource)),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_app_service.go b/internal/commands/persistence_app_service.go
index 8c58215..dd27682 100644
--- a/internal/commands/persistence_app_service.go
+++ b/internal/commands/persistence_app_service.go
@@ -33,13 +33,13 @@ func buildPersistenceAppServiceOutput(
contract contracts.PersistenceSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- appServicesFuture := runGroupedCommandOutput[models.AppServicesOutput](group, ctx, request, appServicesHandler(provider, now), "app-services")
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "app-services", "permissions", "rbac")
+ appServicesFuture := runHelperOutput[models.AppServicesOutput](group, ctx, request, appServicesHandler(provider, now), "app-services", expected)
envVarsFuture := runGroupedCommandOutput[models.EnvVarsOutput](group, ctx, request, envVarsHandler(provider, now), "env-vars")
managedIdentitiesFuture := runGroupedCommandOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ identityControlFutures := startIdentityControlFuturesWithExpected(group, ctx, provider, now, request, expected)
- appServices, err := appServicesFuture.wait()
+ appServices, appServicesSource, err := appServicesFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -51,11 +51,7 @@ func buildPersistenceAppServiceOutput(
if err != nil {
return nil, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return nil, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return nil, err
}
@@ -63,12 +59,12 @@ func buildPersistenceAppServiceOutput(
subscriptionID := firstNonEmpty(
request.Subscription,
stringPtrValue(appServices.Metadata.SubscriptionID),
- stringPtrValue(permissions.Metadata.SubscriptionID),
+ stringPtrValue(identityControl.permissions.Metadata.SubscriptionID),
)
tenantID := firstNonEmpty(
request.Tenant,
stringPtrValue(appServices.Metadata.TenantID),
- stringPtrValue(permissions.Metadata.TenantID),
+ stringPtrValue(identityControl.permissions.Metadata.TenantID),
)
envVarsByAsset := make(map[string][]models.EnvVarSummary)
@@ -79,7 +75,7 @@ func buildPersistenceAppServiceOutput(
envVarsByAsset[item.AssetID] = append(envVarsByAsset[item.AssetID], item)
}
- evidence := buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments)
+ evidence := identityControl.evidence
managedIdentitiesByAttachment := persistenceAppServiceManagedIdentitiesByAttachment(managedIdentities.Identities)
apps := sortedByLess(appServices.AppServices, appServiceLess)
@@ -141,11 +137,11 @@ func buildPersistenceAppServiceOutput(
issues := append([]models.Issue{}, appServices.Issues...)
issues = append(issues, envVars.Issues...)
issues = append(issues, managedIdentities.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
return models.PersistenceAppServiceOutput{
- Metadata: scopedMetadata(now, request, tenantID, subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, tenantID, subscriptionID, "persistence"), appendSessionArtifact(identityControl.sessionArtifacts, appServicesSource)),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_azure_ml.go b/internal/commands/persistence_azure_ml.go
index 7d33ecc..77a8430 100644
--- a/internal/commands/persistence_azure_ml.go
+++ b/internal/commands/persistence_azure_ml.go
@@ -36,8 +36,7 @@ func buildPersistenceAzureMLOutput(
group := newCommandOutputGroup(chainsFanoutLimit)
azureMLFuture := runGroupedCommandOutput[models.AzureMLOutput](group, ctx, request, azureMLHandler(provider, now), "azure-ml")
managedIdentitiesFuture := runGroupedCommandOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ identityControlFutures := startIdentityControlFutures(group, ctx, provider, now, request)
azureML, err := azureMLFuture.wait()
if err != nil {
@@ -47,11 +46,7 @@ func buildPersistenceAzureMLOutput(
if err != nil {
return nil, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return nil, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return nil, err
}
@@ -59,15 +54,15 @@ func buildPersistenceAzureMLOutput(
subscriptionID := firstNonEmpty(
request.Subscription,
stringPtrValue(azureML.Metadata.SubscriptionID),
- stringPtrValue(permissions.Metadata.SubscriptionID),
+ stringPtrValue(identityControl.permissions.Metadata.SubscriptionID),
)
tenantID := firstNonEmpty(
request.Tenant,
stringPtrValue(azureML.Metadata.TenantID),
- stringPtrValue(permissions.Metadata.TenantID),
+ stringPtrValue(identityControl.permissions.Metadata.TenantID),
)
- evidence := buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments)
+ evidence := identityControl.evidence
managedIdentitiesByAttachment := persistenceAzureMLManagedIdentitiesByAttachment(managedIdentities.Identities)
workspaces := sortedByLess(azureML.Workspaces, persistenceAzureMLWorkspaceLess)
@@ -117,11 +112,11 @@ func buildPersistenceAzureMLOutput(
issues := append([]models.Issue{}, azureML.Issues...)
issues = append(issues, managedIdentities.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
return models.PersistenceAzureMLOutput{
- Metadata: scopedMetadata(now, request, tenantID, subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, tenantID, subscriptionID, "persistence"), identityControl.sessionArtifacts),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_container_apps_jobs.go b/internal/commands/persistence_container_apps_jobs.go
index 021955d..800c48e 100644
--- a/internal/commands/persistence_container_apps_jobs.go
+++ b/internal/commands/persistence_container_apps_jobs.go
@@ -79,7 +79,7 @@ func buildPersistenceContainerAppsJobsOutput(
}
return models.PersistenceContainerAppsJobsOutput{
- Metadata: scopedMetadata(now, request, backing.tenantID, backing.subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, backing.tenantID, backing.subscriptionID, "persistence"), backing.sessionArtifacts),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_functions.go b/internal/commands/persistence_functions.go
index 0547dd6..69b8e87 100644
--- a/internal/commands/persistence_functions.go
+++ b/internal/commands/persistence_functions.go
@@ -35,18 +35,13 @@ func buildPersistenceFunctionsOutput(
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
functionsFuture := runGroupedCommandOutput[models.FunctionsOutput](group, ctx, request, functionsHandler(provider, now), "functions")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ identityControlFutures := startIdentityControlFutures(group, ctx, provider, now, request)
functions, err := functionsFuture.wait()
if err != nil {
return nil, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return nil, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return nil, err
}
@@ -54,15 +49,15 @@ func buildPersistenceFunctionsOutput(
subscriptionID := firstNonEmpty(
request.Subscription,
stringPtrValue(functions.Metadata.SubscriptionID),
- stringPtrValue(permissions.Metadata.SubscriptionID),
+ stringPtrValue(identityControl.permissions.Metadata.SubscriptionID),
)
tenantID := firstNonEmpty(
request.Tenant,
stringPtrValue(functions.Metadata.TenantID),
- stringPtrValue(permissions.Metadata.TenantID),
+ stringPtrValue(identityControl.permissions.Metadata.TenantID),
)
- evidence := buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments)
+ evidence := identityControl.evidence
functionApps := sortedByLess(functions.FunctionApps, functionAppLess)
rows := make([]models.PersistenceFunctionApp, 0, len(functionApps))
@@ -105,11 +100,11 @@ func buildPersistenceFunctionsOutput(
}
issues := append([]models.Issue{}, functions.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
return models.PersistenceFunctionsOutput{
- Metadata: scopedMetadata(now, request, tenantID, subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, tenantID, subscriptionID, "persistence"), identityControl.sessionArtifacts),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_logic_apps.go b/internal/commands/persistence_logic_apps.go
index ea859d0..0442ed9 100644
--- a/internal/commands/persistence_logic_apps.go
+++ b/internal/commands/persistence_logic_apps.go
@@ -34,19 +34,15 @@ func buildPersistenceLogicAppsOutput(
contract contracts.PersistenceSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- logicAppsFuture := runGroupedCommandOutput[models.LogicAppsOutput](group, ctx, request, logicAppsHandler(provider, now), "logic-apps")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "logic-apps", "permissions", "rbac")
+ logicAppsFuture := runHelperOutput[models.LogicAppsOutput](group, ctx, request, logicAppsHandler(provider, now), "logic-apps", expected)
+ identityControlFutures := startIdentityControlFuturesWithExpected(group, ctx, provider, now, request, expected)
- logicApps, err := logicAppsFuture.wait()
+ logicApps, logicAppsSource, err := logicAppsFuture.waitWithSource()
if err != nil {
return nil, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return nil, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return nil, err
}
@@ -54,15 +50,15 @@ func buildPersistenceLogicAppsOutput(
subscriptionID := firstNonEmpty(
request.Subscription,
stringPtrValue(logicApps.Metadata.SubscriptionID),
- stringPtrValue(permissions.Metadata.SubscriptionID),
+ stringPtrValue(identityControl.permissions.Metadata.SubscriptionID),
)
tenantID := firstNonEmpty(
request.Tenant,
stringPtrValue(logicApps.Metadata.TenantID),
- stringPtrValue(permissions.Metadata.TenantID),
+ stringPtrValue(identityControl.permissions.Metadata.TenantID),
)
- evidence := buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments)
+ evidence := identityControl.evidence
workflows := sortedByLess(logicApps.Workflows, logicAppLess)
rows := make([]models.PersistenceLogicAppWorkflow, 0, len(workflows))
@@ -102,11 +98,11 @@ func buildPersistenceLogicAppsOutput(
}
issues := append([]models.Issue{}, logicApps.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
return models.PersistenceLogicAppsOutput{
- Metadata: scopedMetadata(now, request, tenantID, subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, tenantID, subscriptionID, "persistence"), appendSessionArtifact(identityControl.sessionArtifacts, logicAppsSource)),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_shared.go b/internal/commands/persistence_shared.go
index d65436c..117feb1 100644
--- a/internal/commands/persistence_shared.go
+++ b/internal/commands/persistence_shared.go
@@ -6,6 +6,7 @@ import (
"strings"
"time"
+ "harrierops-azure/internal/artifacts"
"harrierops-azure/internal/models"
"harrierops-azure/internal/providers"
)
@@ -26,6 +27,7 @@ type persistenceBackingData struct {
permissions models.PermissionsOutput
rbac models.RbacOutput
evidence persistencePrincipalEvidence
+ sessionArtifacts []models.SessionArtifact
tenantID string
subscriptionID string
issues []models.Issue
@@ -37,11 +39,23 @@ func startPersistenceBackingFutures(
provider providers.Provider,
now func() time.Time,
request Request,
+) persistenceBackingFutures {
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "permissions", "rbac")
+ return startPersistenceBackingFuturesWithExpected(group, ctx, provider, now, request, expected)
+}
+
+func startPersistenceBackingFuturesWithExpected(
+ group commandOutputGroup,
+ ctx context.Context,
+ provider providers.Provider,
+ now func() time.Time,
+ request Request,
+ expected map[string]artifacts.ExpectedSession,
) persistenceBackingFutures {
return persistenceBackingFutures{
managedIdentities: runGroupedCommandOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities"),
- permissions: runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions"),
- rbac: runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac"),
+ permissions: runPermissionsOutput(group, ctx, request, provider, now, expected),
+ rbac: runRBACOutput(group, ctx, request, provider, now, expected),
}
}
@@ -55,14 +69,21 @@ func (futures persistenceBackingFutures) wait(
if err != nil {
return persistenceBackingData{}, err
}
- permissions, err := futures.permissions.wait()
+ permissions, permissionsSource, err := futures.permissions.waitWithSource()
if err != nil {
return persistenceBackingData{}, err
}
- rbac, err := futures.rbac.wait()
+ rbac, rbacSource, err := futures.rbac.waitWithSource()
if err != nil {
return persistenceBackingData{}, err
}
+ sessionArtifacts := []models.SessionArtifact{}
+ if permissionsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *permissionsSource)
+ }
+ if rbacSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *rbacSource)
+ }
issues := append([]models.Issue{}, primaryIssues...)
issues = append(issues, managedIdentities.Issues...)
@@ -74,6 +95,7 @@ func (futures persistenceBackingFutures) wait(
permissions: permissions,
rbac: rbac,
evidence: buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments),
+ sessionArtifacts: sessionArtifacts,
tenantID: firstNonEmpty(
request.Tenant,
stringPtrValue(primaryTenantID),
diff --git a/internal/commands/persistence_vm_extensions.go b/internal/commands/persistence_vm_extensions.go
index 4f43279..9f7e6b8 100644
--- a/internal/commands/persistence_vm_extensions.go
+++ b/internal/commands/persistence_vm_extensions.go
@@ -37,10 +37,11 @@ func buildPersistenceVMExtensionsOutput(
contract contracts.PersistenceSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- extensionsFuture := runGroupedCommandOutput[models.VMExtensionsOutput](group, ctx, request, vmExtensionsHandler(provider, now), "vm-extensions")
- backingFutures := startPersistenceBackingFutures(group, ctx, provider, now, request)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "vm-extensions", "permissions", "rbac")
+ extensionsFuture := runHelperOutput[models.VMExtensionsOutput](group, ctx, request, vmExtensionsHandler(provider, now), "vm-extensions", expected)
+ backingFutures := startPersistenceBackingFuturesWithExpected(group, ctx, provider, now, request, expected)
- extensions, err := extensionsFuture.wait()
+ extensions, extensionsSource, err := extensionsFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -81,7 +82,7 @@ func buildPersistenceVMExtensionsOutput(
}
return models.PersistenceVMExtensionsOutput{
- Metadata: scopedMetadata(now, request, backing.tenantID, backing.subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, backing.tenantID, backing.subscriptionID, "persistence"), appendSessionArtifact(backing.sessionArtifacts, extensionsSource)),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/persistence_webjobs.go b/internal/commands/persistence_webjobs.go
index 6fb211a..ebd21c5 100644
--- a/internal/commands/persistence_webjobs.go
+++ b/internal/commands/persistence_webjobs.go
@@ -34,16 +34,16 @@ func buildPersistenceWebJobsOutput(
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
webJobsFuture := runGroupedCommandOutput[models.WebJobsOutput](group, ctx, request, webJobsHandler(provider, now), "webjobs")
- appServicesFuture := runGroupedCommandOutput[models.AppServicesOutput](group, ctx, request, appServicesHandler(provider, now), "app-services")
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "app-services", "permissions", "rbac")
+ appServicesFuture := runHelperOutput[models.AppServicesOutput](group, ctx, request, appServicesHandler(provider, now), "app-services", expected)
managedIdentitiesFuture := runGroupedCommandOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities")
- permissionsFuture := runGroupedCommandOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions")
- rbacFuture := runGroupedCommandOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac")
+ identityControlFutures := startIdentityControlFuturesWithExpected(group, ctx, provider, now, request, expected)
webJobs, err := webJobsFuture.wait()
if err != nil {
return nil, err
}
- appServices, err := appServicesFuture.wait()
+ appServices, appServicesSource, err := appServicesFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -51,11 +51,7 @@ func buildPersistenceWebJobsOutput(
if err != nil {
return nil, err
}
- permissions, err := permissionsFuture.wait()
- if err != nil {
- return nil, err
- }
- rbac, err := rbacFuture.wait()
+ identityControl, err := identityControlFutures.wait()
if err != nil {
return nil, err
}
@@ -64,13 +60,13 @@ func buildPersistenceWebJobsOutput(
request.Subscription,
stringPtrValue(webJobs.Metadata.SubscriptionID),
stringPtrValue(appServices.Metadata.SubscriptionID),
- stringPtrValue(permissions.Metadata.SubscriptionID),
+ stringPtrValue(identityControl.permissions.Metadata.SubscriptionID),
)
tenantID := firstNonEmpty(
request.Tenant,
stringPtrValue(webJobs.Metadata.TenantID),
stringPtrValue(appServices.Metadata.TenantID),
- stringPtrValue(permissions.Metadata.TenantID),
+ stringPtrValue(identityControl.permissions.Metadata.TenantID),
)
appsByID := make(map[string]models.AppServiceAsset, len(appServices.AppServices))
@@ -81,7 +77,7 @@ func buildPersistenceWebJobsOutput(
appsByID[app.ID] = app
}
- evidence := buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments)
+ evidence := identityControl.evidence
managedIdentitiesByAttachment := persistenceAppServiceManagedIdentitiesByAttachment(managedIdentities.Identities)
items := sortedByLess(webJobs.WebJobs, webJobLess)
@@ -141,11 +137,11 @@ func buildPersistenceWebJobsOutput(
issues := append([]models.Issue{}, webJobs.Issues...)
issues = append(issues, appServices.Issues...)
issues = append(issues, managedIdentities.Issues...)
- issues = append(issues, permissions.Issues...)
- issues = append(issues, rbac.Issues...)
+ issues = append(issues, identityControl.permissions.Issues...)
+ issues = append(issues, identityControl.rbac.Issues...)
return models.PersistenceWebJobsOutput{
- Metadata: scopedMetadata(now, request, tenantID, subscriptionID, "persistence"),
+ Metadata: withSessionArtifacts(scopedMetadata(now, request, tenantID, subscriptionID, "persistence"), appendSessionArtifact(identityControl.sessionArtifacts, appServicesSource)),
GroupedCommandName: "persistence",
Surface: contract.Name,
InputMode: "live",
diff --git a/internal/commands/principals.go b/internal/commands/principals.go
index 1648063..75997f6 100644
--- a/internal/commands/principals.go
+++ b/internal/commands/principals.go
@@ -9,9 +9,13 @@ import (
"harrierops-azure/internal/providers"
)
+type principalsSourceProvider interface {
+ PrincipalsFromSources(context.Context, string, string, providers.RBACFacts, providers.WhoAmIFacts, providers.ManagedIdentitiesFacts) (providers.PrincipalsFacts, error)
+}
+
func principalsHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
- facts, err := provider.Principals(ctx, request.Tenant, request.Subscription)
+ facts, sessionArtifacts, err := principalsFacts(ctx, request, provider, now)
if err != nil {
return nil, err
}
@@ -23,17 +27,110 @@ func principalsHandler(provider providers.Provider, now func() time.Time) Handle
return models.PrincipalsOutput{
Issues: facts.Issues,
- Metadata: models.PrincipalsMetadata{
- AuthMode: nil,
+ Metadata: withPrincipalsSessionArtifacts(withPrincipalsArtifactContext(models.PrincipalsMetadata{
+ AuthMode: models.StringPtr(facts.AuthMode),
Command: "principals",
DevOpsOrganization: models.StringPtr(request.DevOpsOrganization),
GeneratedAt: now().UTC().Format(time.RFC3339),
SchemaVersion: contracts.AzureFoxSchemaVersion,
SubscriptionID: models.StringPtr(subscriptionID),
TenantID: models.StringPtr(facts.TenantID),
- TokenSource: nil,
- },
+ TokenSource: models.StringPtr(facts.TokenSource),
+ }, request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource), sessionArtifacts),
Principals: append([]models.PrincipalSummary{}, facts.Principals...),
}, nil
}
}
+
+func principalsFacts(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (providers.PrincipalsFacts, []models.SessionArtifact, error) {
+ sourceProvider, ok := provider.(principalsSourceProvider)
+ if !ok {
+ facts, err := provider.Principals(ctx, request.Tenant, request.Subscription)
+ return facts, nil, err
+ }
+
+ group := newCommandOutputGroup(3)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "rbac", "whoami", "managed-identities")
+ rbacFuture := runHelperOutput[models.RbacOutput](group, ctx, request, rbacHandler(provider, now), "rbac", expected)
+ whoamiFuture := runHelperOutput[models.WhoAmIOutput](group, ctx, request, whoAmIHandler(provider, now), "whoami", expected)
+ managedIdentityFuture := runHelperOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities", expected)
+
+ rbac, rbacSource, err := rbacFuture.waitWithSource()
+ if err != nil {
+ return providers.PrincipalsFacts{}, nil, err
+ }
+ whoami, whoamiSource, err := whoamiFuture.waitWithSource()
+ if err != nil {
+ return providers.PrincipalsFacts{}, nil, err
+ }
+ managedIdentities, managedIdentitiesSource, err := managedIdentityFuture.waitWithSource()
+ if err != nil {
+ return providers.PrincipalsFacts{}, nil, err
+ }
+
+ rbacFacts := rbacFactsFromOutput(rbac)
+ whoamiFacts := whoAmIFactsFromOutput(whoami)
+ managedIdentityFacts := managedIdentitiesFactsFromOutput(managedIdentities)
+ tenantID := firstNonEmpty(rbacFacts.TenantID, whoamiFacts.TenantID, managedIdentityFacts.TenantID)
+ subscriptionID := firstNonEmpty(rbacFacts.SubscriptionID, whoamiFacts.Subscription.ID, managedIdentityFacts.SubscriptionID)
+ facts, err := sourceProvider.PrincipalsFromSources(
+ ctx,
+ tenantID,
+ subscriptionID,
+ rbacFacts,
+ whoamiFacts,
+ managedIdentityFacts,
+ )
+ if err != nil {
+ return providers.PrincipalsFacts{}, nil, err
+ }
+
+ sessionArtifacts := []models.SessionArtifact{}
+ if rbacSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *rbacSource)
+ }
+ if whoamiSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *whoamiSource)
+ }
+ if managedIdentitiesSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *managedIdentitiesSource)
+ }
+ return facts, sessionArtifacts, nil
+}
+
+func whoAmIFactsFromOutput(output models.WhoAmIOutput) providers.WhoAmIFacts {
+ return providers.WhoAmIFacts{
+ TenantID: output.TenantID,
+ Subscription: output.Subscription,
+ Principal: output.Principal,
+ EffectiveScopes: append([]models.ScopeRef{}, output.EffectiveScopes...),
+ TokenSource: stringPtrValue(output.Metadata.TokenSource),
+ AuthMode: stringPtrValue(output.Metadata.AuthMode),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func managedIdentitiesFactsFromOutput(output models.ManagedIdentitiesOutput) providers.ManagedIdentitiesFacts {
+ identity := artifactIdentityFactsFromScopedMetadata(output.Metadata)
+ return providers.ManagedIdentitiesFacts{
+ ArtifactIdentityFacts: identity,
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ Identities: append([]models.ManagedIdentity{}, output.Identities...),
+ RoleAssignments: append([]models.ManagedIdentityRoleAssignment{}, output.RoleAssignments...),
+ Findings: append([]models.ManagedIdentityFinding{}, output.Findings...),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func artifactIdentityFactsFromScopedMetadata(metadata models.ScopedCommandMetadata) providers.ArtifactIdentityFacts {
+ return artifactIdentityFactsFromContext(metadata.ArtifactContext, metadata.AuthMode, metadata.TokenSource)
+}
+
+func withPrincipalsSessionArtifacts(metadata models.PrincipalsMetadata, artifacts []models.SessionArtifact) models.PrincipalsMetadata {
+ if len(artifacts) == 0 {
+ return metadata
+ }
+ metadata.SessionArtifacts = append([]models.SessionArtifact{}, artifacts...)
+ return metadata
+}
diff --git a/internal/commands/privesc.go b/internal/commands/privesc.go
index b882585..12c7082 100644
--- a/internal/commands/privesc.go
+++ b/internal/commands/privesc.go
@@ -10,9 +10,13 @@ import (
"harrierops-azure/internal/providers"
)
+type privescSourceProvider interface {
+ PrivescFromSources(context.Context, providers.PermissionsFacts, providers.PrincipalsFacts, providers.ManagedIdentitiesFacts, providers.VMsFacts) (providers.PrivescFacts, error)
+}
+
func privescHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
- facts, err := provider.Privesc(ctx, request.Tenant, request.Subscription)
+ facts, sessionArtifacts, err := privescFacts(ctx, request, provider, now)
if err != nil {
return nil, err
}
@@ -24,7 +28,7 @@ func privescHandler(provider providers.Provider, now func() time.Time) Handler {
return models.PrivescOutput{
Issues: facts.Issues,
- Metadata: models.PrincipalsMetadata{
+ Metadata: withPrincipalsSessionArtifacts(models.PrincipalsMetadata{
AuthMode: nil,
Command: "privesc",
DevOpsOrganization: models.StringPtr(request.DevOpsOrganization),
@@ -33,12 +37,141 @@ func privescHandler(provider providers.Provider, now func() time.Time) Handler {
SubscriptionID: models.StringPtr(facts.SubscriptionID),
TenantID: models.StringPtr(facts.TenantID),
TokenSource: nil,
- },
+ }, sessionArtifacts),
Paths: paths,
}, nil
}
}
+func privescFacts(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (providers.PrivescFacts, []models.SessionArtifact, error) {
+ sourceProvider, ok := provider.(privescSourceProvider)
+ if !ok {
+ facts, err := provider.Privesc(ctx, request.Tenant, request.Subscription)
+ return facts, nil, err
+ }
+
+ group := newCommandOutputGroup(4)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "permissions", "principals", "managed-identities", "vms")
+ permissionsFuture := runHelperOutput[models.PermissionsOutput](group, ctx, request, permissionsHandler(provider, now), "permissions", expected)
+ principalsFuture := runHelperOutput[models.PrincipalsOutput](group, ctx, request, principalsHandler(provider, now), "principals", expected)
+ managedIdentitiesFuture := runHelperOutput[models.ManagedIdentitiesOutput](group, ctx, request, managedIdentitiesHandler(provider, now), "managed-identities", expected)
+ vmsFuture := runHelperOutput[models.VmsOutput](group, ctx, request, vmsHandler(provider, now), "vms", expected)
+
+ permissions, permissionsSource, err := permissionsFuture.waitWithSource()
+ if err != nil {
+ return providers.PrivescFacts{}, nil, err
+ }
+ principals, principalsSource, err := principalsFuture.waitWithSource()
+ if err != nil {
+ return providers.PrivescFacts{}, nil, err
+ }
+ managedIdentities, managedIdentitiesSource, err := managedIdentitiesFuture.waitWithSource()
+ if err != nil {
+ return providers.PrivescFacts{}, nil, err
+ }
+ vms, vmsSource, err := vmsFuture.waitWithSource()
+ if err != nil {
+ return providers.PrivescFacts{}, nil, err
+ }
+
+ facts, err := sourceProvider.PrivescFromSources(
+ ctx,
+ permissionsFactsFromOutput(permissions),
+ principalsFactsFromOutput(principals),
+ managedIdentitiesFactsFromOutput(managedIdentities),
+ vmsFactsFromOutput(vms),
+ )
+ if err != nil {
+ return providers.PrivescFacts{}, nil, err
+ }
+
+ sessionArtifacts := []models.SessionArtifact{}
+ if permissionsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *permissionsSource)
+ }
+ if principalsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *principalsSource)
+ }
+ if managedIdentitiesSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *managedIdentitiesSource)
+ }
+ if vmsSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *vmsSource)
+ }
+ return facts, sessionArtifacts, nil
+}
+
+func permissionsFactsFromOutput(output models.PermissionsOutput) providers.PermissionsFacts {
+ identity := artifactIdentityFactsFromScopedMetadata(output.Metadata)
+ return providers.PermissionsFacts{
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ CurrentPrincipal: identity.CurrentPrincipal,
+ TokenSource: identity.TokenSource,
+ AuthMode: identity.AuthMode,
+ Permissions: permissionFactsFromRows(output.Permissions),
+ Principals: permissionPrincipalFactsFromRows(output.Permissions),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func principalsFactsFromOutput(output models.PrincipalsOutput) providers.PrincipalsFacts {
+ identity := artifactIdentityFactsFromPrincipalsMetadata(output.Metadata)
+ return providers.PrincipalsFacts{
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ CurrentPrincipal: identity.CurrentPrincipal,
+ TokenSource: identity.TokenSource,
+ AuthMode: identity.AuthMode,
+ Principals: append([]models.PrincipalSummary{}, output.Principals...),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func vmsFactsFromOutput(output models.VmsOutput) providers.VMsFacts {
+ identity := artifactIdentityFactsFromMetadata(output.Metadata)
+ return providers.VMsFacts{
+ ArtifactIdentityFacts: identity,
+ TenantID: stringPtrValue(output.Metadata.TenantID),
+ SubscriptionID: stringPtrValue(output.Metadata.SubscriptionID),
+ VMAssets: append([]models.VmAsset{}, output.VMAssets...),
+ Issues: append([]models.Issue{}, output.Issues...),
+ }
+}
+
+func permissionFactsFromRows(rows []models.PermissionRow) []providers.PermissionFact {
+ facts := make([]providers.PermissionFact, 0, len(rows))
+ for _, row := range rows {
+ facts = append(facts, providers.PermissionFact{
+ PrincipalID: row.PrincipalID,
+ DisplayName: row.DisplayName,
+ PrincipalType: row.PrincipalType,
+ HighImpactRoles: append([]string{}, row.HighImpactRoles...),
+ AllRoleNames: append([]string{}, row.AllRoleNames...),
+ RoleAssignmentCount: row.RoleAssignmentCount,
+ ScopeCount: row.ScopeCount,
+ ScopeIDs: append([]string{}, row.ScopeIDs...),
+ Privileged: row.Privileged,
+ IsCurrentIdentity: row.IsCurrentIdentity,
+ })
+ }
+ return facts
+}
+
+func permissionPrincipalFactsFromRows(rows []models.PermissionRow) []providers.PermissionPrincipalFact {
+ facts := make([]providers.PermissionPrincipalFact, 0, len(rows))
+ for _, row := range rows {
+ facts = append(facts, providers.PermissionPrincipalFact{
+ ID: row.PrincipalID,
+ })
+ }
+ return facts
+}
+
+func artifactIdentityFactsFromPrincipalsMetadata(metadata models.PrincipalsMetadata) providers.ArtifactIdentityFacts {
+ return artifactIdentityFactsFromContext(metadata.ArtifactContext, metadata.AuthMode, metadata.TokenSource)
+}
+
func privescArtifactTarget(path models.PrivescPathSummary) string {
if path.CurrentIdentity {
return "current foothold (" + privescArtifactPrincipalType(path.PrincipalType) + ")"
diff --git a/internal/commands/rbac.go b/internal/commands/rbac.go
index 83b7858..f2763a9 100644
--- a/internal/commands/rbac.go
+++ b/internal/commands/rbac.go
@@ -15,7 +15,7 @@ func rbacHandler(provider providers.Provider, now func() time.Time) Handler {
return nil, err
}
- subscriptionID := ""
+ subscriptionID := facts.SubscriptionID
if len(facts.Scopes) > 0 {
subscriptionID = facts.Scopes[0].ID
}
@@ -24,8 +24,14 @@ func rbacHandler(provider providers.Provider, now func() time.Time) Handler {
}
return models.RbacOutput{
- Issues: facts.Issues,
- Metadata: commandMetadata("rbac", now, request, facts.TenantID, subscriptionIDForMetadata(subscriptionID), ""),
+ Issues: facts.Issues,
+ Metadata: withArtifactContext(
+ commandMetadata("rbac", now, request, facts.TenantID, subscriptionIDForMetadata(subscriptionID), facts.TokenSource),
+ request,
+ facts.CurrentPrincipal,
+ facts.AuthMode,
+ facts.TokenSource,
+ ),
Principals: facts.Principals,
RoleAssignments: facts.RoleAssignments,
Scopes: facts.Scopes,
diff --git a/internal/commands/relay.go b/internal/commands/relay.go
index cdd0233..dd1436b 100644
--- a/internal/commands/relay.go
+++ b/internal/commands/relay.go
@@ -28,7 +28,7 @@ func relayHandler(provider providers.Provider, now func() time.Time) Handler {
return models.RelayOutput{
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("relay", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("relay", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
Namespaces: namespaces,
}, nil
}
diff --git a/internal/commands/resource_hijacking_api_mgmt.go b/internal/commands/resource_hijacking_api_mgmt.go
index 32b6139..19915f7 100644
--- a/internal/commands/resource_hijacking_api_mgmt.go
+++ b/internal/commands/resource_hijacking_api_mgmt.go
@@ -28,10 +28,11 @@ func buildResourceHijackingAPIMOutput(
contract contracts.ResourceHijackingSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- apiMgmtFuture := runGroupedCommandOutput[models.ApiMgmtOutput](group, ctx, request, apiMgmtHandler(provider, now), "api-mgmt")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "api-mgmt", "permissions", "rbac")
+ apiMgmtFuture := runHelperOutput[models.ApiMgmtOutput](group, ctx, request, apiMgmtHandler(provider, now), "api-mgmt", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- apiMgmt, err := apiMgmtFuture.wait()
+ apiMgmt, apiMgmtSource, err := apiMgmtFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -69,12 +70,15 @@ func buildResourceHijackingAPIMOutput(
issues := familyIssues(apiMgmt.Issues, evidence)
return models.ResourceHijackingAPIMOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(apiMgmt.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(apiMgmt.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "resourcehijacking",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(apiMgmt.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(apiMgmt.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "resourcehijacking",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, apiMgmtSource),
),
GroupedCommandName: "resourcehijacking",
Surface: contract.Name,
diff --git a/internal/commands/resource_hijacking_automation.go b/internal/commands/resource_hijacking_automation.go
index 94add88..1204bb3 100644
--- a/internal/commands/resource_hijacking_automation.go
+++ b/internal/commands/resource_hijacking_automation.go
@@ -28,10 +28,11 @@ func buildResourceHijackingAutomationOutput(
contract contracts.ResourceHijackingSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- automationFuture := runGroupedCommandOutput[models.AutomationOutput](group, ctx, request, automationHandler(provider, now), "automation")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "automation", "permissions", "rbac")
+ automationFuture := runHelperOutput[models.AutomationOutput](group, ctx, request, automationHandler(provider, now), "automation", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- automation, err := automationFuture.wait()
+ automation, automationSource, err := automationFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -69,12 +70,15 @@ func buildResourceHijackingAutomationOutput(
issues := familyIssues(automation.Issues, evidence)
return models.ResourceHijackingAutomationOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(automation.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(automation.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "resourcehijacking",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(automation.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(automation.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "resourcehijacking",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, automationSource),
),
GroupedCommandName: "resourcehijacking",
Surface: contract.Name,
diff --git a/internal/commands/resource_hijacking_logic_apps.go b/internal/commands/resource_hijacking_logic_apps.go
index efe1272..c3af132 100644
--- a/internal/commands/resource_hijacking_logic_apps.go
+++ b/internal/commands/resource_hijacking_logic_apps.go
@@ -28,10 +28,11 @@ func buildResourceHijackingLogicAppsOutput(
contract contracts.ResourceHijackingSurfaceContract,
) (any, error) {
group := newCommandOutputGroup(chainsFanoutLimit)
- logicAppsFuture := runGroupedCommandOutput[models.LogicAppsOutput](group, ctx, request, logicAppsHandler(provider, now), "logic-apps")
- evidenceFutures := runFamilyEvidence(group, ctx, request, provider, now)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "logic-apps", "permissions", "rbac")
+ logicAppsFuture := runHelperOutput[models.LogicAppsOutput](group, ctx, request, logicAppsHandler(provider, now), "logic-apps", expected)
+ evidenceFutures := runFamilyEvidenceWithExpected(group, ctx, request, provider, now, expected)
- logicApps, err := logicAppsFuture.wait()
+ logicApps, logicAppsSource, err := logicAppsFuture.waitWithSource()
if err != nil {
return nil, err
}
@@ -69,12 +70,15 @@ func buildResourceHijackingLogicAppsOutput(
issues := familyIssues(logicApps.Issues, evidence)
return models.ResourceHijackingLogicAppsOutput{
- Metadata: scopedMetadata(
- now,
- request,
- firstNonEmpty(request.Tenant, stringPtrValue(logicApps.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
- firstNonEmpty(request.Subscription, stringPtrValue(logicApps.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
- "resourcehijacking",
+ Metadata: withSessionArtifacts(
+ scopedMetadata(
+ now,
+ request,
+ firstNonEmpty(request.Tenant, stringPtrValue(logicApps.Metadata.TenantID), stringPtrValue(evidence.permissions.Metadata.TenantID)),
+ firstNonEmpty(request.Subscription, stringPtrValue(logicApps.Metadata.SubscriptionID), stringPtrValue(evidence.permissions.Metadata.SubscriptionID)),
+ "resourcehijacking",
+ ),
+ appendSessionArtifact(evidence.sessionArtifacts, logicAppsSource),
),
GroupedCommandName: "resourcehijacking",
Surface: contract.Name,
diff --git a/internal/commands/resource_trusts.go b/internal/commands/resource_trusts.go
index d26f605..518770f 100644
--- a/internal/commands/resource_trusts.go
+++ b/internal/commands/resource_trusts.go
@@ -11,7 +11,7 @@ import (
func resourceTrustsHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
- facts, err := provider.ResourceTrusts(ctx, request.Tenant, request.Subscription)
+ facts, sessionArtifacts, err := resourceTrustsFacts(ctx, request, provider, now)
if err != nil {
return nil, err
}
@@ -19,12 +19,51 @@ func resourceTrustsHandler(provider providers.Provider, now func() time.Time) Ha
return models.ResourceTrustsOutput{
Findings: resourceTrustFindings(facts.StorageAssets, facts.KeyVaults),
Issues: facts.Issues,
- Metadata: commandMetadata("resource-trusts", now, request, facts.TenantID, facts.SubscriptionID, ""),
+ Metadata: withMetadataSessionArtifacts(withArtifactContext(commandMetadata("resource-trusts", now, request, facts.TenantID, facts.SubscriptionID, facts.TokenSource), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource), sessionArtifacts),
ResourceTrusts: sortedByLess(composeResourceTrusts(facts.StorageAssets, facts.KeyVaults), resourceTrustLess),
}, nil
}
}
+func resourceTrustsFacts(ctx context.Context, request Request, provider providers.Provider, now func() time.Time) (providers.ResourceTrustsFacts, []models.SessionArtifact, error) {
+ group := newCommandOutputGroup(2)
+ expected := helperArtifactExpectedSessions(ctx, request, provider, now, "storage", "keyvault")
+ storageFuture := runHelperOutput[models.StorageOutput](group, ctx, request, storageHandler(provider, now), "storage", expected)
+ keyVaultFuture := runHelperOutput[models.KeyVaultOutput](group, ctx, request, keyVaultHandler(provider, now), "keyvault", expected)
+
+ storage, storageSource, err := storageFuture.waitWithSource()
+ if err != nil {
+ return providers.ResourceTrustsFacts{}, nil, err
+ }
+ keyVault, keyVaultSource, err := keyVaultFuture.waitWithSource()
+ if err != nil {
+ return providers.ResourceTrustsFacts{}, nil, err
+ }
+
+ sessionArtifacts := []models.SessionArtifact{}
+ if storageSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *storageSource)
+ }
+ if keyVaultSource != nil {
+ sessionArtifacts = append(sessionArtifacts, *keyVaultSource)
+ }
+ identity, identityIssues := providers.MergeArtifactIdentityFacts(artifactIdentityFactsFromMetadata(storage.Metadata), artifactIdentityFactsFromMetadata(keyVault.Metadata))
+ issues := append(append([]models.Issue{}, storage.Issues...), keyVault.Issues...)
+ issues = append(issues, identityIssues...)
+ return providers.ResourceTrustsFacts{
+ ArtifactIdentityFacts: identity,
+ TenantID: firstNonEmpty(stringPtrValue(storage.Metadata.TenantID), stringPtrValue(keyVault.Metadata.TenantID)),
+ SubscriptionID: firstNonEmpty(stringPtrValue(storage.Metadata.SubscriptionID), stringPtrValue(keyVault.Metadata.SubscriptionID)),
+ StorageAssets: append([]models.StorageAsset{}, storage.StorageAssets...),
+ KeyVaults: append([]models.KeyVaultAsset{}, keyVault.KeyVaults...),
+ Issues: issues,
+ }, sessionArtifacts, nil
+}
+
+func artifactIdentityFactsFromMetadata(metadata models.Metadata) providers.ArtifactIdentityFacts {
+ return artifactIdentityFactsFromContext(metadata.ArtifactContext, metadata.AuthMode, metadata.TokenSource)
+}
+
func composeResourceTrusts(storageAssets []models.StorageAsset, keyVaults []models.KeyVaultAsset) []models.ResourceTrustSummary {
trusts := append([]models.ResourceTrustSummary{}, resourceTrustsFromStorage(storageAssets)...)
trusts = append(trusts, resourceTrustsFromKeyVault(keyVaults)...)
diff --git a/internal/commands/storage.go b/internal/commands/storage.go
index 3b9b494..6a2824e 100644
--- a/internal/commands/storage.go
+++ b/internal/commands/storage.go
@@ -20,7 +20,7 @@ func storageHandler(provider providers.Provider, now func() time.Time) Handler {
return models.StorageOutput{
Findings: storageFindings(assets),
Issues: facts.Issues,
- Metadata: commandMetadata("storage", now, request, facts.TenantID, facts.SubscriptionID, ""),
+ Metadata: withArtifactContext(commandMetadata("storage", now, request, facts.TenantID, facts.SubscriptionID, facts.TokenSource), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
StorageAssets: assets,
}, nil
}
diff --git a/internal/commands/vm_extensions.go b/internal/commands/vm_extensions.go
index 24eb0e1..4acf13c 100644
--- a/internal/commands/vm_extensions.go
+++ b/internal/commands/vm_extensions.go
@@ -25,7 +25,7 @@ func vmExtensionsHandler(provider providers.Provider, now func() time.Time) Hand
return models.VMExtensionsOutput{
Findings: []models.Finding{},
Issues: facts.Issues,
- Metadata: runtimeCommandMetadata("vm-extensions", now, facts.TenantID, facts.SubscriptionID),
+ Metadata: withRuntimeArtifactContext(runtimeCommandMetadata("vm-extensions", now, facts.TenantID, facts.SubscriptionID), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
VMExtensions: extensions,
}, nil
}
diff --git a/internal/commands/vms.go b/internal/commands/vms.go
index a52e1a5..dc16a75 100644
--- a/internal/commands/vms.go
+++ b/internal/commands/vms.go
@@ -43,7 +43,7 @@ func vmsHandler(provider providers.Provider, now func() time.Time) Handler {
return models.VmsOutput{
Findings: buildVMFindings(vmAssets),
Issues: facts.Issues,
- Metadata: commandMetadata("vms", now, request, facts.TenantID, facts.SubscriptionID, ""),
+ Metadata: withArtifactContext(commandMetadata("vms", now, request, facts.TenantID, facts.SubscriptionID, facts.TokenSource), request, facts.CurrentPrincipal, facts.AuthMode, facts.TokenSource),
VMAssets: vmAssets,
}, nil
}
diff --git a/internal/commands/whoami.go b/internal/commands/whoami.go
index 7406b00..29b398d 100644
--- a/internal/commands/whoami.go
+++ b/internal/commands/whoami.go
@@ -18,10 +18,19 @@ func whoAmIHandler(provider providers.Provider, now func() time.Time) Handler {
return models.WhoAmIOutput{
EffectiveScopes: facts.EffectiveScopes,
Issues: facts.Issues,
- Metadata: whoAmIMetadata(now, request, facts.TenantID, facts.Subscription.ID, facts.TokenSource, facts.AuthMode),
- Principal: facts.Principal,
- Subscription: facts.Subscription,
- TenantID: facts.TenantID,
+ Metadata: models.WhoAmIMetadata{
+ AuthMode: models.StringPtr(facts.AuthMode),
+ Metadata: withArtifactContext(
+ commandMetadata("whoami", now, request, facts.TenantID, facts.Subscription.ID, facts.TokenSource),
+ request,
+ facts.Principal,
+ facts.AuthMode,
+ facts.TokenSource,
+ ),
+ },
+ Principal: facts.Principal,
+ Subscription: facts.Subscription,
+ TenantID: facts.TenantID,
}, nil
}
}
diff --git a/internal/models/automation.go b/internal/models/automation.go
index 6071ab6..283061a 100644
--- a/internal/models/automation.go
+++ b/internal/models/automation.go
@@ -45,12 +45,15 @@ type AutomationAccountAsset struct {
}
type AutomationMetadata struct {
- SchemaVersion string `json:"schema_version"`
- Command string `json:"command"`
- GeneratedAt string `json:"generated_at"`
- TenantID *string `json:"tenant_id"`
- SubscriptionID *string `json:"subscription_id"`
- TokenSource *string `json:"token_source"`
+ SchemaVersion string `json:"schema_version"`
+ Command string `json:"command"`
+ GeneratedAt string `json:"generated_at"`
+ TenantID *string `json:"tenant_id"`
+ SubscriptionID *string `json:"subscription_id"`
+ TokenSource *string `json:"token_source"`
+ AuthMode *string `json:"auth_mode,omitempty"`
+ ArtifactContext *ArtifactContext `json:"artifact_context,omitempty"`
+ SessionArtifacts []SessionArtifact `json:"session_artifacts,omitempty"`
}
type AutomationOutput struct {
diff --git a/internal/models/common.go b/internal/models/common.go
index ce03d74..8c45342 100644
--- a/internal/models/common.go
+++ b/internal/models/common.go
@@ -85,22 +85,28 @@ func (mode RoleTrustsMode) Legacy() bool {
}
type Metadata struct {
- Command string `json:"command"`
- DevOpsOrganization *string `json:"devops_organization"`
- GeneratedAt string `json:"generated_at"`
- SchemaVersion string `json:"schema_version"`
- SubscriptionID *string `json:"subscription_id"`
- TenantID *string `json:"tenant_id"`
- TokenSource *string `json:"token_source"`
+ AuthMode *string `json:"auth_mode,omitempty"`
+ Command string `json:"command"`
+ DevOpsOrganization *string `json:"devops_organization"`
+ GeneratedAt string `json:"generated_at"`
+ SchemaVersion string `json:"schema_version"`
+ SubscriptionID *string `json:"subscription_id"`
+ TenantID *string `json:"tenant_id"`
+ TokenSource *string `json:"token_source"`
+ ArtifactContext *ArtifactContext `json:"artifact_context,omitempty"`
+ SessionArtifacts []SessionArtifact `json:"session_artifacts,omitempty"`
}
type RuntimeCommandMetadata struct {
- Command string `json:"command"`
- GeneratedAt string `json:"generated_at"`
- SchemaVersion string `json:"schema_version"`
- SubscriptionID *string `json:"subscription_id"`
- TenantID *string `json:"tenant_id"`
- TokenSource *string `json:"token_source"`
+ Command string `json:"command"`
+ GeneratedAt string `json:"generated_at"`
+ SchemaVersion string `json:"schema_version"`
+ SubscriptionID *string `json:"subscription_id"`
+ TenantID *string `json:"tenant_id"`
+ TokenSource *string `json:"token_source"`
+ AuthMode *string `json:"auth_mode,omitempty"`
+ ArtifactContext *ArtifactContext `json:"artifact_context,omitempty"`
+ SessionArtifacts []SessionArtifact `json:"session_artifacts,omitempty"`
}
type WhoAmIMetadata struct {
@@ -109,14 +115,16 @@ type WhoAmIMetadata struct {
}
type ScopedCommandMetadata struct {
- SchemaVersion string `json:"schema_version"`
- Command string `json:"command"`
- GeneratedAt string `json:"generated_at"`
- TenantID *string `json:"tenant_id"`
- SubscriptionID *string `json:"subscription_id"`
- DevOpsOrganization *string `json:"devops_organization"`
- TokenSource *string `json:"token_source"`
- AuthMode *string `json:"auth_mode"`
+ SchemaVersion string `json:"schema_version"`
+ Command string `json:"command"`
+ GeneratedAt string `json:"generated_at"`
+ TenantID *string `json:"tenant_id"`
+ SubscriptionID *string `json:"subscription_id"`
+ DevOpsOrganization *string `json:"devops_organization"`
+ TokenSource *string `json:"token_source"`
+ AuthMode *string `json:"auth_mode"`
+ ArtifactContext *ArtifactContext `json:"artifact_context,omitempty"`
+ SessionArtifacts []SessionArtifact `json:"session_artifacts,omitempty"`
}
type RenderContext struct {
@@ -124,6 +132,26 @@ type RenderContext struct {
Subscription string
}
+type ArtifactPrincipal struct {
+ ID string `json:"id"`
+ PrincipalType string `json:"principal_type,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+}
+
+type ArtifactContext struct {
+ ToolVersion string `json:"tool_version"`
+ CurrentPrincipal ArtifactPrincipal `json:"current_principal"`
+ CommandOptions map[string]string `json:"command_options"`
+}
+
+type SessionArtifact struct {
+ Command string `json:"command"`
+ Path string `json:"path"`
+ GeneratedAt string `json:"generated_at"`
+ AgeSeconds int `json:"age_seconds"`
+ Context string `json:"context"`
+}
+
type PermissionsMetadata = ScopedCommandMetadata
type Issue struct {
diff --git a/internal/models/principals.go b/internal/models/principals.go
index 7382d19..b63e266 100644
--- a/internal/models/principals.go
+++ b/internal/models/principals.go
@@ -16,14 +16,16 @@ type PrincipalSummary struct {
}
type PrincipalsMetadata struct {
- AuthMode *string `json:"auth_mode"`
- Command string `json:"command"`
- DevOpsOrganization *string `json:"devops_organization"`
- GeneratedAt string `json:"generated_at"`
- SchemaVersion string `json:"schema_version"`
- SubscriptionID *string `json:"subscription_id"`
- TenantID *string `json:"tenant_id"`
- TokenSource *string `json:"token_source"`
+ AuthMode *string `json:"auth_mode"`
+ Command string `json:"command"`
+ DevOpsOrganization *string `json:"devops_organization"`
+ GeneratedAt string `json:"generated_at"`
+ SchemaVersion string `json:"schema_version"`
+ SubscriptionID *string `json:"subscription_id"`
+ TenantID *string `json:"tenant_id"`
+ TokenSource *string `json:"token_source"`
+ ArtifactContext *ArtifactContext `json:"artifact_context,omitempty"`
+ SessionArtifacts []SessionArtifact `json:"session_artifacts,omitempty"`
}
type PrincipalsOutput struct {
diff --git a/internal/providers/azure.go b/internal/providers/azure.go
index 5f8b03a..5ef263a 100644
--- a/internal/providers/azure.go
+++ b/internal/providers/azure.go
@@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
+ "sync"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
@@ -25,11 +26,15 @@ import (
const managementScope = "https://management.azure.com/.default"
type AzureProvider struct {
- cache *liveAzureCache
+ mu *sync.Mutex
+ devopsDiscoveries map[string]*onceValue[devopsOrganizationDiscovery]
}
func NewAzureProvider() AzureProvider {
- return AzureProvider{cache: newLiveAzureCache()}
+ return AzureProvider{
+ mu: &sync.Mutex{},
+ devopsDiscoveries: map[string]*onceValue[devopsOrganizationDiscovery]{},
+ }
}
type azureSession struct {
@@ -72,6 +77,20 @@ func (provider AzureProvider) WhoAmI(ctx context.Context, tenant string, subscri
}, nil
}
+func azureArtifactIdentityFacts(session azureSession) ArtifactIdentityFacts {
+ principalID, displayName := currentPrincipalFromClaims(session.claims)
+ return ArtifactIdentityFacts{
+ CurrentPrincipal: models.Principal{
+ DisplayName: displayName,
+ ID: principalID,
+ PrincipalType: principalTypeFromClaims(session.claims),
+ TenantID: session.tenantID,
+ },
+ TokenSource: session.tokenSource,
+ AuthMode: session.authMode,
+ }
+}
+
func (provider AzureProvider) Inventory(ctx context.Context, tenant string, subscription string) (InventoryFacts, error) {
session, err := provider.session(ctx, tenant, subscription)
if err != nil {
@@ -244,10 +263,11 @@ func (provider AzureProvider) AppServices(ctx context.Context, tenant string, su
}
return AppServicesFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- AppServices: rows,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ AppServices: rows,
+ Issues: issues,
}, nil
}
@@ -427,7 +447,7 @@ func (provider AzureProvider) EnvVars(ctx context.Context, tenant string, subscr
}
func (provider AzureProvider) session(ctx context.Context, tenant string, subscription string) (azureSession, error) {
- return provider.cachedSession(ctx, tenant, subscription)
+ return provider.buildSession(ctx, tenant, subscription)
}
func newAzureCredential(ctx context.Context, tenant string) (azcore.TokenCredential, string, string, map[string]string, string, error) {
diff --git a/internal/providers/azure_api_mgmt.go b/internal/providers/azure_api_mgmt.go
index 6b47cdd..258d3bb 100644
--- a/internal/providers/azure_api_mgmt.go
+++ b/internal/providers/azure_api_mgmt.go
@@ -82,6 +82,7 @@ func (provider AzureProvider) ApiMgmt(ctx context.Context, tenant string, subscr
}
return ApiMgmtFacts{
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
TenantID: session.tenantID,
SubscriptionID: session.subscription.ID,
ApiManagementServices: apiMgmtServices,
diff --git a/internal/providers/azure_appinsights.go b/internal/providers/azure_appinsights.go
index 632467a..d0c048a 100644
--- a/internal/providers/azure_appinsights.go
+++ b/internal/providers/azure_appinsights.go
@@ -64,11 +64,12 @@ func (provider AzureProvider) AppInsights(ctx context.Context, tenant string, su
})
return AppInsightsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Components: components,
- Targets: targets,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Components: components,
+ Targets: targets,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_automation.go b/internal/providers/azure_automation.go
index 1ff3913..ba660bf 100644
--- a/internal/providers/azure_automation.go
+++ b/internal/providers/azure_automation.go
@@ -24,10 +24,11 @@ func (provider AzureProvider) Automation(ctx context.Context, tenant string, sub
)
if err != nil {
return AutomationFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- AutomationAccounts: []models.AutomationAccountAsset{},
- Issues: []models.Issue{issueFromError("automation.accounts", err)},
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ AutomationAccounts: []models.AutomationAccountAsset{},
+ Issues: []models.Issue{issueFromError("automation.accounts", err)},
}, nil
}
@@ -38,10 +39,11 @@ func (provider AzureProvider) Automation(ctx context.Context, tenant string, sub
}
return AutomationFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- AutomationAccounts: accountsOut,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ AutomationAccounts: accountsOut,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_compute_network.go b/internal/providers/azure_compute_network.go
index 4631d97..c1e8d2a 100644
--- a/internal/providers/azure_compute_network.go
+++ b/internal/providers/azure_compute_network.go
@@ -237,10 +237,11 @@ func (provider AzureProvider) VMs(ctx context.Context, tenant string, subscripti
vms := state.vmSnapshot(ctx)
return VMsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- VMAssets: vms.assets,
- Issues: append(append([]models.Issue{}, nics.issues...), vms.issues...),
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ VMAssets: vms.assets,
+ Issues: append(append([]models.Issue{}, nics.issues...), vms.issues...),
}, nil
}
diff --git a/internal/providers/azure_dcr.go b/internal/providers/azure_dcr.go
index e5aaca1..754004b 100644
--- a/internal/providers/azure_dcr.go
+++ b/internal/providers/azure_dcr.go
@@ -34,10 +34,11 @@ func (provider AzureProvider) DCR(ctx context.Context, tenant string, subscripti
}
return DCRFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- DCRs: assets,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ DCRs: assets,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_devops.go b/internal/providers/azure_devops.go
index 335f2db..2cea266 100644
--- a/internal/providers/azure_devops.go
+++ b/internal/providers/azure_devops.go
@@ -50,19 +50,15 @@ func (snapshot devopsPermissionSnapshot) allows(permissionName string) bool {
}
func (provider AzureProvider) devopsOrganizationsFromAzureSideClues(ctx context.Context, session azureSession) devopsOrganizationDiscovery {
- if provider.cache == nil {
- return collectDevopsOrganizationsFromAzureSideClues(ctx, session)
- }
-
- cacheKey := sessionStateKey(session)
+ cacheKey := session.tenantID + "::" + session.subscription.ID
- provider.cache.mu.Lock()
- entry := provider.cache.devopsOrgDiscoveries[cacheKey]
+ provider.mu.Lock()
+ entry := provider.devopsDiscoveries[cacheKey]
if entry == nil {
entry = &onceValue[devopsOrganizationDiscovery]{}
- provider.cache.devopsOrgDiscoveries[cacheKey] = entry
+ provider.devopsDiscoveries[cacheKey] = entry
}
- provider.cache.mu.Unlock()
+ provider.mu.Unlock()
discovery, err := entry.get(func() (devopsOrganizationDiscovery, error) {
return collectDevopsOrganizationsFromAzureSideClues(ctx, session), nil
diff --git a/internal/providers/azure_diagnostic_settings.go b/internal/providers/azure_diagnostic_settings.go
index e54292c..55c6d57 100644
--- a/internal/providers/azure_diagnostic_settings.go
+++ b/internal/providers/azure_diagnostic_settings.go
@@ -63,10 +63,11 @@ func (provider AzureProvider) DiagnosticSettings(ctx context.Context, tenant str
})
return DiagnosticSettingsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Sources: sources,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Sources: sources,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_event_grid.go b/internal/providers/azure_event_grid.go
index 4c390e3..82f754c 100644
--- a/internal/providers/azure_event_grid.go
+++ b/internal/providers/azure_event_grid.go
@@ -44,10 +44,11 @@ func (provider AzureProvider) EventGrid(ctx context.Context, tenant string, subs
}
return EventGridFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Routes: routes,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Routes: routes,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_identity.go b/internal/providers/azure_identity.go
index 6c10171..82e0557 100644
--- a/internal/providers/azure_identity.go
+++ b/internal/providers/azure_identity.go
@@ -83,6 +83,16 @@ func (provider AzureProvider) Permissions(ctx context.Context, tenant string, su
return PermissionsFacts{}, err
}
+ return PermissionsFactsFromSources(session.tenantID, session.subscription.ID, rbacFacts, whoamiFacts, managedIdentityFacts), nil
+}
+
+func PermissionsFactsFromSources(
+ tenantID string,
+ subscriptionID string,
+ rbacFacts RBACFacts,
+ whoamiFacts WhoAmIFacts,
+ managedIdentityFacts ManagedIdentitiesFacts,
+) PermissionsFacts {
principalRecords := map[string]livePrincipalRecord{}
ensureRecord := func(principalID string) livePrincipalRecord {
record, ok := principalRecords[principalID]
@@ -209,12 +219,19 @@ func (provider AzureProvider) Permissions(ctx context.Context, tenant string, su
issues = append(issues, whoamiFacts.Issues...)
return PermissionsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Permissions: permissions,
- Principals: principals,
- Issues: issues,
- }, nil
+ TenantID: tenantID,
+ SubscriptionID: subscriptionID,
+ CurrentPrincipal: whoamiFacts.Principal,
+ TokenSource: whoamiFacts.TokenSource,
+ AuthMode: whoamiFacts.AuthMode,
+ Permissions: permissions,
+ Principals: principals,
+ Issues: issues,
+ }
+}
+
+func (provider AzureProvider) PermissionsFromSources(_ context.Context, tenant string, subscription string, rbacFacts RBACFacts, whoamiFacts WhoAmIFacts, managedIdentityFacts ManagedIdentitiesFacts) (PermissionsFacts, error) {
+ return PermissionsFactsFromSources(tenant, subscription, rbacFacts, whoamiFacts, managedIdentityFacts), nil
}
func (provider AzureProvider) ManagedIdentities(ctx context.Context, tenant string, subscription string) (ManagedIdentitiesFacts, error) {
@@ -227,12 +244,37 @@ func (provider AzureProvider) ManagedIdentities(ctx context.Context, tenant stri
return provider.collectManagedIdentityFacts(ctx, tenant, subscription, session, rbacFacts), nil
}
+func (provider AzureProvider) ManagedIdentitiesFromSources(ctx context.Context, tenant string, subscription string, rbacFacts *RBACFacts) (ManagedIdentitiesFacts, error) {
+ session, err := provider.session(ctx, tenant, subscription)
+ if err != nil {
+ return ManagedIdentitiesFacts{}, err
+ }
+
+ if rbacFacts == nil {
+ collected := provider.collectRBACFacts(ctx, session)
+ rbacFacts = &collected
+ }
+ return provider.collectManagedIdentityFacts(ctx, tenant, subscription, session, *rbacFacts), nil
+}
+
func (provider AzureProvider) collectRBACFacts(ctx context.Context, session azureSession) RBACFacts {
subscriptionScope := "/subscriptions/" + session.subscription.ID
+ currentPrincipalID, currentDisplayName := currentPrincipalFromClaims(session.claims)
+ currentPrincipalType := principalTypeFromClaims(session.claims)
+ currentPrincipal := models.Principal{
+ DisplayName: currentDisplayName,
+ ID: currentPrincipalID,
+ PrincipalType: currentPrincipalType,
+ TenantID: session.tenantID,
+ }
clientFactory, err := armauthorization.NewClientFactory(session.subscription.ID, session.credential, nil)
if err != nil {
return RBACFacts{
- TenantID: session.tenantID,
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ CurrentPrincipal: currentPrincipal,
+ TokenSource: session.tokenSource,
+ AuthMode: session.authMode,
Scopes: []models.ScopeRef{
{DisplayName: session.subscription.DisplayName, ID: subscriptionScope, ScopeType: "subscription"},
},
@@ -246,7 +288,11 @@ func (provider AzureProvider) collectRBACFacts(ctx context.Context, session azur
page, pagerErr := assignmentsPager.NextPage(ctx)
if pagerErr != nil {
return RBACFacts{
- TenantID: session.tenantID,
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ CurrentPrincipal: currentPrincipal,
+ TokenSource: session.tokenSource,
+ AuthMode: session.authMode,
Scopes: []models.ScopeRef{
{DisplayName: session.subscription.DisplayName, ID: subscriptionScope, ScopeType: "subscription"},
},
@@ -265,9 +311,6 @@ func (provider AzureProvider) collectRBACFacts(ctx context.Context, session azur
roleDefinitionsClient := clientFactory.NewRoleDefinitionsClient()
- currentPrincipalID, currentDisplayName := currentPrincipalFromClaims(session.claims)
- currentPrincipalType := principalTypeFromClaims(session.claims)
-
scopes := map[string]models.ScopeRef{
subscriptionScope: {
DisplayName: session.subscription.DisplayName,
@@ -364,11 +407,15 @@ func (provider AzureProvider) collectRBACFacts(ctx context.Context, session azur
})
return RBACFacts{
- TenantID: session.tenantID,
- Principals: principalRows,
- Scopes: scopeRows,
- RoleAssignments: roleAssignments,
- Issues: issues,
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ CurrentPrincipal: currentPrincipal,
+ TokenSource: session.tokenSource,
+ AuthMode: session.authMode,
+ Principals: principalRows,
+ Scopes: scopeRows,
+ RoleAssignments: roleAssignments,
+ Issues: issues,
}
}
@@ -739,12 +786,13 @@ func (provider AzureProvider) collectManagedIdentityFacts(ctx context.Context, t
})
return ManagedIdentitiesFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Identities: identities,
- RoleAssignments: roleAssignments,
- Findings: findings,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Identities: identities,
+ RoleAssignments: roleAssignments,
+ Findings: findings,
+ Issues: issues,
}
}
diff --git a/internal/providers/azure_keyvault.go b/internal/providers/azure_keyvault.go
index 2e75e12..b123325 100644
--- a/internal/providers/azure_keyvault.go
+++ b/internal/providers/azure_keyvault.go
@@ -23,10 +23,11 @@ func (provider AzureProvider) KeyVault(ctx context.Context, tenant string, subsc
)
if err != nil {
return KeyVaultFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- KeyVaults: []models.KeyVaultAsset{},
- Issues: []models.Issue{issueFromError("keyvault", err)},
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ KeyVaults: []models.KeyVaultAsset{},
+ Issues: []models.Issue{issueFromError("keyvault", err)},
}, nil
}
@@ -42,10 +43,11 @@ func (provider AzureProvider) KeyVault(ctx context.Context, tenant string, subsc
})
return KeyVaultFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- KeyVaults: rows,
- Issues: []models.Issue{},
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ KeyVaults: rows,
+ Issues: []models.Issue{},
}, nil
}
diff --git a/internal/providers/azure_live_cache.go b/internal/providers/azure_live_cache.go
index 42f8b26..300b7a4 100644
--- a/internal/providers/azure_live_cache.go
+++ b/internal/providers/azure_live_cache.go
@@ -14,23 +14,6 @@ import (
"harrierops-azure/internal/models"
)
-type liveAzureCache struct {
- mu sync.Mutex
- sessions map[string]*onceValue[azureSession]
- webAppsStates map[string]*liveWebAppsState
- computeStates map[string]*liveComputeNetworkState
- devopsOrgDiscoveries map[string]*onceValue[devopsOrganizationDiscovery]
-}
-
-func newLiveAzureCache() *liveAzureCache {
- return &liveAzureCache{
- sessions: map[string]*onceValue[azureSession]{},
- webAppsStates: map[string]*liveWebAppsState{},
- computeStates: map[string]*liveComputeNetworkState{},
- devopsOrgDiscoveries: map[string]*onceValue[devopsOrganizationDiscovery]{},
- }
-}
-
type onceValue[T any] struct {
mu sync.Mutex
ready chan struct{}
@@ -74,34 +57,6 @@ func (value *onceValue[T]) get(load func() (T, error)) (T, error) {
}
}
-func sessionRequestKey(tenant string, subscription string) string {
- return tenant + "::" + subscription
-}
-
-func sessionStateKey(session azureSession) string {
- return session.tenantID + "::" + session.subscription.ID
-}
-
-func (provider AzureProvider) cachedSession(ctx context.Context, tenant string, subscription string) (azureSession, error) {
- if provider.cache == nil {
- return provider.buildSession(ctx, tenant, subscription)
- }
-
- cacheKey := sessionRequestKey(tenant, subscription)
-
- provider.cache.mu.Lock()
- entry := provider.cache.sessions[cacheKey]
- if entry == nil {
- entry = &onceValue[azureSession]{}
- provider.cache.sessions[cacheKey] = entry
- }
- provider.cache.mu.Unlock()
-
- return entry.get(func() (azureSession, error) {
- return provider.buildSession(ctx, tenant, subscription)
- })
-}
-
func (provider AzureProvider) buildSession(ctx context.Context, tenant string, subscription string) (azureSession, error) {
credential, tokenSource, authMode, claims, tenantID, err := newAzureCredential(ctx, tenant)
if err != nil {
@@ -156,40 +111,16 @@ type liveWebAppsState struct {
}
func (provider AzureProvider) webAppsState(session azureSession) (*liveWebAppsState, error) {
- if provider.cache == nil {
- client, err := armappservice.NewWebAppsClient(session.subscription.ID, session.credential, nil)
- if err != nil {
- return nil, fmt.Errorf("build web apps client: %w", err)
- }
- return &liveWebAppsState{
- client: client,
- credential: session.credential,
- subscriptionID: session.subscription.ID,
- apps: &onceValue[[]*liveWebAppResource]{},
- }, nil
- }
-
- cacheKey := sessionStateKey(session)
-
- provider.cache.mu.Lock()
- state := provider.cache.webAppsStates[cacheKey]
- if state == nil {
- client, err := armappservice.NewWebAppsClient(session.subscription.ID, session.credential, nil)
- if err != nil {
- provider.cache.mu.Unlock()
- return nil, fmt.Errorf("build web apps client: %w", err)
- }
- state = &liveWebAppsState{
- client: client,
- credential: session.credential,
- subscriptionID: session.subscription.ID,
- apps: &onceValue[[]*liveWebAppResource]{},
- }
- provider.cache.webAppsStates[cacheKey] = state
+ client, err := armappservice.NewWebAppsClient(session.subscription.ID, session.credential, nil)
+ if err != nil {
+ return nil, fmt.Errorf("build web apps client: %w", err)
}
- provider.cache.mu.Unlock()
-
- return state, nil
+ return &liveWebAppsState{
+ client: client,
+ credential: session.credential,
+ subscriptionID: session.subscription.ID,
+ apps: &onceValue[[]*liveWebAppResource]{},
+ }, nil
}
func (state *liveWebAppsState) list(ctx context.Context) ([]*liveWebAppResource, error) {
@@ -352,42 +283,17 @@ type liveComputeNetworkState struct {
}
func (provider AzureProvider) computeNetworkState(session azureSession) (*liveComputeNetworkState, error) {
- if provider.cache == nil {
- collector, err := newComputeNetworkCollector(session)
- if err != nil {
- return nil, err
- }
- return &liveComputeNetworkState{
- collector: collector,
- nics: &onceValue[liveNICSnapshot]{},
- vms: &onceValue[liveVMSnapshot]{},
- vmss: &onceValue[liveVMSSSnapshot]{},
- snapshots: &onceValue[liveSnapshotDiskSnapshot]{},
- }, nil
- }
-
- cacheKey := sessionStateKey(session)
-
- provider.cache.mu.Lock()
- state := provider.cache.computeStates[cacheKey]
- if state == nil {
- collector, err := newComputeNetworkCollector(session)
- if err != nil {
- provider.cache.mu.Unlock()
- return nil, err
- }
- state = &liveComputeNetworkState{
- collector: collector,
- nics: &onceValue[liveNICSnapshot]{},
- vms: &onceValue[liveVMSnapshot]{},
- vmss: &onceValue[liveVMSSSnapshot]{},
- snapshots: &onceValue[liveSnapshotDiskSnapshot]{},
- }
- provider.cache.computeStates[cacheKey] = state
+ collector, err := newComputeNetworkCollector(session)
+ if err != nil {
+ return nil, err
}
- provider.cache.mu.Unlock()
-
- return state, nil
+ return &liveComputeNetworkState{
+ collector: collector,
+ nics: &onceValue[liveNICSnapshot]{},
+ vms: &onceValue[liveVMSnapshot]{},
+ vmss: &onceValue[liveVMSSSnapshot]{},
+ snapshots: &onceValue[liveSnapshotDiskSnapshot]{},
+ }, nil
}
func (state *liveComputeNetworkState) nicSnapshot(ctx context.Context) liveNICSnapshot {
diff --git a/internal/providers/azure_logic_apps.go b/internal/providers/azure_logic_apps.go
index 2632fbe..367f874 100644
--- a/internal/providers/azure_logic_apps.go
+++ b/internal/providers/azure_logic_apps.go
@@ -26,10 +26,11 @@ func (provider AzureProvider) LogicApps(ctx context.Context, tenant string, subs
)
if err != nil {
return LogicAppsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Workflows: []models.LogicAppWorkflowAsset{},
- Issues: []models.Issue{issueFromError("logic-apps.workflows", err)},
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Workflows: []models.LogicAppWorkflowAsset{},
+ Issues: []models.Issue{issueFromError("logic-apps.workflows", err)},
}, nil
}
@@ -50,10 +51,11 @@ func (provider AzureProvider) LogicApps(ctx context.Context, tenant string, subs
}
return LogicAppsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Workflows: rows,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Workflows: rows,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_monitoring_sinks.go b/internal/providers/azure_monitoring_sinks.go
index 7a3c9ce..0fa184b 100644
--- a/internal/providers/azure_monitoring_sinks.go
+++ b/internal/providers/azure_monitoring_sinks.go
@@ -8,6 +8,10 @@ import (
)
func (provider AzureProvider) MonitoringSinks(ctx context.Context, tenant string, subscription string) (MonitoringSinksFacts, error) {
+ return provider.MonitoringSinksFromSources(ctx, tenant, subscription, nil, nil)
+}
+
+func (provider AzureProvider) MonitoringSinksFromSources(ctx context.Context, tenant string, subscription string, dcrFacts *DCRFacts, diagnosticFacts *DiagnosticSettingsFacts) (MonitoringSinksFacts, error) {
session, err := provider.session(ctx, tenant, subscription)
if err != nil {
return MonitoringSinksFacts{}, err
@@ -29,19 +33,29 @@ func (provider AzureProvider) MonitoringSinks(ctx context.Context, tenant string
}
issues := []models.Issue{}
- dcrFacts, err := provider.DCR(ctx, tenant, subscription)
- if err != nil {
- issues = append(issues, issueFromError("monitoring-sinks.dcr", err))
- } else {
+ if dcrFacts == nil {
+ collected, err := provider.DCR(ctx, tenant, subscription)
+ if err != nil {
+ issues = append(issues, issueFromError("monitoring-sinks.dcr", err))
+ } else {
+ dcrFacts = &collected
+ }
+ }
+ if dcrFacts != nil {
issues = append(issues, dcrFacts.Issues...)
monitoringSinksEnsureDCRDestinations(&sinks, dcrFacts.DCRs)
monitoringSinksAttachDCRReferences(sinks, dcrFacts.DCRs)
}
- diagnosticFacts, err := provider.DiagnosticSettings(ctx, tenant, subscription)
- if err != nil {
- issues = append(issues, issueFromError("monitoring-sinks.diagnostic-settings", err))
- } else {
+ if diagnosticFacts == nil {
+ collected, err := provider.DiagnosticSettings(ctx, tenant, subscription)
+ if err != nil {
+ issues = append(issues, issueFromError("monitoring-sinks.diagnostic-settings", err))
+ } else {
+ diagnosticFacts = &collected
+ }
+ }
+ if diagnosticFacts != nil {
issues = append(issues, diagnosticFacts.Issues...)
monitoringSinksEnsureDiagnosticDestinations(&sinks, diagnosticFacts.Sources)
monitoringSinksAttachDiagnosticReferences(sinks, diagnosticFacts.Sources)
@@ -50,10 +64,11 @@ func (provider AzureProvider) MonitoringSinks(ctx context.Context, tenant string
sinks = monitoringSinksFinalize(sinks)
return MonitoringSinksFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Sinks: sinks,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Sinks: sinks,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_principals.go b/internal/providers/azure_principals.go
index ddebab9..6d54eeb 100644
--- a/internal/providers/azure_principals.go
+++ b/internal/providers/azure_principals.go
@@ -15,5 +15,9 @@ func (provider AzureProvider) Principals(ctx context.Context, tenant string, sub
return PrincipalsFacts{}, err
}
- return principalsFactsFromSources(session.tenantID, session.subscription.ID, rbacFacts, whoamiFacts, managedIdentityFacts), nil
+ return PrincipalsFactsFromSources(session.tenantID, session.subscription.ID, rbacFacts, whoamiFacts, managedIdentityFacts), nil
+}
+
+func (provider AzureProvider) PrincipalsFromSources(_ context.Context, tenant string, subscription string, rbacFacts RBACFacts, whoamiFacts WhoAmIFacts, managedIdentityFacts ManagedIdentitiesFacts) (PrincipalsFacts, error) {
+ return PrincipalsFactsFromSources(tenant, subscription, rbacFacts, whoamiFacts, managedIdentityFacts), nil
}
diff --git a/internal/providers/azure_relay.go b/internal/providers/azure_relay.go
index a1f1fe0..07de58f 100644
--- a/internal/providers/azure_relay.go
+++ b/internal/providers/azure_relay.go
@@ -51,10 +51,11 @@ func (provider AzureProvider) Relay(ctx context.Context, tenant string, subscrip
}
return RelayFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- Namespaces: rows,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ Namespaces: rows,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_storage.go b/internal/providers/azure_storage.go
index 1a780c0..22669f6 100644
--- a/internal/providers/azure_storage.go
+++ b/internal/providers/azure_storage.go
@@ -24,10 +24,11 @@ func (provider AzureProvider) Storage(ctx context.Context, tenant string, subscr
)
if err != nil {
return StorageFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- StorageAssets: []models.StorageAsset{},
- Issues: []models.Issue{issueFromError("storage", err)},
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ StorageAssets: []models.StorageAsset{},
+ Issues: []models.Issue{issueFromError("storage", err)},
}, nil
}
@@ -44,10 +45,11 @@ func (provider AzureProvider) Storage(ctx context.Context, tenant string, subscr
})
return StorageFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- StorageAssets: assets,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ StorageAssets: assets,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/azure_vm_extensions.go b/internal/providers/azure_vm_extensions.go
index 1de66d9..0f34387 100644
--- a/internal/providers/azure_vm_extensions.go
+++ b/internal/providers/azure_vm_extensions.go
@@ -39,10 +39,11 @@ func (provider AzureProvider) VMExtensions(ctx context.Context, tenant string, s
}
return VMExtensionsFacts{
- TenantID: session.tenantID,
- SubscriptionID: session.subscription.ID,
- VMExtensions: extensions,
- Issues: issues,
+ ArtifactIdentityFacts: azureArtifactIdentityFacts(session),
+ TenantID: session.tenantID,
+ SubscriptionID: session.subscription.ID,
+ VMExtensions: extensions,
+ Issues: issues,
}, nil
}
diff --git a/internal/providers/principals.go b/internal/providers/principals.go
index 46f14de..dd67a74 100644
--- a/internal/providers/principals.go
+++ b/internal/providers/principals.go
@@ -22,7 +22,7 @@ type principalRecord struct {
isCurrentIdentity bool
}
-func principalsFactsFromSources(
+func PrincipalsFactsFromSources(
tenantID string,
subscriptionID string,
rbacFacts RBACFacts,
@@ -152,10 +152,13 @@ func principalsFactsFromSources(
})
return PrincipalsFacts{
- TenantID: tenantID,
- SubscriptionID: subscriptionID,
- Principals: principals,
- Issues: issues,
+ TenantID: tenantID,
+ SubscriptionID: subscriptionID,
+ CurrentPrincipal: whoamiFacts.Principal,
+ TokenSource: whoamiFacts.TokenSource,
+ AuthMode: whoamiFacts.AuthMode,
+ Principals: principals,
+ Issues: issues,
}
}
diff --git a/internal/providers/principals_test.go b/internal/providers/principals_test.go
index 1a1093c..52eaf6c 100644
--- a/internal/providers/principals_test.go
+++ b/internal/providers/principals_test.go
@@ -9,7 +9,7 @@ import (
func TestPrincipalsFactsPromotesManagedIdentityOverServicePrincipal(t *testing.T) {
principalID := "mi-principal"
- facts := principalsFactsFromSources(
+ facts := PrincipalsFactsFromSources(
"tenant-1",
"sub-1",
RBACFacts{
diff --git a/internal/providers/privesc.go b/internal/providers/privesc.go
index acc8176..cb85b97 100644
--- a/internal/providers/privesc.go
+++ b/internal/providers/privesc.go
@@ -87,10 +87,14 @@ func (p AzureProvider) Privesc(ctx context.Context, tenant string, subscription
if err != nil {
return PrivescFacts{}, err
}
- return privescFactsFromSources(permissionsFacts, principalsFacts, managedIdentityFacts, vmFacts), nil
+ return PrivescFactsFromSources(permissionsFacts, principalsFacts, managedIdentityFacts, vmFacts), nil
}
-func privescFactsFromSources(
+func (p AzureProvider) PrivescFromSources(_ context.Context, permissionsFacts PermissionsFacts, principalsFacts PrincipalsFacts, managedIdentityFacts ManagedIdentitiesFacts, vmFacts VMsFacts) (PrivescFacts, error) {
+ return PrivescFactsFromSources(permissionsFacts, principalsFacts, managedIdentityFacts, vmFacts), nil
+}
+
+func PrivescFactsFromSources(
permissionsFacts PermissionsFacts,
principalsFacts PrincipalsFacts,
managedIdentityFacts ManagedIdentitiesFacts,
diff --git a/internal/providers/privesc_test.go b/internal/providers/privesc_test.go
index 0fab220..9c2c06f 100644
--- a/internal/providers/privesc_test.go
+++ b/internal/providers/privesc_test.go
@@ -79,7 +79,7 @@ func TestPrivescMarksPreferredPathAndExplainsWhyItWon(t *testing.T) {
},
}
- facts := privescFactsFromSources(permissions, principals, managedIdentities, vms)
+ facts := PrivescFactsFromSources(permissions, principals, managedIdentities, vms)
if len(facts.Paths) < 2 {
t.Fatalf("expected at least two privesc paths, got %d", len(facts.Paths))
}
@@ -167,7 +167,7 @@ func TestPrivescPrefersVisiblePrivilegedLeadBeforeIngressPivotWhenPrivilegeSimil
},
}
- facts := privescFactsFromSources(permissions, principals, managedIdentities, vms)
+ facts := PrivescFactsFromSources(permissions, principals, managedIdentities, vms)
if len(facts.Paths) < 3 {
t.Fatalf("expected at least three privesc paths, got %d", len(facts.Paths))
}
@@ -223,7 +223,7 @@ func TestPrivescPrefersHigherPrivilegeThenNonHumanIdentity(t *testing.T) {
},
}
- facts := privescFactsFromSources(permissions, principals, ManagedIdentitiesFacts{}, VMsFacts{})
+ facts := PrivescFactsFromSources(permissions, principals, ManagedIdentitiesFacts{}, VMsFacts{})
if len(facts.Paths) < 2 {
t.Fatalf("expected at least two privesc paths, got %d", len(facts.Paths))
}
@@ -282,7 +282,7 @@ func TestPrivescPrefersAutomationThemedIdentityWhenPrivilegeAndTypeAreSimilar(t
},
}
- facts := privescFactsFromSources(permissions, principals, ManagedIdentitiesFacts{}, VMsFacts{})
+ facts := PrivescFactsFromSources(permissions, principals, ManagedIdentitiesFacts{}, VMsFacts{})
if len(facts.Paths) < 2 {
t.Fatalf("expected at least two privesc paths, got %d", len(facts.Paths))
}
diff --git a/internal/providers/resource_trusts.go b/internal/providers/resource_trusts.go
index 08d02be..8b0e194 100644
--- a/internal/providers/resource_trusts.go
+++ b/internal/providers/resource_trusts.go
@@ -25,11 +25,58 @@ func collectResourceTrusts(ctx context.Context, provider Provider, tenant string
return ResourceTrustsFacts{}, err
}
+ identity, identityIssues := MergeArtifactIdentityFacts(storageFacts.ArtifactIdentityFacts, keyVaultFacts.ArtifactIdentityFacts)
+ issues := append(append([]models.Issue{}, storageFacts.Issues...), keyVaultFacts.Issues...)
+ issues = append(issues, identityIssues...)
+
return ResourceTrustsFacts{
- TenantID: firstNonEmpty(storageFacts.TenantID, keyVaultFacts.TenantID),
- SubscriptionID: firstNonEmpty(storageFacts.SubscriptionID, keyVaultFacts.SubscriptionID),
- StorageAssets: append([]models.StorageAsset{}, storageFacts.StorageAssets...),
- KeyVaults: append([]models.KeyVaultAsset{}, keyVaultFacts.KeyVaults...),
- Issues: append(append([]models.Issue{}, storageFacts.Issues...), keyVaultFacts.Issues...),
+ ArtifactIdentityFacts: identity,
+ TenantID: firstNonEmpty(storageFacts.TenantID, keyVaultFacts.TenantID),
+ SubscriptionID: firstNonEmpty(storageFacts.SubscriptionID, keyVaultFacts.SubscriptionID),
+ StorageAssets: append([]models.StorageAsset{}, storageFacts.StorageAssets...),
+ KeyVaults: append([]models.KeyVaultAsset{}, keyVaultFacts.KeyVaults...),
+ Issues: issues,
}, nil
}
+
+func MergeArtifactIdentityFacts(values ...ArtifactIdentityFacts) (ArtifactIdentityFacts, []models.Issue) {
+ var selected ArtifactIdentityFacts
+ issues := []models.Issue{}
+ for _, value := range values {
+ if !artifactIdentityFactsPresent(value) {
+ continue
+ }
+ if !artifactIdentityFactsPresent(selected) {
+ selected = value
+ continue
+ }
+ if !artifactIdentityFactsEqual(selected, value) {
+ issues = append(issues, models.Issue{
+ Kind: "artifact_identity_mismatch",
+ Message: "Source helper artifacts carry different identity context; resource trust provenance uses the first visible context and marks the mismatch.",
+ Scope: "resource-trusts",
+ Context: map[string]string{
+ "first_principal_id": selected.CurrentPrincipal.ID,
+ "second_principal_id": value.CurrentPrincipal.ID,
+ "first_auth_mode": selected.AuthMode,
+ "second_auth_mode": value.AuthMode,
+ "first_token_source": selected.TokenSource,
+ "second_token_source": value.TokenSource,
+ },
+ })
+ }
+ }
+ return selected, issues
+}
+
+func artifactIdentityFactsPresent(value ArtifactIdentityFacts) bool {
+ return value.CurrentPrincipal.ID != "" || value.CurrentPrincipal.TenantID != "" || value.AuthMode != "" || value.TokenSource != ""
+}
+
+func artifactIdentityFactsEqual(left ArtifactIdentityFacts, right ArtifactIdentityFacts) bool {
+ return left.CurrentPrincipal.ID == right.CurrentPrincipal.ID &&
+ left.CurrentPrincipal.PrincipalType == right.CurrentPrincipal.PrincipalType &&
+ left.CurrentPrincipal.TenantID == right.CurrentPrincipal.TenantID &&
+ left.AuthMode == right.AuthMode &&
+ left.TokenSource == right.TokenSource
+}
diff --git a/internal/providers/resource_trusts_test.go b/internal/providers/resource_trusts_test.go
new file mode 100644
index 0000000..d0e5be6
--- /dev/null
+++ b/internal/providers/resource_trusts_test.go
@@ -0,0 +1,28 @@
+package providers
+
+import (
+ "testing"
+
+ "harrierops-azure/internal/models"
+)
+
+func TestMergeArtifactIdentityFactsMarksMismatchedSourceContext(t *testing.T) {
+ first := ArtifactIdentityFacts{
+ CurrentPrincipal: models.Principal{ID: "principal-a", PrincipalType: "ServicePrincipal", TenantID: "tenant-a"},
+ AuthMode: "azure_cli",
+ TokenSource: "cli",
+ }
+ second := ArtifactIdentityFacts{
+ CurrentPrincipal: models.Principal{ID: "principal-b", PrincipalType: "ServicePrincipal", TenantID: "tenant-a"},
+ AuthMode: "azure_cli",
+ TokenSource: "cli",
+ }
+
+ merged, issues := MergeArtifactIdentityFacts(first, second)
+ if merged.CurrentPrincipal.ID != "principal-a" {
+ t.Fatalf("expected first visible identity to remain selected, got %#v", merged)
+ }
+ if len(issues) != 1 || issues[0].Kind != "artifact_identity_mismatch" {
+ t.Fatalf("expected artifact identity mismatch issue, got %#v", issues)
+ }
+}
diff --git a/internal/providers/static.go b/internal/providers/static.go
index ef444bc..2907eaf 100644
--- a/internal/providers/static.go
+++ b/internal/providers/static.go
@@ -84,7 +84,14 @@ type ArmDeploymentsFacts struct {
Issues []models.Issue
}
+type ArtifactIdentityFacts struct {
+ CurrentPrincipal models.Principal
+ TokenSource string
+ AuthMode string
+}
+
type AutomationFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
AutomationAccounts []models.AutomationAccountAsset
@@ -123,6 +130,7 @@ type ApplicationGatewayFacts struct {
}
type ApiMgmtFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
ApiManagementServices []models.ApiMgmtServiceAsset
@@ -137,6 +145,7 @@ type DatabasesFacts struct {
}
type DCRFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
DCRs []models.DCRAsset
@@ -144,6 +153,7 @@ type DCRFacts struct {
}
type AppInsightsFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Components []models.AppInsightsComponent
@@ -152,6 +162,7 @@ type AppInsightsFacts struct {
}
type DiagnosticSettingsFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Sources []models.DiagnosticSettingsSource
@@ -159,6 +170,7 @@ type DiagnosticSettingsFacts struct {
}
type MonitoringSinksFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Sinks []models.MonitoringSinkAsset
@@ -166,6 +178,7 @@ type MonitoringSinksFacts struct {
}
type KeyVaultFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
KeyVaults []models.KeyVaultAsset
@@ -173,6 +186,7 @@ type KeyVaultFacts struct {
}
type StorageFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
StorageAssets []models.StorageAsset
@@ -180,6 +194,7 @@ type StorageFacts struct {
}
type ResourceTrustsFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
StorageAssets []models.StorageAsset
@@ -188,6 +203,7 @@ type ResourceTrustsFacts struct {
}
type RelayFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Namespaces []models.RelayNamespaceAsset
@@ -223,6 +239,7 @@ type DNSFacts struct {
}
type AppServicesFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
AppServices []models.AppServiceAsset
@@ -251,6 +268,7 @@ type AzureMLFacts struct {
}
type EventGridFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Routes []models.EventGridRouteAsset
@@ -258,6 +276,7 @@ type EventGridFacts struct {
}
type LogicAppsFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Workflows []models.LogicAppWorkflowAsset
@@ -314,6 +333,7 @@ type NICsFacts struct {
}
type VMsFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
VMAssets []models.VmAsset
@@ -321,6 +341,7 @@ type VMsFacts struct {
}
type VMExtensionsFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
VMExtensions []models.VMExtensionAsset
@@ -342,26 +363,36 @@ type WorkloadsFacts struct {
}
type RBACFacts struct {
- TenantID string
- Principals []models.Principal
- Scopes []models.ScopeRef
- RoleAssignments []models.RoleAssignment
- Issues []models.Issue
+ TenantID string
+ SubscriptionID string
+ CurrentPrincipal models.Principal
+ TokenSource string
+ AuthMode string
+ Principals []models.Principal
+ Scopes []models.ScopeRef
+ RoleAssignments []models.RoleAssignment
+ Issues []models.Issue
}
type PermissionsFacts struct {
- TenantID string
- SubscriptionID string
- Permissions []PermissionFact
- Principals []PermissionPrincipalFact
- Issues []models.Issue
+ TenantID string
+ SubscriptionID string
+ CurrentPrincipal models.Principal
+ TokenSource string
+ AuthMode string
+ Permissions []PermissionFact
+ Principals []PermissionPrincipalFact
+ Issues []models.Issue
}
type PrincipalsFacts struct {
- TenantID string
- SubscriptionID string
- Principals []models.PrincipalSummary
- Issues []models.Issue
+ TenantID string
+ SubscriptionID string
+ CurrentPrincipal models.Principal
+ TokenSource string
+ AuthMode string
+ Principals []models.PrincipalSummary
+ Issues []models.Issue
}
type PrivescFacts struct {
@@ -414,6 +445,7 @@ type RoleTrustsFacts struct {
}
type ManagedIdentitiesFacts struct {
+ ArtifactIdentityFacts
TenantID string
SubscriptionID string
Identities []models.ManagedIdentity
@@ -455,6 +487,14 @@ func (StaticProvider) WhoAmI(_ context.Context, tenant string, subscription stri
}, nil
}
+func staticArtifactIdentityFacts(session fixtureSession) ArtifactIdentityFacts {
+ return ArtifactIdentityFacts{
+ CurrentPrincipal: session.Principal,
+ TokenSource: "fixture",
+ AuthMode: "fixture",
+ }
+}
+
func (StaticProvider) Inventory(_ context.Context, tenant string, subscription string) (InventoryFacts, error) {
session := staticFixtureSession(tenant, subscription)
return InventoryFacts{
@@ -581,8 +621,9 @@ func (StaticProvider) AppServices(_ context.Context, tenant string, subscription
publicAPISensitiveSettingCount := 1
return AppServicesFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
AppServices: []models.AppServiceAsset{
{
AppSettingsCount: &emptyMIAppSettingsCount,
@@ -1227,8 +1268,9 @@ func (StaticProvider) VMs(_ context.Context, tenant string, subscription string)
subscriptionID := session.Subscription.ID
return VMsFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
VMAssets: []models.VmAsset{
{
ID: "/subscriptions/" + subscriptionID + "/resourceGroups/rg-workload/providers/" +
@@ -1599,7 +1641,11 @@ func (StaticProvider) Workloads(_ context.Context, tenant string, subscription s
func (StaticProvider) RBAC(_ context.Context, tenant string, subscription string) (RBACFacts, error) {
session := staticFixtureSession(tenant, subscription)
return RBACFacts{
- TenantID: session.TenantID,
+ TenantID: session.TenantID,
+ SubscriptionID: session.Subscription.ID,
+ CurrentPrincipal: session.Principal,
+ TokenSource: session.TokenSource,
+ AuthMode: session.AuthMode,
Principals: append([]models.Principal{
session.Principal,
{
@@ -1641,8 +1687,11 @@ func (StaticProvider) RBAC(_ context.Context, tenant string, subscription string
func (StaticProvider) Permissions(_ context.Context, tenant string, subscription string) (PermissionsFacts, error) {
session := staticFixtureSession(tenant, subscription)
return PermissionsFacts{
- TenantID: session.TenantID,
- SubscriptionID: session.Subscription.ID,
+ TenantID: session.TenantID,
+ SubscriptionID: session.Subscription.ID,
+ CurrentPrincipal: session.Principal,
+ TokenSource: session.TokenSource,
+ AuthMode: session.AuthMode,
Permissions: append([]PermissionFact{
{
PrincipalID: session.Principal.ID,
@@ -2043,8 +2092,9 @@ func (StaticProvider) ManagedIdentities(_ context.Context, tenant string, subscr
subscriptionID := session.Subscription.ID
return ManagedIdentitiesFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
Identities: append([]models.ManagedIdentity{
{
ID: "/subscriptions/" + subscriptionID + "/resourceGroups/rg-workload/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-app",
@@ -2169,6 +2219,10 @@ func (StaticProvider) ManagedIdentities(_ context.Context, tenant string, subscr
}, nil
}
+func (provider StaticProvider) ManagedIdentitiesFromSources(ctx context.Context, tenant string, subscription string, _ *RBACFacts) (ManagedIdentitiesFacts, error) {
+ return provider.ManagedIdentities(ctx, tenant, subscription)
+}
+
func (StaticProvider) EnvVars(_ context.Context, tenant string, subscription string) (EnvVarsFacts, error) {
session := staticFixtureSession(tenant, subscription)
subscriptionID := session.Subscription.ID
@@ -2554,6 +2608,8 @@ type fixtureSession struct {
Subscription models.SubscriptionRef
Principal models.Principal
EffectiveScopes []models.ScopeRef
+ TokenSource string
+ AuthMode string
}
func staticFixtureSession(tenant string, subscription string) fixtureSession {
@@ -2586,5 +2642,7 @@ func staticFixtureSession(tenant string, subscription string) fixtureSession {
DisplayName: subscriptionRef.DisplayName,
},
},
+ TokenSource: "fixture",
+ AuthMode: "fixture",
}
}
diff --git a/internal/providers/static_appinsights.go b/internal/providers/static_appinsights.go
index cf52871..59dd29c 100644
--- a/internal/providers/static_appinsights.go
+++ b/internal/providers/static_appinsights.go
@@ -14,8 +14,9 @@ func (StaticProvider) AppInsights(_ context.Context, tenant string, subscription
functionID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders"
facts := AppInsightsFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
Components: []models.AppInsightsComponent{
{
ID: componentID,
diff --git a/internal/providers/static_automation.go b/internal/providers/static_automation.go
index 6fe5684..e9b2cb0 100644
--- a/internal/providers/static_automation.go
+++ b/internal/providers/static_automation.go
@@ -11,8 +11,9 @@ func (StaticProvider) Automation(_ context.Context, tenant string, subscription
subscriptionID := session.Subscription.ID
return AutomationFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
AutomationAccounts: []models.AutomationAccountAsset{
{
ID: "/subscriptions/" + subscriptionID + "/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet",
diff --git a/internal/providers/static_dcr.go b/internal/providers/static_dcr.go
index 1193de3..3777751 100644
--- a/internal/providers/static_dcr.go
+++ b/internal/providers/static_dcr.go
@@ -21,8 +21,9 @@ func (StaticProvider) DCR(_ context.Context, tenant string, subscription string)
transformLength := 84
facts := DCRFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
DCRs: []models.DCRAsset{
{
ID: prodDCRID,
diff --git a/internal/providers/static_diagnostic_settings.go b/internal/providers/static_diagnostic_settings.go
index e4afc96..d450484 100644
--- a/internal/providers/static_diagnostic_settings.go
+++ b/internal/providers/static_diagnostic_settings.go
@@ -56,8 +56,9 @@ func (StaticProvider) DiagnosticSettings(_ context.Context, tenant string, subsc
storageSetting.Summary = diagnosticSettingSummary(storageSetting)
facts := DiagnosticSettingsFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
Sources: []models.DiagnosticSettingsSource{
{
ID: keyVaultID,
diff --git a/internal/providers/static_event_grid.go b/internal/providers/static_event_grid.go
index 249d2ac..1b1bab3 100644
--- a/internal/providers/static_event_grid.go
+++ b/internal/providers/static_event_grid.go
@@ -11,8 +11,9 @@ func (StaticProvider) EventGrid(_ context.Context, tenant string, subscription s
subscriptionID := session.Subscription.ID
return EventGridFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
Routes: []models.EventGridRouteAsset{
{
ID: "/subscriptions/" + subscriptionID + "/resourceGroups/rg-storage/providers/Microsoft.Storage/storageAccounts/stlanding/providers/Microsoft.EventGrid/eventSubscriptions/to-function",
diff --git a/internal/providers/static_logic_apps.go b/internal/providers/static_logic_apps.go
index 0cdf3e4..dceac58 100644
--- a/internal/providers/static_logic_apps.go
+++ b/internal/providers/static_logic_apps.go
@@ -43,9 +43,10 @@ func (StaticProvider) LogicApps(_ context.Context, tenant string, subscription s
}
return LogicAppsFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
- Workflows: workflows,
- Issues: []models.Issue{},
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
+ Workflows: workflows,
+ Issues: []models.Issue{},
}, nil
}
diff --git a/internal/providers/static_monitoring_sinks.go b/internal/providers/static_monitoring_sinks.go
index 4700338..451ec15 100644
--- a/internal/providers/static_monitoring_sinks.go
+++ b/internal/providers/static_monitoring_sinks.go
@@ -7,14 +7,24 @@ import (
)
func (provider StaticProvider) MonitoringSinks(ctx context.Context, tenant string, subscription string) (MonitoringSinksFacts, error) {
+ return provider.MonitoringSinksFromSources(ctx, tenant, subscription, nil, nil)
+}
+
+func (provider StaticProvider) MonitoringSinksFromSources(ctx context.Context, tenant string, subscription string, dcrFacts *DCRFacts, diagnosticFacts *DiagnosticSettingsFacts) (MonitoringSinksFacts, error) {
session := staticFixtureSession(tenant, subscription)
subscriptionID := session.Subscription.ID
workspaceID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod"
eventHubRuleID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"
storageID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"
- dcrFacts, _ := provider.DCR(ctx, tenant, subscription)
- diagnosticFacts, _ := provider.DiagnosticSettings(ctx, tenant, subscription)
+ if dcrFacts == nil {
+ collected, _ := provider.DCR(ctx, tenant, subscription)
+ dcrFacts = &collected
+ }
+ if diagnosticFacts == nil {
+ collected, _ := provider.DiagnosticSettings(ctx, tenant, subscription)
+ diagnosticFacts = &collected
+ }
sinks := []models.MonitoringSinkAsset{
{
@@ -54,9 +64,10 @@ func (provider StaticProvider) MonitoringSinks(ctx context.Context, tenant strin
sinks = monitoringSinksFinalize(sinks)
return MonitoringSinksFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
- Sinks: sinks,
- Issues: []models.Issue{},
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
+ Sinks: sinks,
+ Issues: []models.Issue{},
}, nil
}
diff --git a/internal/providers/static_principals.go b/internal/providers/static_principals.go
index acd4081..1cd232e 100644
--- a/internal/providers/static_principals.go
+++ b/internal/providers/static_principals.go
@@ -112,9 +112,12 @@ func (provider StaticProvider) Principals(ctx context.Context, tenant string, su
principals = append(principals, staticAzureMLPrincipalSummaries(session.TenantID, subscriptionID)...)
return PrincipalsFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
- Principals: principals,
- Issues: []models.Issue{},
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
+ CurrentPrincipal: session.Principal,
+ TokenSource: "fixture",
+ AuthMode: "fixture",
+ Principals: principals,
+ Issues: []models.Issue{},
}, nil
}
diff --git a/internal/providers/static_relay.go b/internal/providers/static_relay.go
index 3ab7048..5c617a3 100644
--- a/internal/providers/static_relay.go
+++ b/internal/providers/static_relay.go
@@ -24,8 +24,9 @@ func (StaticProvider) Relay(_ context.Context, tenant string, subscription strin
authRules := 2
return RelayFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
Namespaces: []models.RelayNamespaceAsset{
{
ID: namespaceID,
diff --git a/internal/providers/static_resources.go b/internal/providers/static_resources.go
index 59fbb0e..14cd07d 100644
--- a/internal/providers/static_resources.go
+++ b/internal/providers/static_resources.go
@@ -370,8 +370,9 @@ func (StaticProvider) KeyVault(_ context.Context, tenant string, subscription st
privateVaultID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-secrets/providers/Microsoft.KeyVault/vaults/kvlabpriv01"
return KeyVaultFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
KeyVaults: []models.KeyVaultAsset{
{
AccessPolicyCount: 2,
@@ -617,8 +618,9 @@ func (StaticProvider) Storage(_ context.Context, tenant string, subscription str
privateID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stlabpriv01"
return StorageFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
StorageAssets: []models.StorageAsset{
{
AllowSharedKeyAccess: &trueValue,
@@ -1072,8 +1074,9 @@ func (StaticProvider) ApiMgmt(_ context.Context, tenant string, subscription str
workloadClientID := "99990000-0000-0000-0000-000000000002"
return ApiMgmtFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
ApiManagementServices: []models.ApiMgmtServiceAsset{
{
ID: serviceID,
diff --git a/internal/providers/static_vm_extensions.go b/internal/providers/static_vm_extensions.go
index f76a7a5..e120d6a 100644
--- a/internal/providers/static_vm_extensions.go
+++ b/internal/providers/static_vm_extensions.go
@@ -14,8 +14,9 @@ func (StaticProvider) VMExtensions(_ context.Context, tenant string, subscriptio
identityID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-workload/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-app"
return VMExtensionsFacts{
- TenantID: session.TenantID,
- SubscriptionID: subscriptionID,
+ ArtifactIdentityFacts: staticArtifactIdentityFacts(session),
+ TenantID: session.TenantID,
+ SubscriptionID: subscriptionID,
VMExtensions: []models.VMExtensionAsset{
{
AutoUpgradeMinorVersion: boolPtr(true),
diff --git a/testdata/api-mgmt.golden.json b/testdata/api-mgmt.golden.json
index c5a4722..23c16ee 100644
--- a/testdata/api-mgmt.golden.json
+++ b/testdata/api-mgmt.golden.json
@@ -66,6 +66,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/app-services.golden.json b/testdata/app-services.golden.json
index 24c640a..c43ba68 100644
--- a/testdata/app-services.golden.json
+++ b/testdata/app-services.golden.json
@@ -84,6 +84,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/appinsights.golden.json b/testdata/appinsights.golden.json
index bab428e..574b05e 100644
--- a/testdata/appinsights.golden.json
+++ b/testdata/appinsights.golden.json
@@ -71,6 +71,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/automation.golden.json b/testdata/automation.golden.json
index 6040fa4..9ef9f43 100644
--- a/testdata/automation.golden.json
+++ b/testdata/automation.golden.json
@@ -5,7 +5,17 @@
"generated_at": "2026-04-13T12:00:00Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"subscription_id": "22222222-2222-2222-2222-222222222222",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"automation_accounts": [
{
diff --git a/testdata/dcr.golden.json b/testdata/dcr.golden.json
index b2c5660..568bc88 100644
--- a/testdata/dcr.golden.json
+++ b/testdata/dcr.golden.json
@@ -172,6 +172,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/diagnostic-settings.golden.json b/testdata/diagnostic-settings.golden.json
index 5dd09e1..7dc1eaa 100644
--- a/testdata/diagnostic-settings.golden.json
+++ b/testdata/diagnostic-settings.golden.json
@@ -210,6 +210,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/event-grid.golden.json b/testdata/event-grid.golden.json
index 4c3917c..97fa30b 100644
--- a/testdata/event-grid.golden.json
+++ b/testdata/event-grid.golden.json
@@ -7,7 +7,17 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"routes": [
{
diff --git a/testdata/keyvault.golden.json b/testdata/keyvault.golden.json
index 90db827..58b0fb9 100644
--- a/testdata/keyvault.golden.json
+++ b/testdata/keyvault.golden.json
@@ -105,12 +105,22 @@
}
],
"metadata": {
+ "auth_mode": "fixture",
"command": "keyvault",
"devops_organization": null,
"generated_at": "2026-04-13T12:00:00Z",
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/logic-apps.golden.json b/testdata/logic-apps.golden.json
index 2d56595..fac2bd4 100644
--- a/testdata/logic-apps.golden.json
+++ b/testdata/logic-apps.golden.json
@@ -7,7 +7,17 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"workflows": [
{
diff --git a/testdata/managed-identities.golden.json b/testdata/managed-identities.golden.json
index 12adac0..c45995d 100644
--- a/testdata/managed-identities.golden.json
+++ b/testdata/managed-identities.golden.json
@@ -6,8 +6,17 @@
"tenant_id": "11111111-1111-1111-1111-111111111111",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"devops_organization": null,
- "token_source": null,
- "auth_mode": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"identities": [
{
diff --git a/testdata/monitoring-sinks.golden.json b/testdata/monitoring-sinks.golden.json
index 05200ca..15e8dd3 100644
--- a/testdata/monitoring-sinks.golden.json
+++ b/testdata/monitoring-sinks.golden.json
@@ -93,6 +93,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
}
}
diff --git a/testdata/permissions.golden.json b/testdata/permissions.golden.json
index 72516a2..7516e90 100644
--- a/testdata/permissions.golden.json
+++ b/testdata/permissions.golden.json
@@ -6,8 +6,17 @@
"tenant_id": "11111111-1111-1111-1111-111111111111",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"devops_organization": null,
- "token_source": null,
- "auth_mode": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"permissions": [
{
diff --git a/testdata/principals.golden.json b/testdata/principals.golden.json
index 564d5b9..d9fe88d 100644
--- a/testdata/principals.golden.json
+++ b/testdata/principals.golden.json
@@ -1,14 +1,23 @@
{
"issues": [],
"metadata": {
- "auth_mode": null,
+ "auth_mode": "fixture",
"command": "principals",
"devops_organization": null,
"generated_at": "2026-04-13T12:00:00Z",
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"principals": [
{
diff --git a/testdata/rbac.golden.json b/testdata/rbac.golden.json
index 13908f2..0e1a523 100644
--- a/testdata/rbac.golden.json
+++ b/testdata/rbac.golden.json
@@ -1,13 +1,23 @@
{
"issues": [],
"metadata": {
+ "auth_mode": "fixture",
"command": "rbac",
"devops_organization": null,
"generated_at": "2026-04-13T12:00:00Z",
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"principals": [
{
diff --git a/testdata/relay.golden.json b/testdata/relay.golden.json
index 38263ca..0d567f0 100644
--- a/testdata/relay.golden.json
+++ b/testdata/relay.golden.json
@@ -7,7 +7,17 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"namespaces": [
{
diff --git a/testdata/resource-trusts.golden.json b/testdata/resource-trusts.golden.json
index b0a0ab9..402dbf5 100644
--- a/testdata/resource-trusts.golden.json
+++ b/testdata/resource-trusts.golden.json
@@ -48,13 +48,23 @@
],
"issues": [],
"metadata": {
+ "auth_mode": "fixture",
"command": "resource-trusts",
"devops_organization": null,
"generated_at": "2026-04-13T12:00:00Z",
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"resource_trusts": [
{
diff --git a/testdata/storage.golden.json b/testdata/storage.golden.json
index 655a0d8..56fe307 100644
--- a/testdata/storage.golden.json
+++ b/testdata/storage.golden.json
@@ -21,13 +21,23 @@
],
"issues": [],
"metadata": {
+ "auth_mode": "fixture",
"command": "storage",
"devops_organization": null,
"generated_at": "2026-04-13T12:00:00Z",
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"storage_assets": [
{
diff --git a/testdata/vm-extensions.golden.json b/testdata/vm-extensions.golden.json
index c17cef0..6b66c9f 100644
--- a/testdata/vm-extensions.golden.json
+++ b/testdata/vm-extensions.golden.json
@@ -7,7 +7,17 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "auth_mode": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"vm_extensions": [
{
diff --git a/testdata/vms.golden.json b/testdata/vms.golden.json
index 1166456..5690eeb 100644
--- a/testdata/vms.golden.json
+++ b/testdata/vms.golden.json
@@ -13,13 +13,23 @@
],
"issues": [],
"metadata": {
+ "auth_mode": "fixture",
"command": "vms",
"devops_organization": null,
"generated_at": "2026-04-13T12:00:00Z",
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": null
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"vm_assets": [
{
diff --git a/testdata/whoami.golden.json b/testdata/whoami.golden.json
index 2392ae5..0016a3d 100644
--- a/testdata/whoami.golden.json
+++ b/testdata/whoami.golden.json
@@ -15,7 +15,16 @@
"schema_version": "1.4.0",
"subscription_id": "22222222-2222-2222-2222-222222222222",
"tenant_id": "11111111-1111-1111-1111-111111111111",
- "token_source": "fixture"
+ "token_source": "fixture",
+ "artifact_context": {
+ "tool_version": "dev",
+ "current_principal": {
+ "id": "33333333-3333-3333-3333-333333333333",
+ "principal_type": "ServicePrincipal",
+ "tenant_id": "11111111-1111-1111-1111-111111111111"
+ },
+ "command_options": {}
+ }
},
"principal": {
"display_name": "azurefox-lab-sp",