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 @@ HarrierOps Azure logo

-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",