diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a78ad84..323fc77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,8 +33,7 @@ jobs: run: | set -euo pipefail go build -o /tmp/ho-azure ./cmd/azurefox - /tmp/ho-azure help >/dev/null - AZUREFOX_PROVIDER=static /tmp/ho-azure whoami --output json >/dev/null + scripts/release_smoke_unix.sh /tmp/ho-azure build: runs-on: ubuntu-latest @@ -111,6 +110,9 @@ jobs: permissions: contents: read steps: + - name: Checkout smoke script + uses: actions/checkout@v6 + - name: Download Linux amd64 artifact uses: actions/download-artifact@v8 with: @@ -124,8 +126,7 @@ jobs: tar -xzf dist/ho-azure-linux-amd64.tar.gz -C extracted binary="./extracted/ho-azure-linux-amd64/ho-azure" chmod +x "$binary" - "$binary" help >/dev/null - AZUREFOX_PROVIDER=static "$binary" whoami --output json >/dev/null + scripts/release_smoke_unix.sh "$binary" verify-linux-x64-containers: name: Verify ${{ matrix.target_name }} runtime @@ -160,15 +161,19 @@ jobs: set -euo pipefail binary_path="${PWD}/extracted/ho-azure-linux-amd64/ho-azure" chmod +x "$binary_path" - docker run --rm \ - -v "$binary_path:/usr/local/bin/ho-azure:ro" \ - --entrypoint /usr/local/bin/ho-azure \ - "${{ matrix.image }}" help >/dev/null - docker run --rm \ - -e AZUREFOX_PROVIDER=static \ - -v "$binary_path:/usr/local/bin/ho-azure:ro" \ - --entrypoint /usr/local/bin/ho-azure \ - "${{ matrix.image }}" whoami --output json >/dev/null + smoke_container() { + env_args="$1" + shift + docker run --rm \ + $env_args \ + -v "$binary_path:/usr/local/bin/ho-azure:ro" \ + --entrypoint /usr/local/bin/ho-azure \ + "${{ matrix.image }}" "$@" >/dev/null + } + smoke_container "" help + smoke_container "-e AZUREFOX_PROVIDER=static" whoami --output json + smoke_container "-e AZUREFOX_PROVIDER=static" chains credential-path --output json + smoke_container "-e AZUREFOX_PROVIDER=static" persistence automation --output json verify-ubuntu-arm64: name: Verify Ubuntu LTS arm64 runtime @@ -178,6 +183,9 @@ jobs: permissions: contents: read steps: + - name: Checkout smoke script + uses: actions/checkout@v6 + - name: Download Linux arm64 artifact uses: actions/download-artifact@v8 with: @@ -191,8 +199,7 @@ jobs: tar -xzf dist/ho-azure-linux-arm64.tar.gz -C extracted binary="./extracted/ho-azure-linux-arm64/ho-azure" chmod +x "$binary" - "$binary" help >/dev/null - AZUREFOX_PROVIDER=static "$binary" whoami --output json >/dev/null + scripts/release_smoke_unix.sh "$binary" verify-windows-x64: name: Verify Windows x64 runtime @@ -218,9 +225,14 @@ jobs: & $binary help | Out-Null $env:AZUREFOX_PROVIDER = "static" & $binary whoami --output json | Out-Null - - publish-release-containers: - name: Publish ${{ matrix.target_name }} release container + & $binary chains credential-path --output json | Out-Null + & $binary persistence automation --output json | Out-Null + & $binary evasion dcr --output json | Out-Null + & $binary resourcehijacking api-mgmt --output json | Out-Null + & $binary pathmasking relay --output json | Out-Null + + publish-release-container: + name: Publish Linux multi-arch release container runs-on: ubuntu-latest needs: - build @@ -231,16 +243,6 @@ jobs: permissions: contents: read packages: write - strategy: - fail-fast: false - matrix: - include: - - target_name: Debian stable x64 - flavor: debian - base_image: debian:stable-slim - - target_name: Kali rolling x64 - flavor: kali - base_image: kalilinux/kali-rolling steps: - name: Checkout uses: actions/checkout@v6 @@ -249,32 +251,49 @@ jobs: uses: actions/download-artifact@v8 with: name: release-linux-amd64 - path: dist + path: dist/amd64 + + - name: Download Linux arm64 artifact + uses: actions/download-artifact@v8 + with: + name: release-linux-arm64 + path: dist/arm64 - name: Prepare container build context run: | set -euo pipefail - context_dir="${RUNNER_TEMP}/container-${{ matrix.flavor }}" + context_dir="${RUNNER_TEMP}/container-linux" mkdir -p "$context_dir" - tar -xzf dist/ho-azure-linux-amd64.tar.gz -C "$context_dir" - cp "$context_dir/ho-azure-linux-amd64/ho-azure" "$context_dir/ho-azure" + tar -xzf dist/amd64/ho-azure-linux-amd64.tar.gz -C "$context_dir" + tar -xzf dist/arm64/ho-azure-linux-arm64.tar.gz -C "$context_dir" + cp "$context_dir/ho-azure-linux-amd64/ho-azure" "$context_dir/ho-azure-amd64" + cp "$context_dir/ho-azure-linux-arm64/ho-azure" "$context_dir/ho-azure-arm64" cp packaging/container/Dockerfile.release-linux "$context_dir/Dockerfile" - chmod +x "$context_dir/ho-azure" + chmod +x "$context_dir/ho-azure-amd64" "$context_dir/ho-azure-arm64" echo "CONTEXT_DIR=$context_dir" >> "$GITHUB_ENV" - - name: Build and smoke-test release container + - name: Setup QEMU + uses: docker/setup-qemu-action@v3 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and smoke-test local amd64 release container run: | set -euo pipefail owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" - image="ghcr.io/${owner}/ho-azure-${{ matrix.flavor }}" + image="ghcr.io/${owner}/ho-azure" version_tag="${GITHUB_REF_NAME}" docker build \ - --build-arg BASE_IMAGE=${{ matrix.base_image }} \ - -t "${image}:${version_tag}" \ - -t "${image}:latest" \ + --platform linux/amd64 \ + --build-arg BASE_IMAGE=debian:stable-slim \ + --build-arg TARGETARCH=amd64 \ + -t "${image}:local-amd64-smoke" \ "$CONTEXT_DIR" - docker run --rm "${image}:${version_tag}" help >/dev/null - docker run --rm -e AZUREFOX_PROVIDER=static "${image}:${version_tag}" whoami --output json >/dev/null + docker run --rm "${image}:local-amd64-smoke" help >/dev/null + for command in "whoami --output json" "chains credential-path --output json" "persistence automation --output json" "evasion dcr --output json" "resourcehijacking api-mgmt --output json" "pathmasking relay --output json"; do + docker run --rm -e AZUREFOX_PROVIDER=static "${image}:local-amd64-smoke" $command >/dev/null + done echo "IMAGE_NAME=$image" >> "$GITHUB_ENV" echo "IMAGE_TAG=$version_tag" >> "$GITHUB_ENV" @@ -288,8 +307,45 @@ jobs: - name: Push release container run: | set -euo pipefail - docker push "${IMAGE_NAME}:${IMAGE_TAG}" - docker push "${IMAGE_NAME}:latest" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg BASE_IMAGE=debian:stable-slim \ + -t "${IMAGE_NAME}:${IMAGE_TAG}" \ + -t "${IMAGE_NAME}:latest" \ + --push \ + "$CONTEXT_DIR" + + - name: Smoke-test pushed release container + run: | + set -euo pipefail + for platform in linux/amd64 linux/arm64; do + docker run --rm --platform "$platform" "${IMAGE_NAME}:${IMAGE_TAG}" help >/dev/null + for command in "whoami --output json" "chains credential-path --output json" "persistence automation --output json" "evasion dcr --output json" "resourcehijacking api-mgmt --output json" "pathmasking relay --output json"; do + docker run --rm --platform "$platform" -e AZUREFOX_PROVIDER=static "${IMAGE_NAME}:${IMAGE_TAG}" $command >/dev/null + done + done + + - name: Verify public container pull and run + run: | + set -euo pipefail + docker logout ghcr.io || true + for attempt in 1 2 3 4 5; do + manifest="$(docker manifest inspect "${IMAGE_NAME}:${IMAGE_TAG}" 2>/dev/null || true)" + if echo "$manifest" | grep -Fq '"architecture": "amd64"' \ + && echo "$manifest" | grep -Fq '"architecture": "arm64"'; then + for platform in linux/amd64 linux/arm64; do + docker run --rm --pull=always --platform "$platform" "${IMAGE_NAME}:${IMAGE_TAG}" help >/dev/null + for command in "whoami --output json" "chains credential-path --output json" "persistence automation --output json" "evasion dcr --output json" "resourcehijacking api-mgmt --output json" "pathmasking relay --output json"; do + docker run --rm --pull=always --platform "$platform" -e AZUREFOX_PROVIDER=static "${IMAGE_NAME}:${IMAGE_TAG}" $command >/dev/null + done + done + exit 0 + fi + echo "Multi-arch container manifest is not publicly readable yet; retrying (${attempt}/5)." + sleep 6 + done + echo "Container ${IMAGE_NAME}:${IMAGE_TAG} is not publicly pullable/runnable as linux/amd64 and linux/arm64 without GHCR authentication." >&2 + exit 1 github-release: runs-on: ubuntu-latest @@ -299,7 +355,7 @@ jobs: - verify-linux-x64-containers - verify-ubuntu-arm64 - verify-windows-x64 - - publish-release-containers + - publish-release-container permissions: contents: write steps: @@ -330,30 +386,33 @@ jobs: owner="$(echo "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')" release_header_file="$(mktemp)" generated_notes_file="$(mktemp)" + notes_body_file="$(mktemp)" final_notes_file="$(mktemp)" cat >"$release_header_file" </dev/null 2>&1; then gh release upload "${GITHUB_REF_NAME}" "${assets[@]}" --clobber gh release view "${GITHUB_REF_NAME}" --json body --jq .body >"$generated_notes_file" - if grep -Fq "## Runtime-verified containers" "$generated_notes_file"; then - gh release edit "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --verify-tag - else - cat "$release_header_file" "$generated_notes_file" >"$final_notes_file" - gh release edit "${GITHUB_REF_NAME}" \ - --title "${GITHUB_REF_NAME}" \ - --notes-file "$final_notes_file" \ - --verify-tag - fi + awk ' + /^## Containers$/ || /^## Runtime-verified containers$/ { skip=1; next } + skip && /^## / { skip=0 } + !skip { print } + ' "$generated_notes_file" >"$notes_body_file" + cat "$release_header_file" "$notes_body_file" >"$final_notes_file" + gh release edit "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file "$final_notes_file" \ + --verify-tag else gh api \ -X POST \ @@ -368,17 +427,35 @@ jobs: --verify-tag fi - update-homebrew-tap: + dispatch-stable-homebrew-tap: + name: Dispatch stable Homebrew tap update runs-on: ubuntu-latest needs: - github-release permissions: contents: read steps: - - name: Dispatch tap update + - name: Resolve release source + id: meta + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + source_url="https://github.com/HarrierSecurity/HarrierOps-Azure/archive/refs/tags/${RELEASE_TAG}.tar.gz" + curl -L --fail "$source_url" -o /tmp/ho-azure-source.tar.gz + sha256="$(sha256sum /tmp/ho-azure-source.tar.gz | awk '{print $1}')" + { + echo "version=$RELEASE_TAG" + echo "source_url=$source_url" + echo "sha256=$sha256" + } >> "$GITHUB_OUTPUT" + + - name: Dispatch stable tap update env: TOKEN: ${{ secrets.HOMEBREW_TAP_REPO_TOKEN }} - VERSION: ${{ github.ref_name }} + VERSION: ${{ steps.meta.outputs.version }} + SOURCE_URL: ${{ steps.meta.outputs.source_url }} + SHA256: ${{ steps.meta.outputs.sha256 }} run: | set -euo pipefail if [ -z "$TOKEN" ]; then @@ -386,23 +463,19 @@ jobs: exit 1 fi - source_url="https://github.com/HarrierSecurity/HarrierOps-Azure/archive/refs/tags/${VERSION}.tar.gz" - curl -L --fail "$source_url" -o /tmp/ho-azure-source.tar.gz - sha256="$(sha256sum /tmp/ho-azure-source.tar.gz | awk '{print $1}')" - json_payload="$(cat <> "$GITHUB_OUTPUT" + + - name: Dispatch preview tap update + env: + TOKEN: ${{ secrets.HOMEBREW_TAP_REPO_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + SOURCE_URL: ${{ steps.meta.outputs.source_url }} + SHA256: ${{ steps.meta.outputs.sha256 }} + run: | + set -euo pipefail + if [ -z "$TOKEN" ]; then + echo "missing secret HOMEBREW_TAP_REPO_TOKEN" >&2 + exit 1 + fi + + json_payload="$(cat <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. | ### Flat Commands @@ -116,9 +119,9 @@ ho-azure permissions | `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`, `databases`, `resource-trusts` | +| `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` | +| `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` | ## Need A Test Lab? @@ -326,8 +329,8 @@ Artifact intent: ## Sections And Grouped Commands -HarrierOps Azure keeps flat standalone commands and also supports grouped execution through `chains` -and `persistence`. +HarrierOps Azure keeps flat standalone commands and also supports grouped execution through `chains`, +`persistence`, `evasion`, `resourcehijacking`, and `pathmasking`. For narrower current work: @@ -335,18 +338,24 @@ For narrower current work: - 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`, `databases`, `resource-trusts` +- `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` +- `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` +- `orchestration`: `chains`, `persistence`, `evasion`, `resourcehijacking`, `pathmasking` Current `chains` families: @@ -366,6 +375,24 @@ Current `persistence` surfaces: - `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` + ## Help HarrierOps Azure supports generic and scoped help: @@ -382,7 +409,8 @@ ho-azure -h permissions Command help includes ATT&CK cloud leads as investigation prompts, not proof that a technique occurred. -Help also points grouped follow-up toward `chains` and `persistence` where those presets exist. +Help also points grouped follow-up toward `chains`, `persistence`, `evasion`, +`resourcehijacking`, and `pathmasking` where those presets exist. For ad hoc demos or local testing, use a dedicated path like `--outdir ./ho-azure-demo` so artifacts do not pile up in the repo root. diff --git a/internal/cli/app.go b/internal/cli/app.go index 0c4bb39..2b92cc2 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -25,6 +25,15 @@ type sectionHelpTopic struct { OperatorGoal string } +type groupedCommandDescriptor struct { + UsageLabel string + ListLabel string + Example string + SetSelector func(*Options, string) + SurfaceNames func() []string + SurfaceLine func(string) (string, bool) +} + var ( helpFlags = map[string]struct{}{ "-h": {}, @@ -77,6 +86,78 @@ var ( OperatorGoal: "Reserved for future coverage.", }, } + groupedCommandDescriptors = map[string]groupedCommandDescriptor{ + "chains": { + UsageLabel: "family", + ListLabel: "Current families", + Example: "ho-azure chains deployment-path --output table", + SetSelector: func(options *Options, selector string) { options.ChainFamily = selector }, + SurfaceNames: contracts.FamilyNames, + SurfaceLine: func(name string) (string, bool) { + family, ok := contracts.Family(name) + if !ok { + return "", false + } + return fmt.Sprintf("%s: %s", family.Name, family.Summary), true + }, + }, + "persistence": { + UsageLabel: "surface", + ListLabel: "Current surfaces", + Example: "ho-azure persistence automation --output table", + SetSelector: func(options *Options, selector string) { options.PersistenceSurface = selector }, + SurfaceNames: contracts.PersistenceSurfaceNames, + SurfaceLine: func(name string) (string, bool) { + surface, ok := contracts.PersistenceSurface(name) + if !ok { + return "", false + } + return fmt.Sprintf("%s: %s", surface.Name, surface.Summary), true + }, + }, + "evasion": { + UsageLabel: "surface", + ListLabel: "Current surfaces", + Example: "ho-azure evasion dcr --output table", + SetSelector: func(options *Options, selector string) { options.EvasionSurface = selector }, + SurfaceNames: contracts.EvasionSurfaceNames, + SurfaceLine: func(name string) (string, bool) { + surface, ok := contracts.EvasionSurface(name) + if !ok { + return "", false + } + return fmt.Sprintf("%s: %s", surface.Name, surface.Summary), true + }, + }, + "resourcehijacking": { + UsageLabel: "surface", + ListLabel: "Current surfaces", + Example: "ho-azure resourcehijacking api-mgmt --output table", + SetSelector: func(options *Options, selector string) { options.ResourceHijackingSurface = selector }, + SurfaceNames: contracts.ResourceHijackingSurfaceNames, + SurfaceLine: func(name string) (string, bool) { + surface, ok := contracts.ResourceHijackingSurface(name) + if !ok { + return "", false + } + return fmt.Sprintf("%s: %s", surface.Name, surface.Summary), true + }, + }, + "pathmasking": { + UsageLabel: "surface", + ListLabel: "Current surfaces", + Example: "ho-azure pathmasking relay --output table", + SetSelector: func(options *Options, selector string) { options.PathMaskingSurface = selector }, + SurfaceNames: contracts.PathMaskingSurfaceNames, + SurfaceLine: func(name string) (string, bool) { + surface, ok := contracts.PathMaskingSurface(name) + if !ok { + return "", false + } + return fmt.Sprintf("%s: %s", surface.Name, surface.Summary), true + }, + }, + } ) func New(registry *commands.Registry) *App { @@ -135,14 +216,17 @@ func (app *App) Run(args []string, stdout io.Writer, stderr io.Writer) int { } response, err := app.registry.Run(context.Background(), commandName, commands.Request{ - Tenant: options.Tenant, - Subscription: options.Subscription, - DevOpsOrganization: options.DevOpsOrganization, - ChainFamily: options.ChainFamily, - PersistenceSurface: options.PersistenceSurface, - Output: options.Output, - RoleTrustsMode: options.RoleTrustsMode, - OutDir: options.OutDir, + Tenant: options.Tenant, + Subscription: options.Subscription, + DevOpsOrganization: options.DevOpsOrganization, + ChainFamily: options.ChainFamily, + PersistenceSurface: options.PersistenceSurface, + EvasionSurface: options.EvasionSurface, + ResourceHijackingSurface: options.ResourceHijackingSurface, + PathMaskingSurface: options.PathMaskingSurface, + Output: options.Output, + RoleTrustsMode: options.RoleTrustsMode, + OutDir: options.OutDir, }) if err != nil { if contract.Status != contracts.StatusImplemented { @@ -177,15 +261,18 @@ func (app *App) Run(args []string, stdout io.Writer, stderr io.Writer) int { } type Options struct { - Tenant string - Subscription string - DevOpsOrganization string - ChainFamily string - PersistenceSurface string - Output models.OutputMode - RoleTrustsMode models.RoleTrustsMode - OutDir string - Debug bool + Tenant string + Subscription string + DevOpsOrganization string + ChainFamily string + PersistenceSurface string + EvasionSurface string + ResourceHijackingSurface string + PathMaskingSurface string + Output models.OutputMode + RoleTrustsMode models.RoleTrustsMode + OutDir string + Debug bool } func parseOptions(commandName string, args []string, stderr io.Writer) (Options, error) { @@ -228,42 +315,15 @@ func parseOptions(commandName string, args []string, stderr io.Writer) (Options, } } - if commandName == "chains" && len(args) > 0 && !strings.HasPrefix(args[0], "-") { - if args[0] != "help" { - options.ChainFamily = args[0] - } - args = args[1:] - } - if commandName == "persistence" && len(args) > 0 && !strings.HasPrefix(args[0], "-") { - if args[0] != "help" { - options.PersistenceSurface = args[0] - } - args = args[1:] - } + args = consumeGroupedSelectorBeforeFlags(commandName, args, &options) if err := flags.Parse(args); err != nil { return Options{}, err } remainingArgs := flags.Args() - if commandName == "chains" { - switch len(remainingArgs) { - case 0: - case 1: - if remainingArgs[0] != "help" { - options.ChainFamily = remainingArgs[0] - } - default: - return Options{}, fmt.Errorf("unexpected arguments: %s", strings.Join(remainingArgs, " ")) - } - } else if commandName == "persistence" { - switch len(remainingArgs) { - case 0: - case 1: - if remainingArgs[0] != "help" { - options.PersistenceSurface = remainingArgs[0] - } - default: - return Options{}, fmt.Errorf("unexpected arguments: %s", strings.Join(remainingArgs, " ")) + if groupedCommandAcceptsSelector(commandName) { + if err := consumeGroupedSelectorAfterFlags(commandName, remainingArgs, &options); err != nil { + return Options{}, err } } else if len(remainingArgs) != 0 { return Options{}, fmt.Errorf("unexpected arguments: %s", strings.Join(remainingArgs, " ")) @@ -274,6 +334,41 @@ func parseOptions(commandName string, args []string, stderr io.Writer) (Options, return options, nil } +func consumeGroupedSelectorBeforeFlags(commandName string, args []string, options *Options) []string { + if !groupedCommandAcceptsSelector(commandName) || len(args) == 0 || strings.HasPrefix(args[0], "-") { + return args + } + setGroupedSelector(commandName, args[0], options) + return args[1:] +} + +func consumeGroupedSelectorAfterFlags(commandName string, args []string, options *Options) error { + switch len(args) { + case 0: + return nil + case 1: + setGroupedSelector(commandName, args[0], options) + return nil + default: + return fmt.Errorf("unexpected arguments: %s", strings.Join(args, " ")) + } +} + +func groupedCommandAcceptsSelector(commandName string) bool { + _, ok := groupedCommandDescriptors[commandName] + return ok +} + +func setGroupedSelector(commandName string, selector string, options *Options) { + if selector == "help" { + return + } + descriptor, ok := groupedCommandDescriptors[commandName] + if ok { + descriptor.SetSelector(options, selector) + } +} + func (app *App) rootHelp() string { var builder strings.Builder builder.WriteString("HO-Azure Help\n\n") @@ -303,7 +398,7 @@ func (app *App) rootHelp() string { } builder.WriteString("\nNotes:\n") builder.WriteString(" - Shared flags such as --tenant, --subscription, --output, and --outdir work before or after the command.\n") - builder.WriteString(" - Grouped `chains` and `persistence` help stays available while additional grouped surfaces land.\n") + builder.WriteString(" - Grouped `chains`, `persistence`, `evasion`, `resourcehijacking`, and `pathmasking` help stays available while additional grouped surfaces land.\n") builder.WriteString(" - Default output prefers exact claims when proven and bounded weaker claims when they stay honest and useful.\n") return builder.String() } @@ -342,26 +437,15 @@ func (app *App) commandHelp(name string) string { } builder.WriteString("\nExample:\n") builder.WriteString(fmt.Sprintf(" %s\n", commandExample(contract.Name))) - if name == "chains" { - builder.WriteString("\nUsage:\n ho-azure chains [family|help] [flags]\n") - builder.WriteString("\nCurrent families:\n") - for _, familyName := range contracts.FamilyNames() { - family, ok := contracts.Family(familyName) - if !ok { - continue - } - builder.WriteString(fmt.Sprintf(" %s: %s\n", family.Name, family.Summary)) - } - } - if name == "persistence" { - builder.WriteString("\nUsage:\n ho-azure persistence [surface|help] [flags]\n") - builder.WriteString("\nCurrent surfaces:\n") - for _, surfaceName := range contracts.PersistenceSurfaceNames() { - surface, ok := contracts.PersistenceSurface(surfaceName) + if descriptor, ok := groupedCommandDescriptors[name]; ok { + builder.WriteString(fmt.Sprintf("\nUsage:\n ho-azure %s [%s|help] [flags]\n", name, descriptor.UsageLabel)) + builder.WriteString("\n" + descriptor.ListLabel + ":\n") + for _, surfaceName := range descriptor.SurfaceNames() { + line, ok := descriptor.SurfaceLine(surfaceName) if !ok { continue } - builder.WriteString(fmt.Sprintf(" %s: %s\n", surface.Name, surface.Summary)) + builder.WriteString(" " + line + "\n") } } builder.WriteString("\nNotes:\n") @@ -413,11 +497,10 @@ func commandExample(name string) string { return "ho-azure --devops-organization contoso devops --output table" case "role-trusts": return "ho-azure role-trusts --mode full --output table" - case "chains": - return "ho-azure chains deployment-path --output table" - case "persistence": - return "ho-azure persistence automation --output table" default: + if descriptor, ok := groupedCommandDescriptors[name]; ok { + return descriptor.Example + } return fmt.Sprintf("ho-azure %s --output table", name) } } diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 2f3f555..37ed16c 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -109,6 +109,15 @@ func implementedArtifactCases() []artifactCase { explicitArtifactCase("persistence-logic-apps", []string{"persistence", "logic-apps", "--output", "json"}, "persistence"), explicitArtifactCase("persistence-functions", []string{"persistence", "functions", "--output", "json"}, "persistence"), explicitArtifactCase("persistence-webjobs", []string{"persistence", "webjobs", "--output", "json"}, "persistence"), + explicitArtifactCase("evasion-appinsights", []string{"evasion", "appinsights", "--output", "json"}, "evasion"), + explicitArtifactCase("evasion-dcr", []string{"evasion", "dcr", "--output", "json"}, "evasion"), + explicitArtifactCase("evasion-diagnostic-settings", []string{"evasion", "diagnostic-settings", "--output", "json"}, "evasion"), + explicitArtifactCase("resourcehijacking-api-mgmt", []string{"resourcehijacking", "api-mgmt", "--output", "json"}, "resourcehijacking"), + explicitArtifactCase("resourcehijacking-automation", []string{"resourcehijacking", "automation", "--output", "json"}, "resourcehijacking"), + explicitArtifactCase("resourcehijacking-logic-apps", []string{"resourcehijacking", "logic-apps", "--output", "json"}, "resourcehijacking"), + explicitArtifactCase("pathmasking-api-mgmt", []string{"pathmasking", "api-mgmt", "--output", "json"}, "pathmasking"), + explicitArtifactCase("pathmasking-logic-apps", []string{"pathmasking", "logic-apps", "--output", "json"}, "pathmasking"), + explicitArtifactCase("pathmasking-relay", []string{"pathmasking", "relay", "--output", "json"}, "pathmasking"), } { cases = append(cases, extra) } @@ -353,6 +362,33 @@ func TestPersistenceHelpMatchesOverviewJSON(t *testing.T) { } } +func TestEvasionHelpMatchesOverviewJSON(t *testing.T) { + overview, _ := runSuccess(t, "evasion", "--output", "json") + helpView, _ := runSuccess(t, "evasion", "help", "--output", "json") + assertMatchesGolden(t, overview, "evasion.golden.json") + if overview != helpView { + t.Fatalf("expected evasion help JSON to match overview\noverview:\n%s\nhelp:\n%s", overview, helpView) + } +} + +func TestResourceHijackingHelpMatchesOverviewJSON(t *testing.T) { + overview, _ := runSuccess(t, "resourcehijacking", "--output", "json") + helpView, _ := runSuccess(t, "resourcehijacking", "help", "--output", "json") + assertMatchesGolden(t, overview, "resourcehijacking.golden.json") + if overview != helpView { + t.Fatalf("expected resourcehijacking help JSON to match overview\noverview:\n%s\nhelp:\n%s", overview, helpView) + } +} + +func TestPathMaskingHelpMatchesOverviewJSON(t *testing.T) { + overview, _ := runSuccess(t, "pathmasking", "--output", "json") + helpView, _ := runSuccess(t, "pathmasking", "help", "--output", "json") + assertMatchesGolden(t, overview, "pathmasking.golden.json") + if overview != helpView { + t.Fatalf("expected pathmasking help JSON to match overview\noverview:\n%s\nhelp:\n%s", overview, helpView) + } +} + func TestArtifactGenerationWritesAllFormats(t *testing.T) { for _, tc := range implementedArtifactCases() { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/cli/golden_update_test.go b/internal/cli/golden_update_test.go new file mode 100644 index 0000000..e4af0eb --- /dev/null +++ b/internal/cli/golden_update_test.go @@ -0,0 +1,44 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestUpdateGoldens(t *testing.T) { + if os.Getenv("HO_AZURE_UPDATE_GOLDENS") != "1" { + t.Skip("set HO_AZURE_UPDATE_GOLDENS=1 to refresh deterministic CLI goldens") + } + + filter := strings.TrimSpace(os.Getenv("HO_AZURE_GOLDEN_FILTER")) + for _, artifact := range implementedArtifactCases() { + if filter != "" && !strings.Contains(artifact.name, filter) { + continue + } + + tempDir := t.TempDir() + args := append(append([]string{}, artifact.args...), "--outdir", tempDir) + jsonOut, _ := runSuccess(t, args...) + writeGolden(t, artifact.jsonGolden, jsonOut) + writeGolden(t, artifact.lootGolden, readFile(t, filepath.Join(tempDir, "loot", artifact.artifactBase+".json"))) + writeGolden(t, artifact.csvGolden, readFile(t, filepath.Join(tempDir, "csv", artifact.artifactBase+".csv"))) + + if artifact.tableGolden == "" { + continue + } + writeGolden(t, artifact.tableGolden, readFile(t, filepath.Join(tempDir, "table", artifact.artifactBase+".txt"))) + } +} + +func writeGolden(t *testing.T, name string, content string) { + t.Helper() + if name == "" { + return + } + path := filepath.Join("..", "..", "testdata", name) + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/commands/appinsights.go b/internal/commands/appinsights.go new file mode 100644 index 0000000..bca71ce --- /dev/null +++ b/internal/commands/appinsights.go @@ -0,0 +1,38 @@ +package commands + +import ( + "context" + "sort" + "time" + + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +func appInsightsHandler(provider providers.Provider, now func() time.Time) Handler { + return func(ctx context.Context, request Request) (any, error) { + facts, err := provider.AppInsights(ctx, request.Tenant, request.Subscription) + if err != nil { + return nil, err + } + components := append([]models.AppInsightsComponent{}, facts.Components...) + targets := append([]models.AppInsightsAppTarget{}, facts.Targets...) + sort.SliceStable(targets, func(i, j int) bool { + if appInsightsCommandRank(targets[i]) != appInsightsCommandRank(targets[j]) { + return appInsightsCommandRank(targets[i]) > appInsightsCommandRank(targets[j]) + } + return targets[i].Name < targets[j].Name + }) + return models.AppInsightsOutput{ + Components: components, + Targets: targets, + Findings: []models.Finding{}, + Issues: facts.Issues, + Metadata: runtimeCommandMetadata("appinsights", now, facts.TenantID, facts.SubscriptionID), + }, nil + } +} + +func appInsightsCommandRank(target models.AppInsightsAppTarget) int { + return len(target.FilteringClues)*3 + len(target.SamplingClues)*2 + len(target.LoggingLevelClues) + len(target.InstrumentationClues) +} diff --git a/internal/commands/chains.go b/internal/commands/chains.go index e70a6e6..4c2bc20 100644 --- a/internal/commands/chains.go +++ b/internal/commands/chains.go @@ -7,6 +7,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" "harrierops-azure/internal/contracts" @@ -253,41 +254,67 @@ func runCommandOutput[T any](ctx context.Context, request Request, handler Handl } type asyncCommandOutput[T any] struct { - result chan asyncCommandResult[T] + call *commandOutputCall + name string } -type asyncCommandResult[T any] struct { - value T +type commandOutputCall struct { + done chan struct{} + value any err error } type commandOutputGroup struct { limiter chan struct{} + mu *sync.Mutex + calls map[string]*commandOutputCall } func newCommandOutputGroup(limit int) commandOutputGroup { if limit < 1 { limit = 1 } - return commandOutputGroup{limiter: make(chan struct{}, limit)} + return commandOutputGroup{ + limiter: make(chan struct{}, limit), + mu: &sync.Mutex{}, + calls: map[string]*commandOutputCall{}, + } } func runGroupedCommandOutput[T any](group commandOutputGroup, ctx context.Context, request Request, handler Handler, name string) asyncCommandOutput[T] { - result := make(chan asyncCommandResult[T], 1) + 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) - result <- asyncCommandResult[T]{value: value, err: err} + call.value = value + call.err = err + close(call.done) }() - return asyncCommandOutput[T]{result: result} + return asyncCommandOutput[T]{call: call, name: name} } func (future asyncCommandOutput[T]) wait() (T, error) { - result := <-future.result - return result.value, result.err + var zero T + <-future.call.done + if future.call.err != nil { + return zero, 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 value, nil } func buildDatabaseTargetView(output models.DatabasesOutput) credentialPathTargetView { diff --git a/internal/commands/dcr.go b/internal/commands/dcr.go new file mode 100644 index 0000000..4cef08c --- /dev/null +++ b/internal/commands/dcr.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "time" + + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +func dcrHandler(provider providers.Provider, now func() time.Time) Handler { + return func(ctx context.Context, request Request) (any, error) { + facts, err := provider.DCR(ctx, request.Tenant, request.Subscription) + if err != nil { + return nil, err + } + + dcrs := sortedByLess(facts.DCRs, dcrLess) + + return models.DCROutput{ + DCRs: dcrs, + Findings: []models.Finding{}, + Issues: facts.Issues, + Metadata: runtimeCommandMetadata("dcr", now, facts.TenantID, facts.SubscriptionID), + }, nil + } +} + +func dcrLess(left models.DCRAsset, right models.DCRAsset) bool { + leftTransform := left.TransformationCount > 0 + rightTransform := right.TransformationCount > 0 + if leftTransform != rightTransform { + return leftTransform + } + + leftHighSignal := len(left.HighSignalStreams) + rightHighSignal := len(right.HighSignalStreams) + if leftHighSignal != rightHighSignal { + return leftHighSignal > rightHighSignal + } + + leftAssociations := left.AssociationCount + rightAssociations := right.AssociationCount + if leftAssociations != rightAssociations { + return leftAssociations > rightAssociations + } + + if left.Name != right.Name { + return left.Name < right.Name + } + return left.ID < right.ID +} diff --git a/internal/commands/diagnostic_settings.go b/internal/commands/diagnostic_settings.go new file mode 100644 index 0000000..c10f341 --- /dev/null +++ b/internal/commands/diagnostic_settings.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "sort" + "time" + + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +func diagnosticSettingsHandler(provider providers.Provider, now func() time.Time) Handler { + return func(ctx context.Context, request Request) (any, error) { + facts, err := provider.DiagnosticSettings(ctx, request.Tenant, request.Subscription) + if err != nil { + return nil, err + } + sources := append([]models.DiagnosticSettingsSource{}, facts.Sources...) + sort.SliceStable(sources, func(i, j int) bool { + if diagnosticSettingsCommandRank(sources[i]) != diagnosticSettingsCommandRank(sources[j]) { + return diagnosticSettingsCommandRank(sources[i]) > diagnosticSettingsCommandRank(sources[j]) + } + if sources[i].Type != sources[j].Type { + return sources[i].Type < sources[j].Type + } + return sources[i].Name < sources[j].Name + }) + return models.DiagnosticSettingsOutput{ + Sources: sources, + Findings: []models.Finding{}, + Issues: facts.Issues, + Metadata: runtimeCommandMetadata("diagnostic-settings", now, facts.TenantID, facts.SubscriptionID), + }, nil + } +} + +func diagnosticSettingsCommandRank(source models.DiagnosticSettingsSource) int { + rank := 0 + if source.HasNonWorkspaceDestination { + rank += 2 + } + if len(source.DisabledCategories) > 0 { + rank += 2 + } + if source.HasHighSignalLogPosture { + rank += 2 + } + if !source.HasDiagnosticSettings { + rank++ + } + return rank +} diff --git a/internal/commands/evasion.go b/internal/commands/evasion.go new file mode 100644 index 0000000..152e0a0 --- /dev/null +++ b/internal/commands/evasion.go @@ -0,0 +1,59 @@ +package commands + +import ( + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +const ( + evasionCurrentBehavior = "Grouped evasion walkthroughs. Use `ho-azure evasion` or `ho-azure evasion help` to list surfaces, then `ho-azure evasion ` to run an implemented surface." + evasionCommandState = contracts.StatusImplemented +) + +var ( + evasionInputModes = []string{"live"} + evasionPreferredArtifactMode = []string{"loot", "json"} +) + +var evasionSurfaceBuilders = map[string]groupedSurfaceBuilder{ + "appinsights": buildEvasionAppInsightsOutput, + "dcr": buildEvasionDCROutput, + "diagnostic-settings": buildEvasionDiagnosticSettingsOutput, +} + +func evasionHandler(provider providers.Provider, now func() time.Time) Handler { + return groupedFamilyHandler(provider, now, evasionFamilyConfig()) +} + +func buildEvasionOverview(now func() time.Time, request Request, selectedSurface *string) any { + config := evasionFamilyConfig() + return models.EvasionOverviewOutput{ + Metadata: scopedMetadata(now, request, request.Tenant, request.Subscription, config.CommandName), + GroupedCommandName: config.CommandName, + CommandState: config.CommandState, + CurrentBehavior: config.CurrentBehavior, + PlannedInputModes: append([]string{}, config.InputModes...), + PreferredArtifactOrder: append([]string{}, config.PreferredArtifactOrder...), + SelectedSurface: selectedSurface, + Surfaces: groupedFamilySurfaceDescriptors(config), + Issues: []models.Issue{}, + } +} + +func evasionFamilyConfig() groupedFamilyConfig { + return groupedFamilyConfig{ + CommandName: "evasion", + CurrentBehavior: evasionCurrentBehavior, + CommandState: evasionCommandState, + InputModes: evasionInputModes, + PreferredArtifactOrder: evasionPreferredArtifactMode, + Selector: func(request Request) string { return request.EvasionSurface }, + Overview: buildEvasionOverview, + SurfaceNames: contracts.EvasionSurfaceNames, + SurfaceContract: contracts.EvasionSurface, + SurfaceBuilders: evasionSurfaceBuilders, + } +} diff --git a/internal/commands/evasion_appinsights.go b/internal/commands/evasion_appinsights.go new file mode 100644 index 0000000..38109d0 --- /dev/null +++ b/internal/commands/evasion_appinsights.go @@ -0,0 +1,211 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var evasionAppInsightsSteps = []familyStepDefinition{ + {Action: "change instrumentation posture", APISurface: "Microsoft.Web/sites/config/write", NeedsWrite: true, DownstreamEffect: "Changes app settings that can control Application Insights instrumentation behavior.", Boundary: "Does not read code-level instrumentation bodies."}, + {Action: "choose telemetry target", APISurface: "Application Insights component and app settings", NeedsWrite: true, DownstreamEffect: "Selects the instrumented app or function where telemetry is shaped.", Boundary: "A visible setting name is a posture clue, not proof of emitted telemetry."}, + {Action: "configure sampling", APISurface: "sampling app settings or SDK/OpenTelemetry config clues", NeedsWrite: true, DownstreamEffect: "Can reduce retained request, dependency, trace, or exception examples while dashboards remain alive.", Boundary: "Does not prove true unsampled event count."}, + {Action: "configure filtering or logging level", APISurface: "filter, processor, or logging-level setting clues", NeedsWrite: true, DownstreamEffect: "Can narrow selected telemetry types before investigators query Application Insights.", Boundary: "Does not prove filtered events occurred."}, + {Action: "preserve app-side config", APISurface: "stored app settings", NeedsWrite: true, DownstreamEffect: "The instrumentation posture remains as normal application configuration until changed.", Boundary: "Change timing and author require history."}, + {Action: "blend as observability tuning", APISurface: "app setting names and component posture", DownstreamEffect: "Common cover stories include cost control, health-check filtering, privacy, and performance tuning.", Boundary: "Cover story is not an intent claim."}, +} + +func buildEvasionAppInsightsOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + appInsights, err := appInsightsFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.EvasionAppInsightsTarget, 0, len(appInsights.Targets)) + for _, target := range appInsights.Targets { + control, controlOK := evasionAppInsightsControl(target.ID, evidence.principal.currentIdentityAssignments) + rank, reason := evasionAppInsightsDisruptionRank(target, controlOK) + targets = append(targets, models.EvasionAppInsightsTarget{ + ID: target.ID, + Name: target.Name, + ResourceGroup: target.ResourceGroup, + Location: target.Location, + DisruptionRank: rank, + DisruptionReason: reason, + CapabilitySteps: evasionAppInsightsCapabilitySteps(controlOK), + CurrentIdentityContext: evasionAppInsightsIdentityContext(evidence.principal.currentIdentity, control, controlOK), + CurrentState: evasionAppInsightsState(target), + NotCollectedByDefault: evasionAppInsightsNotCollectedByDefault(), + Summary: evasionAppInsightsSummary(target, rank, controlOK), + RelatedIDs: mergeRelatedIDs(target.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].DisruptionRank != targets[j].DisruptionRank { + return targets[i].DisruptionRank > targets[j].DisruptionRank + } + return targets[i].Name < targets[j].Name + }) + + 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", + ), + GroupedCommandName: "evasion", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Components: append([]models.AppInsightsComponent{}, appInsights.Components...), + Issues: issues, + }, nil +} + +func evasionAppInsightsControl(resourceID string, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + return evasionDCRBestControl(resourceID, assignments, "Microsoft.Web/sites/config/write") +} + +func evasionAppInsightsIdentityContext( + currentIdentity models.PermissionRow, + control persistenceCurrentIdentityControl, + controlOK bool, +) *models.EvasionRoleContext { + if strings.TrimSpace(currentIdentity.DisplayName) == "" && !controlOK { + return nil + } + name := firstNonEmpty(currentIdentity.DisplayName, "current identity") + roleNames := append([]string{}, currentIdentity.HighImpactRoles...) + if len(roleNames) == 0 { + roleNames = append(roleNames, currentIdentity.AllRoleNames...) + } + scopeIDs := append([]string{}, currentIdentity.ScopeIDs...) + summary := "Current foothold identity is visible, but app configuration write control is not proven here." + controlLabel := "not proven" + if controlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible app configuration write control.", name) + roleNames = []string{evasionControlRoleName(control)} + scopeIDs = []string{control.ScopeID} + controlLabel = "app config write" + } + return &models.EvasionRoleContext{ + Name: name, + Kind: "current-foothold", + PrincipalID: stringPtrIf(currentIdentity.PrincipalID), + RoleNames: dedupeStrings(roleNames), + ScopeIDs: dedupeStrings(scopeIDs), + ControlLabel: controlLabel, + Summary: summary, + } +} + +func evasionAppInsightsCapabilitySteps(controlOK bool) []models.EvasionCapabilityStep { + return familyCapabilitySteps(evasionAppInsightsSteps, controlOK) +} + +func evasionAppInsightsState(target models.AppInsightsAppTarget) models.EvasionAppInsightsState { + return models.EvasionAppInsightsState{ + Kind: target.Kind, + InstrumentationClues: append([]string{}, target.InstrumentationClues...), + SamplingClues: append([]string{}, target.SamplingClues...), + FilteringClues: append([]string{}, target.FilteringClues...), + LoggingLevelClues: append([]string{}, target.LoggingLevelClues...), + VisibleTelemetryTypes: append([]string{}, target.VisibleTelemetryTypes...), + Posture: evasionAppInsightsPosture(target), + } +} + +func evasionAppInsightsPosture(target models.AppInsightsAppTarget) string { + parts := []string{} + if len(target.FilteringClues) > 0 { + parts = append(parts, "filtering clue(s) visible") + } + if len(target.SamplingClues) > 0 { + parts = append(parts, "sampling clue(s) visible") + } + if len(target.LoggingLevelClues) > 0 { + parts = append(parts, "logging-level clue(s) visible") + } + if len(parts) == 0 { + return "instrumentation clue(s) only" + } + return strings.Join(parts, "; ") +} + +func evasionAppInsightsDisruptionRank(target models.AppInsightsAppTarget, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + if len(target.FilteringClues) > 0 && len(target.SamplingClues) > 0 { + rank = 5 + reasons = append(reasons, "filtering and sampling posture clues are both visible") + } else if len(target.FilteringClues) > 0 { + rank = 4 + reasons = append(reasons, "filtering posture clues can exclude selected telemetry") + } else if len(target.SamplingClues) > 0 { + rank = 3 + reasons = append(reasons, "sampling posture clues can reduce retained event examples") + } else if len(target.LoggingLevelClues) > 0 { + rank = 2 + reasons = append(reasons, "logging-level posture clues can narrow trace detail") + } + if controlOK { + reasons = append(reasons, "current identity has visible app configuration write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic disruption ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func evasionAppInsightsNotCollectedByDefault() []models.EvasionBoundaryNote { + return []models.EvasionBoundaryNote{ + {Name: "setting values", Classification: "recon safety", Reason: "Default output uses setting names as posture clues and does not print instrumentation keys or connection strings."}, + {Name: "code-level processors", Classification: "proof boundary", Reason: "Telemetry processor bodies usually live in source code or binaries, outside management-plane posture."}, + {Name: "host.json body", Classification: "collector issue", Reason: "Function sampling can live in host.json; this helper only uses visible app setting names by default."}, + {Name: "true unsampled count", Classification: "proof boundary", Reason: "Current posture cannot prove how many events were dropped or retained."}, + {Name: "detector failure", Classification: "proof boundary", Reason: "The command does not inspect detections, so it cannot claim a rule missed activity."}, + } +} + +func evasionAppInsightsSummary(target models.AppInsightsAppTarget, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("target %q ranks %d/5 for Application Insights truth-disruption posture", target.Name, rank)} + if len(target.FilteringClues) > 0 { + parts = append(parts, fmt.Sprintf("%d filtering clue(s)", len(target.FilteringClues))) + } + if len(target.SamplingClues) > 0 { + parts = append(parts, fmt.Sprintf("%d sampling clue(s)", len(target.SamplingClues))) + } + if controlOK { + parts = append(parts, "current identity can modify app configuration from visible RBAC") + } else { + parts = append(parts, "current identity write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/evasion_dcr.go b/internal/commands/evasion_dcr.go new file mode 100644 index 0000000..883b205 --- /dev/null +++ b/internal/commands/evasion_dcr.go @@ -0,0 +1,426 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +type evasionDCRStepDefinition struct { + Action string + APISurface string + NeedsRuleWrite bool + NeedsAssociation bool + DownstreamEffect string + Boundary string +} + +var evasionDCRSteps = []evasionDCRStepDefinition{ + { + Action: "choose or create DCR", + APISurface: "Microsoft.Insights/dataCollectionRules/write", + NeedsRuleWrite: true, + DownstreamEffect: "Sets the Azure Monitor object that can define collection, data flows, destinations, and transform posture.", + Boundary: "Does not prove a monitored agent has applied the rule.", + }, + { + Action: "associate monitored scope", + APISurface: "Microsoft.Insights/dataCollectionRuleAssociations/write", + NeedsAssociation: true, + DownstreamEffect: "Selects which visible resource scope receives the DCR collection and routing posture.", + Boundary: "Does not prove runtime agent state or log arrival.", + }, + { + Action: "select data sources and streams", + APISurface: "dataSources / dataFlows.streams", + NeedsRuleWrite: true, + DownstreamEffect: "Controls which telemetry classes are collected, including host and security-adjacent streams when present.", + Boundary: "Ranks by visible stream value only; missing expected streams require a defended baseline.", + }, + { + Action: "configure data flows and transformations", + APISurface: "dataFlows.transformKql", + NeedsRuleWrite: true, + DownstreamEffect: "Can filter or reshape selected records before storage while the pipeline still appears configured.", + Boundary: "Prints only transform presence, fingerprint, and length; it does not print transformKql or infer intent.", + }, + { + Action: "select destinations", + APISurface: "destinations / dataFlows.destinations", + NeedsRuleWrite: true, + DownstreamEffect: "Chooses where collected data goes, which can preserve logging while moving it away from a SOC workspace.", + Boundary: "Does not claim destination drift without an expected destination model.", + }, + { + Action: "save or re-associate rule", + APISurface: "DCR write plus association write", + NeedsRuleWrite: true, + NeedsAssociation: true, + DownstreamEffect: "Makes the Azure-side collection posture durable as management-plane configuration.", + Boundary: "Persistence here means Azure configuration remains until changed; runtime application is not proven.", + }, + { + Action: "shape defender truth", + APISurface: "streams, dataFlows, destinations, transformKql", + NeedsRuleWrite: true, + DownstreamEffect: "Visible levers can narrow collection, reroute telemetry, or transform records without a full logging disablement.", + Boundary: "Does not claim malicious filtering or downstream detector failure from posture alone.", + }, + { + Action: "preserve Azure-side config", + APISurface: "stored DCR and association resources", + NeedsRuleWrite: true, + DownstreamEffect: "The changed rule or association can remain in place like normal monitoring migration, cost, or schema configuration.", + Boundary: "History, author, and timing require activity-log evidence not collected here by default.", + }, + { + Action: "blend as monitoring change", + APISurface: "DCR metadata and ARM posture", + DownstreamEffect: "Common cover stories include AMA migration, workspace consolidation, cost control, schema normalization, and noise reduction.", + Boundary: "Cover story is an administrative explanation, not a claim of benign or malicious intent.", + }, +} + +func buildEvasionDCROutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + dcrOutput, err := dcrFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + sinks := providers.MonitoringSinksFromDCRReferences(dcrOutput.DCRs) + dcrs := make([]models.EvasionDCR, 0, len(dcrOutput.DCRs)) + for _, dcr := range dcrOutput.DCRs { + ruleControl, ruleControlOK := evasionDCRRuleControl(dcr.ID, evidence.principal.currentIdentityAssignments) + associationControl, associationControlOK := evasionDCRAssociationControl(dcr, evidence.principal.currentIdentityAssignments) + currentContext := evasionCurrentIdentityContext(evidence.principal.currentIdentity, ruleControl, ruleControlOK, associationControl, associationControlOK) + state := evasionDCRState(dcr) + rank, reason := evasionDCRDisruptionRank(dcr, ruleControlOK, associationControlOK) + dcrs = append(dcrs, models.EvasionDCR{ + ID: dcr.ID, + Name: dcr.Name, + ResourceGroup: dcr.ResourceGroup, + Location: dcr.Location, + DisruptionRank: rank, + DisruptionReason: reason, + CapabilitySteps: evasionDCRCapabilitySteps(ruleControlOK, associationControlOK), + CurrentIdentityContext: currentContext, + CurrentState: state, + NotCollectedByDefault: evasionDCRNotCollectedByDefault(), + Summary: evasionDCRSummary(dcr, rank, ruleControlOK, associationControlOK), + RelatedIDs: mergeRelatedIDs(dcr.RelatedIDs), + }) + } + sort.SliceStable(dcrs, func(i, j int) bool { + if dcrs[i].DisruptionRank != dcrs[j].DisruptionRank { + return dcrs[i].DisruptionRank > dcrs[j].DisruptionRank + } + if dcrs[i].CurrentState.TransformationCount != dcrs[j].CurrentState.TransformationCount { + return dcrs[i].CurrentState.TransformationCount > dcrs[j].CurrentState.TransformationCount + } + return dcrs[i].Name < dcrs[j].Name + }) + + 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", + ), + GroupedCommandName: "evasion", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + MonitoringSinks: sinks, + DCRs: dcrs, + Issues: issues, + }, nil +} + +func evasionDCRRuleControl(resourceID string, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + return evasionDCRBestControl(resourceID, assignments, "Microsoft.Insights/dataCollectionRules/write") +} + +func evasionDCRAssociationControl(dcr models.DCRAsset, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + bestRank := 99 + best := persistenceCurrentIdentityControl{} + targets := []string{dcr.ID} + for _, association := range dcr.Associations { + targets = append(targets, association.TargetID, association.ID) + } + for _, targetID := range targets { + control, ok := evasionDCRBestControl(targetID, assignments, "Microsoft.Insights/dataCollectionRuleAssociations/write") + if !ok { + continue + } + rank, rankOK := persistenceScopeRank(control.ScopeID, targetID) + if !rankOK || rank >= bestRank { + continue + } + bestRank = rank + best = control + } + return best, bestRank != 99 +} + +func evasionDCRBestControl(resourceID string, assignments []models.RoleAssignment, action string) (persistenceCurrentIdentityControl, bool) { + bestRank := 99 + best := persistenceCurrentIdentityControl{} + for _, assignment := range assignments { + if !persistenceRoleAssignmentAllowsNamedOrActionControl(assignment, action, "Owner", "Contributor", "Monitoring Contributor") { + continue + } + rank, ok := persistenceScopeRank(assignment.ScopeID, resourceID) + if !ok || rank >= bestRank { + continue + } + bestRank = rank + best = persistenceCurrentIdentityControl{ + RoleName: fmt.Sprintf("%s at %s", assignment.RoleName, persistenceScopeLabel(assignment.ScopeID)), + ScopeID: assignment.ScopeID, + } + } + return best, bestRank != 99 +} + +func evasionCurrentIdentityContext( + currentIdentity models.PermissionRow, + ruleControl persistenceCurrentIdentityControl, + ruleControlOK bool, + associationControl persistenceCurrentIdentityControl, + associationControlOK bool, +) *models.EvasionRoleContext { + if strings.TrimSpace(currentIdentity.DisplayName) == "" && !ruleControlOK && !associationControlOK { + return nil + } + + name := firstNonEmpty(currentIdentity.DisplayName, "current identity") + roleNames := append([]string{}, currentIdentity.HighImpactRoles...) + if len(roleNames) == 0 { + roleNames = append(roleNames, currentIdentity.AllRoleNames...) + } + scopeIDs := append([]string{}, currentIdentity.ScopeIDs...) + summary := "Current foothold identity is visible, but DCR or association write control is not proven here." + controlLabel := "not proven" + if ruleControlOK && associationControlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible DCR write control and association write control.", name) + roleNames = []string{evasionControlRoleName(ruleControl), evasionControlRoleName(associationControl)} + scopeIDs = []string{ruleControl.ScopeID, associationControl.ScopeID} + controlLabel = "DCR + association write" + } else if ruleControlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible DCR write control; association write control is not proven from the current evidence.", name) + roleNames = []string{evasionControlRoleName(ruleControl)} + scopeIDs = []string{ruleControl.ScopeID} + controlLabel = "DCR write" + } else if associationControlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible association write control; DCR content write control is not proven from the current evidence.", name) + roleNames = []string{evasionControlRoleName(associationControl)} + scopeIDs = []string{associationControl.ScopeID} + controlLabel = "association write" + } + + return &models.EvasionRoleContext{ + Name: name, + Kind: "current-foothold", + PrincipalID: stringPtrIf(currentIdentity.PrincipalID), + RoleNames: dedupeStrings(roleNames), + ScopeIDs: dedupeStrings(scopeIDs), + ControlLabel: controlLabel, + Summary: summary, + } +} + +func evasionControlRoleName(control persistenceCurrentIdentityControl) string { + return strings.TrimSpace(strings.SplitN(control.RoleName, " at ", 2)[0]) +} + +func evasionDCRCapabilitySteps(ruleControlOK bool, associationControlOK bool) []models.EvasionCapabilityStep { + steps := make([]models.EvasionCapabilityStep, 0, len(evasionDCRSteps)) + for _, step := range evasionDCRSteps { + status := "visible posture only" + canAct := false + if step.NeedsRuleWrite && step.NeedsAssociation { + if ruleControlOK && associationControlOK { + status = "yes" + canAct = true + } else if ruleControlOK || associationControlOK { + status = "partial" + canAct = true + } else { + status = "not proven" + } + } else if step.NeedsRuleWrite { + if ruleControlOK { + status = "yes" + canAct = true + } else { + status = "not proven" + } + } else if step.NeedsAssociation { + if associationControlOK { + status = "yes" + canAct = true + } else { + status = "not proven" + } + } + steps = append(steps, models.EvasionCapabilityStep{ + Action: step.Action, + APISurface: step.APISurface, + Status: status, + CanAct: canAct, + DownstreamEffect: step.DownstreamEffect, + Boundary: step.Boundary, + }) + } + return steps +} + +func evasionDCRState(dcr models.DCRAsset) models.EvasionDCRState { + return models.EvasionDCRState{ + DataSourceTypes: append([]string{}, dcr.DataSourceTypes...), + Streams: append([]string{}, dcr.Streams...), + HighSignalStreams: append([]string{}, dcr.HighSignalStreams...), + DestinationTypes: append([]string{}, dcr.DestinationTypes...), + AssociationTargets: evasionDCRAssociationTargets(dcr), + TransformationCount: dcr.TransformationCount, + AssociationCount: dcr.AssociationCount, + TransformationPosture: evasionDCRTransformationPosture(dcr), + DestinationPosture: evasionDCRDestinationPosture(dcr), + } +} + +func evasionDCRAssociationTargets(dcr models.DCRAsset) []string { + targets := make([]string, 0, len(dcr.Associations)) + for _, association := range dcr.Associations { + if association.TargetID != "" { + targets = append(targets, association.TargetID) + } + } + sort.Strings(targets) + return targets +} + +func evasionDCRTransformationPosture(dcr models.DCRAsset) string { + if dcr.TransformationCount == 0 { + return "no transformation posture visible" + } + if len(dcr.HighSignalStreams) > 0 { + return "transformation posture is visible on a DCR with high-signal streams" + } + return "transformation posture is visible, but stream impact needs monitoring context" +} + +func evasionDCRDestinationPosture(dcr models.DCRAsset) string { + if len(dcr.Destinations) == 0 { + return "no destination visible" + } + return "operator-selected destinations visible: " + strings.Join(dcr.DestinationTypes, ", ") +} + +func evasionDCRDisruptionRank(dcr models.DCRAsset, ruleControlOK bool, associationControlOK bool) (int, string) { + rank := 1 + reasons := []string{} + if dcr.TransformationCount > 0 && len(dcr.HighSignalStreams) > 0 { + rank = 5 + reasons = append(reasons, "transformations can alter selected high-signal data while collection remains configured") + } else if dcr.TransformationCount > 0 { + rank = 4 + reasons = append(reasons, "transformations can filter or reshape collected records before storage") + } else if len(dcr.HighSignalStreams) > 0 { + rank = 3 + reasons = append(reasons, "high-signal streams are visible collection levers") + } else if len(dcr.Destinations) > 0 { + rank = 2 + reasons = append(reasons, "destinations can move collected data without disabling collection") + } + if ruleControlOK && associationControlOK { + reasons = append(reasons, "current identity has visible rule and association write control") + } else if ruleControlOK { + reasons = append(reasons, "current identity has visible rule write control") + } else if associationControlOK { + reasons = append(reasons, "current identity has visible association write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic disruption ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func evasionDCRNotCollectedByDefault() []models.EvasionBoundaryNote { + return []models.EvasionBoundaryNote{ + { + Name: "transformKql body", + Classification: "operational anomaly", + Reason: "Presence, length, and fingerprint are enough for posture; printing full transform logic can expose sensitive filtering logic and encourages overclaiming intent.", + }, + { + Name: "log arrival or missing-record proof", + Classification: "proof boundary", + Reason: "Management-plane DCR posture cannot prove which records arrived, were dropped, or were later queried from Log Analytics.", + }, + { + Name: "agent applied-state", + Classification: "product-model gap", + Reason: "DCR association shows intended scope, not that the Azure Monitor Agent applied the rule on the target at runtime.", + }, + { + Name: "activity-log history and actor timing", + Classification: "API/noise", + Reason: "Broad activity-log pulls can be noisy and are not required for the default posture view; use history only when sequencing is explicitly needed.", + }, + { + Name: "expected SOC destination baseline", + Classification: "scope/sequencing", + Reason: "Destination drift needs a defended expected-workspace model before the tool can say the current destination is wrong.", + }, + } +} + +func evasionDCRSummary(dcr models.DCRAsset, rank int, ruleControlOK bool, associationControlOK bool) string { + parts := []string{fmt.Sprintf("DCR %q ranks %d/5 for quiet truth-disruption posture", dcr.Name, rank)} + if dcr.TransformationCount > 0 { + parts = append(parts, fmt.Sprintf("%d transformation clue(s)", dcr.TransformationCount)) + } + if len(dcr.HighSignalStreams) > 0 { + parts = append(parts, "high-signal streams: "+strings.Join(dcr.HighSignalStreams, ", ")) + } + if len(dcr.DestinationTypes) > 0 { + parts = append(parts, "destinations: "+strings.Join(dcr.DestinationTypes, ", ")) + } + if ruleControlOK && associationControlOK { + parts = append(parts, "current identity can affect both rule content and association scope from visible RBAC") + } else if ruleControlOK { + parts = append(parts, "current identity can affect rule content from visible RBAC") + } else if associationControlOK { + parts = append(parts, "current identity can affect association scope from visible RBAC") + } else { + parts = append(parts, "current identity write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/evasion_diagnostic_settings.go b/internal/commands/evasion_diagnostic_settings.go new file mode 100644 index 0000000..3ce2b47 --- /dev/null +++ b/internal/commands/evasion_diagnostic_settings.go @@ -0,0 +1,306 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var evasionDiagnosticSettingsSteps = []familyStepDefinition{ + { + Action: "create or modify diagnostic setting", + APISurface: "Microsoft.Insights/diagnosticSettings/write", + NeedsWrite: true, + DownstreamEffect: "Sets the Azure Monitor export object on the source resource.", + Boundary: "Does not prove a source event occurred or was collected.", + }, + { + Action: "pick source resource", + APISurface: "source resource ARM scope", + NeedsWrite: true, + DownstreamEffect: "Chooses which resource's logs, metrics, or activity export posture is shaped.", + Boundary: "Source value is ranked from visible type and current settings, not from future activity.", + }, + { + Action: "choose exported categories", + APISurface: "logs, metrics, category groups", + NeedsWrite: true, + DownstreamEffect: "Controls which visible categories or metrics are exported to the configured sink.", + Boundary: "Default output names categories present in visible settings; supported-but-absent categories need a catalog pass.", + }, + { + Action: "choose destination sink", + APISurface: "workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId", + NeedsWrite: true, + DownstreamEffect: "Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination.", + Boundary: "Does not call a destination wrong without an expected SOC sink baseline.", + }, + { + Action: "save or edit setting", + APISurface: "diagnosticSettings/write", + NeedsWrite: true, + DownstreamEffect: "Makes the category and destination posture durable as Azure configuration.", + Boundary: "Persistence here means stored management-plane posture, not proof of sink delivery.", + }, + { + Action: "shape visibility", + APISurface: "selected categories and destination IDs", + NeedsWrite: true, + DownstreamEffect: "Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere.", + Boundary: "Does not claim detector failure or malicious intent from posture alone.", + }, + { + Action: "reuse later", + APISurface: "stored diagnostic setting", + NeedsWrite: true, + DownstreamEffect: "The export posture remains until another actor or automation changes it.", + Boundary: "Change author and timing require activity-log history.", + }, + { + Action: "blend as monitoring admin", + APISurface: "diagnostic setting metadata", + DownstreamEffect: "Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup.", + Boundary: "Cover story is an administrative explanation, not a claim of benign or malicious intent.", + }, +} + +func buildEvasionDiagnosticSettingsOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + settings, err := settingsFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + sinks := providers.MonitoringSinksFromDiagnosticReferences(settings.Sources) + sources := make([]models.EvasionDiagnosticSettingsSource, 0, len(settings.Sources)) + for _, source := range settings.Sources { + control, controlOK := evasionDiagnosticSettingsControl(source.ID, evidence.principal.currentIdentityAssignments) + currentContext := evasionDiagnosticSettingsIdentityContext(evidence.principal.currentIdentity, control, controlOK) + rank, reason := evasionDiagnosticSettingsDisruptionRank(source, controlOK) + sources = append(sources, models.EvasionDiagnosticSettingsSource{ + ID: source.ID, + Name: source.Name, + ResourceGroup: source.ResourceGroup, + Location: source.Location, + DisruptionRank: rank, + DisruptionReason: reason, + CapabilitySteps: evasionDiagnosticSettingsCapabilitySteps(controlOK), + CurrentIdentityContext: currentContext, + CurrentState: evasionDiagnosticSettingsState(source), + NotCollectedByDefault: evasionDiagnosticSettingsNotCollectedByDefault(), + Summary: evasionDiagnosticSettingsSummary(source, rank, controlOK), + RelatedIDs: mergeRelatedIDs(source.RelatedIDs), + }) + } + sort.SliceStable(sources, func(i, j int) bool { + if sources[i].DisruptionRank != sources[j].DisruptionRank { + return sources[i].DisruptionRank > sources[j].DisruptionRank + } + return sources[i].Name < sources[j].Name + }) + + 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", + ), + GroupedCommandName: "evasion", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + MonitoringSinks: sinks, + Sources: sources, + Issues: issues, + }, nil +} + +func evasionDiagnosticSettingsControl(resourceID string, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + return evasionDCRBestControl(resourceID, assignments, "Microsoft.Insights/diagnosticSettings/write") +} + +func evasionDiagnosticSettingsIdentityContext( + currentIdentity models.PermissionRow, + control persistenceCurrentIdentityControl, + controlOK bool, +) *models.EvasionRoleContext { + if strings.TrimSpace(currentIdentity.DisplayName) == "" && !controlOK { + return nil + } + name := firstNonEmpty(currentIdentity.DisplayName, "current identity") + roleNames := append([]string{}, currentIdentity.HighImpactRoles...) + if len(roleNames) == 0 { + roleNames = append(roleNames, currentIdentity.AllRoleNames...) + } + scopeIDs := append([]string{}, currentIdentity.ScopeIDs...) + summary := "Current foothold identity is visible, but diagnostic settings write control is not proven here." + controlLabel := "not proven" + if controlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible diagnostic settings write control.", name) + roleNames = []string{evasionControlRoleName(control)} + scopeIDs = []string{control.ScopeID} + controlLabel = "diagnostic settings write" + } + return &models.EvasionRoleContext{ + Name: name, + Kind: "current-foothold", + PrincipalID: stringPtrIf(currentIdentity.PrincipalID), + RoleNames: dedupeStrings(roleNames), + ScopeIDs: dedupeStrings(scopeIDs), + ControlLabel: controlLabel, + Summary: summary, + } +} + +func evasionDiagnosticSettingsCapabilitySteps(controlOK bool) []models.EvasionCapabilityStep { + return familyCapabilitySteps(evasionDiagnosticSettingsSteps, controlOK) +} + +func evasionDiagnosticSettingsState(source models.DiagnosticSettingsSource) models.EvasionDiagnosticSettingsState { + return models.EvasionDiagnosticSettingsState{ + SourceType: source.Type, + DiagnosticSettingCount: source.DiagnosticSettingCount, + EnabledCategories: append([]string{}, source.EnabledCategories...), + NotExportedCategories: evasionDiagnosticSettingsNotExported(source), + SupportedCategories: append([]string{}, source.SupportedCategories...), + SupportedCategoryProof: source.SupportedCategoryCatalog, + CategoryGroups: append([]string{}, source.CategoryGroups...), + HighSignalCategories: append([]string{}, source.HighSignalCategories...), + DestinationTypes: append([]string{}, source.DestinationTypes...), + HasNonWorkspaceSink: source.HasNonWorkspaceDestination, + ExportPosture: evasionDiagnosticSettingsExportPosture(source), + DestinationPosture: evasionDiagnosticSettingsDestinationPosture(source), + } +} + +func evasionDiagnosticSettingsNotExported(source models.DiagnosticSettingsSource) []string { + if source.SupportedCategoryCatalog { + return append([]string{}, source.NotExportedSupported...) + } + return append([]string{}, source.DisabledCategories...) +} + +func evasionDiagnosticSettingsExportPosture(source models.DiagnosticSettingsSource) string { + if !source.HasDiagnosticSettings { + return "no visible diagnostic settings on this source" + } + if len(evasionDiagnosticSettingsNotExported(source)) > 0 && source.SupportedCategoryCatalog { + return "supported categories are not exported by visible settings" + } + if len(evasionDiagnosticSettingsNotExported(source)) > 0 { + return "some categories present in visible settings are not exported" + } + return "visible settings export selected categories or metrics" +} + +func evasionDiagnosticSettingsDestinationPosture(source models.DiagnosticSettingsSource) string { + if len(source.DestinationTypes) == 0 { + return "no destination visible" + } + return "operator-selected destinations visible: " + strings.Join(source.DestinationTypes, ", ") +} + +func evasionDiagnosticSettingsDisruptionRank(source models.DiagnosticSettingsSource, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + notExported := evasionDiagnosticSettingsNotExported(source) + if len(notExported) > 0 && source.HasHighSignalLogPosture { + rank = 5 + if source.SupportedCategoryCatalog { + reasons = append(reasons, "supported high-signal categories are not exported by visible settings") + } else { + reasons = append(reasons, "high-signal categories are present in visible settings but not exported") + } + } else if source.HasNonWorkspaceDestination && source.HasHighSignalLogPosture { + rank = 4 + reasons = append(reasons, "high-value telemetry is routed to a non-Log Analytics sink") + } else if source.HasNonWorkspaceDestination { + rank = 3 + reasons = append(reasons, "selected telemetry is routed outside Log Analytics") + } else if !source.HasDiagnosticSettings && source.HasHighSignalLogPosture { + rank = 2 + reasons = append(reasons, "high-value source has no visible diagnostic setting, but supported-category proof is not collected") + } + if controlOK { + reasons = append(reasons, "current identity has visible diagnostic settings write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic disruption ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func evasionDiagnosticSettingsNotCollectedByDefault() []models.EvasionBoundaryNote { + return []models.EvasionBoundaryNote{ + { + Name: "supported category catalog", + Classification: "API/noise", + Reason: "Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories.", + }, + { + Name: "activity-log change history", + Classification: "API/noise", + Reason: "Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view.", + }, + { + Name: "sink contents", + Classification: "proof boundary", + Reason: "Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture.", + }, + { + Name: "detector wiring", + Classification: "proof boundary", + Reason: "Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed.", + }, + { + Name: "expected SOC destination baseline", + Classification: "scope/sequencing", + Reason: "Destination drift needs a defended expected sink model before the tool can call the current sink wrong.", + }, + } +} + +func evasionDiagnosticSettingsSummary(source models.DiagnosticSettingsSource, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("source %q ranks %d/5 for diagnostic-settings truth-disruption posture", source.Name, rank)} + if notExported := evasionDiagnosticSettingsNotExported(source); len(notExported) > 0 { + label := "not exported by visible setting" + if source.SupportedCategoryCatalog { + label = "supported but not exported" + } + parts = append(parts, label+": "+strings.Join(notExported, ", ")) + } + if len(source.DestinationTypes) > 0 { + parts = append(parts, "destinations: "+strings.Join(source.DestinationTypes, ", ")) + } + if controlOK { + parts = append(parts, "current identity can modify diagnostic settings from visible RBAC") + } else { + parts = append(parts, "current identity write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/grouped_command_output_test.go b/internal/commands/grouped_command_output_test.go new file mode 100644 index 0000000..0ebbb35 --- /dev/null +++ b/internal/commands/grouped_command_output_test.go @@ -0,0 +1,33 @@ +package commands + +import ( + "context" + "testing" +) + +func TestRunGroupedCommandOutputReusesCommandResult(t *testing.T) { + group := newCommandOutputGroup(chainsFanoutLimit) + calls := 0 + handler := func(context.Context, Request) (any, error) { + calls++ + return "shared-output", nil + } + + first := runGroupedCommandOutput[string](group, context.Background(), Request{}, handler, "shared") + second := runGroupedCommandOutput[string](group, context.Background(), Request{}, handler, "shared") + + firstValue, err := first.wait() + if err != nil { + t.Fatalf("first wait failed: %v", err) + } + secondValue, err := second.wait() + if err != nil { + t.Fatalf("second wait failed: %v", err) + } + if firstValue != "shared-output" || secondValue != "shared-output" { + t.Fatalf("unexpected values: first=%q second=%q", firstValue, secondValue) + } + if calls != 1 { + t.Fatalf("expected one backing call, got %d", calls) + } +} diff --git a/internal/commands/grouped_family.go b/internal/commands/grouped_family.go new file mode 100644 index 0000000..6acaffa --- /dev/null +++ b/internal/commands/grouped_family.go @@ -0,0 +1,214 @@ +package commands + +import ( + "context" + "fmt" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +type groupedSurfaceBuilder func(context.Context, providers.Provider, func() time.Time, Request, contracts.SurfaceContract) (any, error) + +type familyStepDefinition struct { + Action string + APISurface string + NeedsWrite bool + DownstreamEffect string + Boundary string +} + +type groupedFamilyConfig struct { + CommandName string + CurrentBehavior string + CommandState string + InputModes []string + PreferredArtifactOrder []string + Selector func(Request) string + Overview func(func() time.Time, Request, *string) any + SurfaceNames func() []string + SurfaceContract func(string) (contracts.SurfaceContract, bool) + SurfaceBuilders map[string]groupedSurfaceBuilder +} + +type familyEvidenceFutures struct { + permissions asyncCommandOutput[models.PermissionsOutput] + rbac asyncCommandOutput[models.RbacOutput] +} + +type familyEvidence struct { + permissions models.PermissionsOutput + rbac models.RbacOutput + principal persistencePrincipalEvidence +} + +func runFamilyEvidence(group commandOutputGroup, ctx context.Context, request Request, provider providers.Provider, now func() time.Time) 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"), + } +} + +func (futures familyEvidenceFutures) wait() (familyEvidence, error) { + permissions, err := futures.permissions.wait() + if err != nil { + return familyEvidence{}, err + } + rbac, err := futures.rbac.wait() + if err != nil { + return familyEvidence{}, err + } + return familyEvidence{ + permissions: permissions, + rbac: rbac, + principal: buildPersistencePrincipalEvidence(permissions.Permissions, rbac.RoleAssignments), + }, nil +} + +func familyIssues(base []models.Issue, evidence familyEvidence) []models.Issue { + issues := append([]models.Issue{}, base...) + issues = append(issues, evidence.permissions.Issues...) + issues = append(issues, evidence.rbac.Issues...) + return issues +} + +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)) + if surface == "" { + return config.Overview(now, request, nil), nil + } + + contract, ok := config.SurfaceContract(surface) + if !ok { + return nil, fmt.Errorf("unknown %s surface %q", config.CommandName, surface) + } + builder, ok := config.SurfaceBuilders[surface] + if !ok { + return nil, fmt.Errorf("%s surface %q is not implemented yet", config.CommandName, surface) + } + return builder(ctx, provider, now, request, contract) + } +} + +func groupedFamilySurfaceDescriptors(config groupedFamilyConfig) []models.FamilySurfaceDescriptor { + surfaces := make([]models.FamilySurfaceDescriptor, 0, len(config.SurfaceNames())) + for _, name := range config.SurfaceNames() { + surface, _ := config.SurfaceContract(name) + surfaces = append(surfaces, models.FamilySurfaceDescriptor{ + Surface: surface.Name, + State: surface.Status, + Summary: surface.Summary, + OperatorQuestion: surface.OperatorQuestion, + BackingCommands: append([]string{}, surface.BackingCommands...), + }) + } + return surfaces +} + +func familyCapabilitySteps(steps []familyStepDefinition, controlOK bool) []models.FamilyCapabilityStep { + rows := make([]models.FamilyCapabilityStep, 0, len(steps)) + for _, step := range steps { + status := "visible posture only" + canAct := false + if step.NeedsWrite { + if controlOK { + status = "yes" + canAct = true + } else { + status = "not proven" + } + } + rows = append(rows, models.FamilyCapabilityStep{ + Action: step.Action, + APISurface: step.APISurface, + Status: status, + CanAct: canAct, + DownstreamEffect: step.DownstreamEffect, + Boundary: step.Boundary, + }) + } + return rows +} + +type familyAPIMPostureOptions struct { + IncludeAPICount bool + IncludeSubscriptionCount bool + IncludeActiveSubscriptionCount bool + IncludeNamedValueSecretPosture bool +} + +func familyAPIMPostureParts(service models.ApiMgmtServiceAsset, options familyAPIMPostureOptions) []string { + parts := []string{} + if len(service.GatewayHostnames) > 0 { + parts = append(parts, fmt.Sprintf("%d gateway hostname(s)", len(service.GatewayHostnames))) + } + if len(service.BackendHostnames) > 0 { + parts = append(parts, fmt.Sprintf("%d backend hostname(s)", len(service.BackendHostnames))) + } + if options.IncludeAPICount && apiMgmtIntValue(service.APICount) > 0 { + parts = append(parts, fmt.Sprintf("%d API(s)", apiMgmtIntValue(service.APICount))) + } + if options.IncludeSubscriptionCount && apiMgmtIntValue(service.SubscriptionCount) > 0 { + parts = append(parts, fmt.Sprintf("%d subscription(s)", apiMgmtIntValue(service.SubscriptionCount))) + } + if len(service.PolicyControlTypes) > 0 { + parts = append(parts, "policy controls: "+strings.Join(service.PolicyControlTypes, ", ")) + } + if options.IncludeActiveSubscriptionCount && apiMgmtIntValue(service.ActiveSubscriptionCount) > 0 { + parts = append(parts, fmt.Sprintf("%d active subscription(s)", apiMgmtIntValue(service.ActiveSubscriptionCount))) + } + if options.IncludeNamedValueSecretPosture && (apiMgmtIntValue(service.NamedValueSecretCount) > 0 || apiMgmtIntValue(service.NamedValueKeyVaultCount) > 0) { + parts = append(parts, "secret or Key Vault named-value posture") + } + return parts +} + +func familyLogicAppState(workflow models.LogicAppWorkflowAsset, posture string) models.FamilyLogicAppState { + return models.FamilyLogicAppState{ + Platform: workflow.Platform, + State: workflow.State, + TriggerTypes: append([]string{}, workflow.TriggerTypes...), + ExternallyCallableRequestTrigger: workflow.ExternallyCallableRequestTrigger, + RecurrenceSummary: workflow.RecurrenceSummary, + DownstreamActionKinds: append([]string{}, workflow.DownstreamActionKinds...), + ConnectorReferences: append([]string{}, workflow.ConnectorReferences...), + ParameterNames: append([]string{}, workflow.ParameterNames...), + DownstreamResourceReferences: append([]string{}, workflow.DownstreamResourceReferences...), + IdentityType: workflow.IdentityType, + IdentityIDs: append([]string{}, workflow.IdentityIDs...), + Posture: posture, + } +} + +func familyLogicAppPosture(workflow models.LogicAppWorkflowAsset, emptyPosture string) string { + parts := []string{} + if workflow.ExternallyCallableRequestTrigger { + parts = append(parts, "externally callable request trigger") + } + if workflow.RecurrenceSummary != nil && strings.TrimSpace(*workflow.RecurrenceSummary) != "" { + parts = append(parts, "recurrence trigger "+*workflow.RecurrenceSummary) + } + if len(workflow.TriggerTypes) > 0 { + parts = append(parts, "trigger types "+strings.Join(workflow.TriggerTypes, ", ")) + } + if len(workflow.DownstreamActionKinds) > 0 { + parts = append(parts, "downstream actions "+strings.Join(workflow.DownstreamActionKinds, ", ")) + } + if len(workflow.ConnectorReferences) > 0 { + parts = append(parts, "connector references "+strings.Join(workflow.ConnectorReferences, ", ")) + } + if len(workflow.DownstreamResourceReferences) > 0 { + parts = append(parts, fmt.Sprintf("%d downstream resource reference(s)", len(workflow.DownstreamResourceReferences))) + } + if strings.TrimSpace(stringPtrValue(workflow.IdentityType)) != "" { + parts = append(parts, "managed identity posture") + } + if len(parts) == 0 { + return emptyPosture + } + return strings.Join(parts, "; ") +} diff --git a/internal/commands/monitoring_sinks.go b/internal/commands/monitoring_sinks.go new file mode 100644 index 0000000..35262c7 --- /dev/null +++ b/internal/commands/monitoring_sinks.go @@ -0,0 +1,34 @@ +package commands + +import ( + "context" + "time" + + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +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) + if err != nil { + return nil, err + } + return models.MonitoringSinksOutput{ + Sinks: sortedByLess(facts.Sinks, monitoringSinkLess), + Findings: []models.Finding{}, + Issues: facts.Issues, + Metadata: runtimeCommandMetadata("monitoring-sinks", now, facts.TenantID, facts.SubscriptionID), + }, nil + } +} + +func monitoringSinkLess(left models.MonitoringSinkAsset, right models.MonitoringSinkAsset) bool { + if left.ReferenceCount != right.ReferenceCount { + return left.ReferenceCount > right.ReferenceCount + } + if left.Kind != right.Kind { + return left.Kind < right.Kind + } + return left.Name < right.Name +} diff --git a/internal/commands/path_masking.go b/internal/commands/path_masking.go new file mode 100644 index 0000000..99229f1 --- /dev/null +++ b/internal/commands/path_masking.go @@ -0,0 +1,59 @@ +package commands + +import ( + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +const ( + pathMaskingCurrentBehavior = "Grouped pathmasking walkthroughs. Use `ho-azure pathmasking` or `ho-azure pathmasking help` to list surfaces, then `ho-azure pathmasking ` to run an implemented surface." + pathMaskingCommandState = contracts.StatusImplemented +) + +var ( + pathMaskingInputModes = []string{"live"} + pathMaskingPreferredArtifactMode = []string{"loot", "json"} +) + +var pathMaskingSurfaceBuilders = map[string]groupedSurfaceBuilder{ + "api-mgmt": buildPathMaskingAPIMOutput, + "logic-apps": buildPathMaskingLogicAppsOutput, + "relay": buildPathMaskingRelayOutput, +} + +func pathMaskingHandler(provider providers.Provider, now func() time.Time) Handler { + return groupedFamilyHandler(provider, now, pathMaskingFamilyConfig()) +} + +func buildPathMaskingOverview(now func() time.Time, request Request, selectedSurface *string) any { + config := pathMaskingFamilyConfig() + return models.PathMaskingOverviewOutput{ + Metadata: scopedMetadata(now, request, request.Tenant, request.Subscription, config.CommandName), + GroupedCommandName: config.CommandName, + CommandState: config.CommandState, + CurrentBehavior: config.CurrentBehavior, + PlannedInputModes: append([]string{}, config.InputModes...), + PreferredArtifactOrder: append([]string{}, config.PreferredArtifactOrder...), + SelectedSurface: selectedSurface, + Surfaces: groupedFamilySurfaceDescriptors(config), + Issues: []models.Issue{}, + } +} + +func pathMaskingFamilyConfig() groupedFamilyConfig { + return groupedFamilyConfig{ + CommandName: "pathmasking", + CurrentBehavior: pathMaskingCurrentBehavior, + CommandState: pathMaskingCommandState, + InputModes: pathMaskingInputModes, + PreferredArtifactOrder: pathMaskingPreferredArtifactMode, + Selector: func(request Request) string { return request.PathMaskingSurface }, + Overview: buildPathMaskingOverview, + SurfaceNames: contracts.PathMaskingSurfaceNames, + SurfaceContract: contracts.PathMaskingSurface, + SurfaceBuilders: pathMaskingSurfaceBuilders, + } +} diff --git a/internal/commands/path_masking_api_mgmt.go b/internal/commands/path_masking_api_mgmt.go new file mode 100644 index 0000000..aa64432 --- /dev/null +++ b/internal/commands/path_masking_api_mgmt.go @@ -0,0 +1,201 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var pathMaskingAPIMSteps = []familyStepDefinition{ + {Action: "select public gateway", APISurface: "Microsoft.ApiManagement/service", DownstreamEffect: "Clients see the APIM hostname instead of the backend service path.", Boundary: "Gateway posture does not prove traffic volume."}, + {Action: "identify backend indirection", APISurface: "Microsoft.ApiManagement/service/backends", DownstreamEffect: "Shows where the published API surface can forward requests behind the gateway.", Boundary: "Backend hostname posture does not prove ownership or reachability."}, + {Action: "apply route or transform policy", APISurface: "APIM policies and backend settings", NeedsWrite: true, DownstreamEffect: "Can rewrite paths, switch backends, normalize auth, or keep the true route opaque to callers.", Boundary: "Policy XML bodies are not collected by default."}, + {Action: "preserve consumer-facing contract", APISurface: "APIs, operations, products, subscriptions", NeedsWrite: true, DownstreamEffect: "Existing products and subscriptions can keep caller behavior normal while the backend path stays abstracted.", Boundary: "Consumer use and request contents require runtime logs."}, + {Action: "blend as API gateway operations", APISurface: "APIM service configuration", DownstreamEffect: "Normal cover stories include API versioning, throttling, partner exposure, backend migration, and failover.", Boundary: "Cover story is not an intent claim."}, +} + +func buildPathMaskingAPIMOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + apiMgmt, err := apiMgmtFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.PathMaskingAPIMTarget, 0, len(apiMgmt.ApiManagementServices)) + for _, service := range apiMgmt.ApiManagementServices { + control, controlOK := resourceHijackingAPIMControl(service.ID, evidence.principal.currentIdentityAssignments) + rank, reason := pathMaskingAPIMRank(service, controlOK) + targets = append(targets, models.PathMaskingAPIMTarget{ + ID: service.ID, + Name: service.Name, + ResourceGroup: service.ResourceGroup, + Location: service.Location, + MaskingRank: rank, + MaskingReason: reason, + CapabilitySteps: pathMaskingCapabilitySteps(pathMaskingAPIMSteps, controlOK), + CurrentIdentityContext: pathMaskingRoleContext(evidence.principal.currentIdentity, control, controlOK, "APIM route or backend policy control", "APIM route/backend write"), + CurrentState: pathMaskingAPIMState(service), + NotCollectedByDefault: pathMaskingAPIMNotCollectedByDefault(), + Summary: pathMaskingAPIMSummary(service, rank, controlOK), + RelatedIDs: mergeRelatedIDs(service.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].MaskingRank != targets[j].MaskingRank { + return targets[i].MaskingRank > targets[j].MaskingRank + } + return targets[i].Name < targets[j].Name + }) + + 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"), + GroupedCommandName: "pathmasking", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Issues: issues, + }, nil +} + +func pathMaskingCapabilitySteps(steps []familyStepDefinition, controlOK bool) []models.PathMaskingCapabilityStep { + return familyCapabilitySteps(steps, controlOK) +} + +func pathMaskingRoleContext(currentIdentity models.PermissionRow, control persistenceCurrentIdentityControl, controlOK bool, controlName string, controlLabel string) *models.PathMaskingRoleContext { + if strings.TrimSpace(currentIdentity.DisplayName) == "" && !controlOK { + return nil + } + name := firstNonEmpty(currentIdentity.DisplayName, "current identity") + roleNames := append([]string{}, currentIdentity.HighImpactRoles...) + if len(roleNames) == 0 { + roleNames = append(roleNames, currentIdentity.AllRoleNames...) + } + scopeIDs := append([]string{}, currentIdentity.ScopeIDs...) + summary := "Current foothold identity is visible, but " + controlName + " is not proven here." + label := "not proven" + if controlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible %s.", name, controlName) + roleNames = []string{evasionControlRoleName(control)} + scopeIDs = []string{control.ScopeID} + label = controlLabel + } + return &models.PathMaskingRoleContext{ + Name: name, + Kind: "current-foothold", + PrincipalID: stringPtrIf(currentIdentity.PrincipalID), + RoleNames: dedupeStrings(roleNames), + ScopeIDs: dedupeStrings(scopeIDs), + ControlLabel: label, + Summary: summary, + } +} + +func pathMaskingAPIMState(service models.ApiMgmtServiceAsset) models.PathMaskingAPIMState { + return models.PathMaskingAPIMState{ + GatewayHostnames: append([]string{}, service.GatewayHostnames...), + BackendHostnames: append([]string{}, service.BackendHostnames...), + APICount: service.APICount, + SubscriptionCount: service.SubscriptionCount, + PolicyCount: service.PolicyCount, + PolicyControlTypes: append([]string{}, service.PolicyControlTypes...), + NamedValueSecretCount: service.NamedValueSecretCount, + NamedValueKeyVaultCount: service.NamedValueKeyVaultCount, + PublicNetworkAccess: service.PublicNetworkAccess, + VirtualNetworkType: service.VirtualNetworkType, + Posture: pathMaskingAPIMPosture(service), + } +} + +func pathMaskingAPIMPosture(service models.ApiMgmtServiceAsset) string { + parts := familyAPIMPostureParts(service, familyAPIMPostureOptions{ + IncludeAPICount: true, + IncludeSubscriptionCount: true, + }) + if len(parts) == 0 { + return "APIM service visible without stronger proxy or backend-indirection posture" + } + return strings.Join(parts, "; ") +} + +func pathMaskingAPIMRank(service models.ApiMgmtServiceAsset, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + hasGateway := len(service.GatewayHostnames) > 0 + hasBackend := len(service.BackendHostnames) > 0 || apiMgmtIntValue(service.BackendCount) > 0 || len(service.PolicyControlTypes) > 0 + hasContract := apiMgmtIntValue(service.APICount) > 0 || apiMgmtIntValue(service.SubscriptionCount) > 0 + gatewayLabel := "gateway" + if strings.EqualFold(strings.TrimSpace(stringPtrValue(service.PublicNetworkAccess)), "Enabled") { + gatewayLabel = "public gateway" + } + switch { + case hasGateway && hasBackend && hasContract: + rank = 5 + reasons = append(reasons, gatewayLabel+", backend indirection, and API/consumer contract posture are visible") + case hasGateway && hasBackend: + rank = 4 + reasons = append(reasons, gatewayLabel+" and backend indirection posture are visible") + case hasGateway || hasBackend: + rank = 3 + reasons = append(reasons, "partial gateway or backend indirection posture is visible") + case hasContract: + rank = 2 + reasons = append(reasons, "API contract posture is visible without stronger backend masking evidence") + } + if controlOK { + reasons = append(reasons, "current identity has visible APIM route or backend policy control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic pathmasking ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func pathMaskingAPIMNotCollectedByDefault() []models.PathMaskingBoundaryNote { + return []models.PathMaskingBoundaryNote{ + {Name: "policy XML bodies", Classification: "recon safety", Reason: "The flat helper parses safe policy control types, but does not print raw policy XML or named-value expansions."}, + {Name: "live request flow", Classification: "proof boundary", Reason: "Management-plane posture cannot prove callers used this path or which backend received traffic."}, + {Name: "request contents", Classification: "proof boundary", Reason: "The command does not inspect APIM gateway logs or request payloads."}, + {Name: "backend ownership", Classification: "proof boundary", Reason: "A backend hostname does not prove who controls the target or what process answers."}, + {Name: "named-value values", Classification: "recon safety", Reason: "Secret and Key Vault-backed named values are counted but values are not printed."}, + } +} + +func pathMaskingAPIMSummary(service models.ApiMgmtServiceAsset, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("service %q ranks %d/5 for APIM pathmasking posture", service.Name, rank)} + if len(service.GatewayHostnames) > 0 { + parts = append(parts, fmt.Sprintf("%d gateway hostname(s)", len(service.GatewayHostnames))) + } + if len(service.BackendHostnames) > 0 { + parts = append(parts, fmt.Sprintf("%d backend hostname(s)", len(service.BackendHostnames))) + } + if controlOK { + parts = append(parts, "current identity can change route or backend posture from visible RBAC") + } else { + parts = append(parts, "current identity route or backend control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/path_masking_logic_apps.go b/internal/commands/path_masking_logic_apps.go new file mode 100644 index 0000000..da8956e --- /dev/null +++ b/internal/commands/path_masking_logic_apps.go @@ -0,0 +1,162 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var pathMaskingLogicAppSteps = []familyStepDefinition{ + {Action: "select trusted workflow", APISurface: "Microsoft.Logic/workflows", DownstreamEffect: "Keeps the visible activity inside an existing Azure integration resource rather than a direct caller-to-target path.", Boundary: "Workflow posture does not prove the workflow ran."}, + {Action: "identify trigger entry point", APISurface: "request, recurrence, api-connection, or event trigger", DownstreamEffect: "Request, schedule, and connector triggers can make the workflow the front door instead of the operator or caller.", Boundary: "Trigger posture does not prove invocation or caller identity."}, + {Action: "map downstream relay actions", APISurface: "HTTP, api-connection, or service actions", DownstreamEffect: "Downstream actions show where the workflow can forward, transform, or broker activity through trusted connectors.", Boundary: "Default output does not print full workflow bodies or payloads."}, + {Action: "change workflow route", APISurface: "workflow definition", NeedsWrite: true, DownstreamEffect: "Can repoint HTTP actions, reshape branches, or preserve the trigger while changing the downstream path.", Boundary: "Write capability is inferred only from visible management-plane RBAC."}, + {Action: "blend as integration maintenance", APISurface: "workflow configuration", DownstreamEffect: "Normal cover stories include connector repair, retry tuning, endpoint migration, and integration modernization.", Boundary: "Cover story is not an intent claim."}, +} + +func buildPathMaskingLogicAppsOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + logicApps, err := logicAppsFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.PathMaskingLogicAppTarget, 0, len(logicApps.Workflows)) + for _, workflow := range logicApps.Workflows { + control, controlOK := resourceHijackingLogicAppControl(workflow.ID, evidence.principal.currentIdentityAssignments) + rank, reason := pathMaskingLogicAppRank(workflow, controlOK) + targets = append(targets, models.PathMaskingLogicAppTarget{ + ID: workflow.ID, + Name: workflow.Name, + ResourceGroup: workflow.ResourceGroup, + Location: workflow.Location, + MaskingRank: rank, + MaskingReason: reason, + CapabilitySteps: pathMaskingCapabilitySteps(pathMaskingLogicAppSteps, controlOK), + CurrentIdentityContext: pathMaskingRoleContext(evidence.principal.currentIdentity, control, controlOK, "Logic App route or relay workflow write control", "workflow write"), + CurrentState: pathMaskingLogicAppState(workflow), + NotCollectedByDefault: pathMaskingLogicAppNotCollectedByDefault(), + Summary: pathMaskingLogicAppSummary(workflow, rank, controlOK), + RelatedIDs: mergeRelatedIDs(workflow.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].MaskingRank != targets[j].MaskingRank { + return targets[i].MaskingRank > targets[j].MaskingRank + } + return targets[i].Name < targets[j].Name + }) + + 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", + ), + GroupedCommandName: "pathmasking", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Issues: issues, + }, nil +} + +func pathMaskingLogicAppState(workflow models.LogicAppWorkflowAsset) models.PathMaskingLogicAppState { + return familyLogicAppState(workflow, pathMaskingLogicAppPosture(workflow)) +} + +func pathMaskingLogicAppPosture(workflow models.LogicAppWorkflowAsset) string { + return familyLogicAppPosture(workflow, "Logic App workflow visible without stronger trigger, downstream, or identity posture") +} + +func pathMaskingLogicAppRank(workflow models.LogicAppWorkflowAsset, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + hasTrigger := workflow.ExternallyCallableRequestTrigger || len(workflow.TriggerTypes) > 0 || workflow.RecurrenceSummary != nil + hasDownstream := len(workflow.DownstreamActionKinds) > 0 || len(workflow.DownstreamResourceReferences) > 0 || len(workflow.ConnectorReferences) > 0 + hasHTTPOrConnector := workflow.ExternallyCallableRequestTrigger || len(workflow.ConnectorReferences) > 0 || hasActionKind(workflow.DownstreamActionKinds, "http") || hasActionKind(workflow.DownstreamActionKinds, "api-connection") + hasIdentity := strings.TrimSpace(stringPtrValue(workflow.IdentityType)) != "" + switch { + case hasHTTPOrConnector && hasDownstream && hasIdentity: + rank = 5 + reasons = append(reasons, "request or connector path, downstream action posture, and workflow identity are visible") + case hasTrigger && hasDownstream && hasIdentity: + rank = 4 + reasons = append(reasons, "trigger, downstream action posture, and workflow identity are visible") + case hasTrigger && hasDownstream: + rank = 3 + reasons = append(reasons, "trigger and downstream action posture are visible") + case hasTrigger || hasDownstream: + rank = 2 + reasons = append(reasons, "partial trigger or downstream path posture is visible") + } + if controlOK { + reasons = append(reasons, "current identity has visible Logic App route or relay workflow write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic pathmasking ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func hasActionKind(values []string, needle string) bool { + needle = strings.ToLower(strings.TrimSpace(needle)) + for _, value := range values { + if strings.Contains(strings.ToLower(value), needle) { + return true + } + } + return false +} + +func pathMaskingLogicAppNotCollectedByDefault() []models.PathMaskingBoundaryNote { + return []models.PathMaskingBoundaryNote{ + {Name: "full workflow definition body", Classification: "collector issue", Reason: "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default."}, + {Name: "run history", Classification: "proof boundary", Reason: "Management-plane posture cannot prove the workflow ran, succeeded, or carried traffic."}, + {Name: "connector credential values", Classification: "recon safety", Reason: "Connector secrets and connection credential material are not safe default output."}, + {Name: "payload and response contents", Classification: "proof boundary", Reason: "The command does not inspect trigger payloads, action payloads, or response data."}, + {Name: "workflow change history", Classification: "API/noise", Reason: "Broad workflow history is not needed for default posture and should stay a narrow follow-up for timing or actor proof."}, + } +} + +func pathMaskingLogicAppSummary(workflow models.LogicAppWorkflowAsset, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("workflow %q ranks %d/5 for Logic Apps pathmasking posture", workflow.Name, rank)} + if len(workflow.TriggerTypes) > 0 { + parts = append(parts, fmt.Sprintf("%d trigger type(s)", len(workflow.TriggerTypes))) + } + if len(workflow.DownstreamActionKinds) > 0 { + parts = append(parts, fmt.Sprintf("%d downstream action kind(s)", len(workflow.DownstreamActionKinds))) + } + if controlOK { + parts = append(parts, "current identity can change workflow path posture from visible RBAC") + } else { + parts = append(parts, "current identity workflow write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/path_masking_relay.go b/internal/commands/path_masking_relay.go new file mode 100644 index 0000000..71e3d1a --- /dev/null +++ b/internal/commands/path_masking_relay.go @@ -0,0 +1,222 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var pathMaskingRelaySteps = []familyStepDefinition{ + {Action: "select Relay namespace", APISurface: "Microsoft.Relay/namespaces", DownstreamEffect: "Azure becomes the visible rendezvous point while the listener and backend can stay off the direct public path.", Boundary: "Namespace posture does not prove a listener is currently connected."}, + {Action: "identify Hybrid Connections", APISurface: "Microsoft.Relay/namespaces/hybridConnections", DownstreamEffect: "Hybrid Connections show named private-path channels that can bridge callers toward internal services.", Boundary: "Hybrid Connection posture does not identify the backend process."}, + {Action: "review authorization rules", APISurface: "Microsoft.Relay/namespaces/authorizationRules", DownstreamEffect: "Authorization rules show where management-plane control could sustain or reshape send/listen access paths.", Boundary: "The command does not retrieve keys or prove data-plane use."}, + {Action: "change namespace or connection posture", APISurface: "Relay namespace and Hybrid Connection configuration", NeedsWrite: true, DownstreamEffect: "Can add, remove, or reconfigure the cloud rendezvous path while preserving an Azure-native integration story.", Boundary: "Write capability is inferred only from visible management-plane RBAC."}, + {Action: "blend as private connectivity", APISurface: "Relay service configuration", DownstreamEffect: "Normal cover stories include hybrid integration, firewall avoidance for approved apps, partner connectivity, and private service migration.", Boundary: "Cover story is not an intent claim."}, +} + +func buildPathMaskingRelayOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + relay, err := relayFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.PathMaskingRelayTarget, 0, len(relay.Namespaces)) + for _, namespace := range relay.Namespaces { + control, controlOK := pathMaskingRelayControl(namespace.ID, evidence.principal.currentIdentityAssignments) + rank, reason := pathMaskingRelayRank(namespace, controlOK) + targets = append(targets, models.PathMaskingRelayTarget{ + ID: namespace.ID, + Name: namespace.Name, + ResourceGroup: namespace.ResourceGroup, + Location: namespace.Location, + MaskingRank: rank, + MaskingReason: reason, + CapabilitySteps: pathMaskingCapabilitySteps(pathMaskingRelaySteps, controlOK), + CurrentIdentityContext: pathMaskingRoleContext(evidence.principal.currentIdentity, control, controlOK, "Relay namespace or Hybrid Connection write control", "Relay write"), + CurrentState: pathMaskingRelayState(namespace), + NotCollectedByDefault: pathMaskingRelayNotCollectedByDefault(), + Summary: pathMaskingRelaySummary(namespace, rank, controlOK), + RelatedIDs: mergeRelatedIDs(namespace.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].MaskingRank != targets[j].MaskingRank { + return targets[i].MaskingRank > targets[j].MaskingRank + } + return targets[i].Name < targets[j].Name + }) + + 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", + ), + GroupedCommandName: "pathmasking", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Issues: issues, + }, nil +} + +func pathMaskingRelayControl(resourceID string, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + return resourceHijackingBestControl( + resourceID, + assignments, + []string{"Microsoft.Relay/namespaces/write", "Microsoft.Relay/namespaces/hybridConnections/write"}, + "Owner", + "Contributor", + "Azure Relay Owner", + ) +} + +func pathMaskingRelayState(namespace models.RelayNamespaceAsset) models.PathMaskingRelayState { + names := make([]string, 0, len(namespace.HybridConnections)) + listenerParts := []string{} + attachments := []string{} + for _, connection := range namespace.HybridConnections { + names = append(names, connection.Name) + if relayIntValue(connection.ListenerCount) > 0 { + listenerParts = append(listenerParts, fmt.Sprintf("%s=%d", connection.Name, relayIntValue(connection.ListenerCount))) + } + for _, app := range connection.AppServiceAttachments { + attachments = append(attachments, connection.Name+"->"+app) + } + } + listenerSummary := "no listener counts visible" + if len(listenerParts) > 0 { + listenerSummary = strings.Join(listenerParts, "; ") + } + sort.Strings(names) + sort.Strings(attachments) + return models.PathMaskingRelayState{ + ServiceBusEndpoint: namespace.ServiceBusEndpoint, + HybridConnectionCount: namespace.HybridConnectionCount, + AuthorizationRuleCount: namespace.AuthorizationRuleCount, + HybridConnectionNames: names, + ListenerSummary: listenerSummary, + AppServiceAttachments: attachments, + Posture: pathMaskingRelayPosture(namespace, listenerSummary), + } +} + +func pathMaskingRelayPosture(namespace models.RelayNamespaceAsset, listenerSummary string) string { + parts := []string{} + if relayIntValue(namespace.HybridConnectionCount) > 0 { + parts = append(parts, fmt.Sprintf("%d Hybrid Connection(s)", relayIntValue(namespace.HybridConnectionCount))) + } + if relayIntValue(namespace.AuthorizationRuleCount) > 0 { + parts = append(parts, fmt.Sprintf("%d authorization rule(s)", relayIntValue(namespace.AuthorizationRuleCount))) + } + if listenerSummary != "no listener counts visible" { + parts = append(parts, "listener counts "+listenerSummary) + } + if pathMaskingRelayAttachmentCount(namespace) > 0 { + parts = append(parts, fmt.Sprintf("%d App Service Hybrid Connection attachment(s)", pathMaskingRelayAttachmentCount(namespace))) + } + if len(parts) == 0 { + return "Relay namespace visible without stronger Hybrid Connection or authorization posture" + } + return strings.Join(parts, "; ") +} + +func pathMaskingRelayRank(namespace models.RelayNamespaceAsset, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + hasHybrid := relayIntValue(namespace.HybridConnectionCount) > 0 || len(namespace.HybridConnections) > 0 + hasAuthRules := relayIntValue(namespace.AuthorizationRuleCount) > 0 + hasListener := false + hasAttachment := pathMaskingRelayAttachmentCount(namespace) > 0 + for _, connection := range namespace.HybridConnections { + if relayIntValue(connection.ListenerCount) > 0 { + hasListener = true + break + } + } + switch { + case hasHybrid && hasAuthRules && hasListener && hasAttachment: + rank = 5 + reasons = append(reasons, "Hybrid Connection, authorization rule, listener-count, and App Service attachment posture are visible") + case hasHybrid && hasAuthRules: + rank = 4 + reasons = append(reasons, "Hybrid Connection and authorization rule posture are visible") + case hasHybrid: + rank = 3 + reasons = append(reasons, "Hybrid Connection posture is visible") + case hasAuthRules: + rank = 2 + reasons = append(reasons, "authorization rule posture is visible without a visible Hybrid Connection") + } + if controlOK { + reasons = append(reasons, "current identity has visible Relay namespace or Hybrid Connection write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic pathmasking ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func pathMaskingRelayNotCollectedByDefault() []models.PathMaskingBoundaryNote { + return []models.PathMaskingBoundaryNote{ + {Name: "listener runtime state", Classification: "proof boundary", Reason: "Management-plane posture and listener counts do not prove a current listener process, host, or session."}, + {Name: "backend process and host", Classification: "proof boundary", Reason: "Relay names and endpoints do not identify the private service or process behind the listener."}, + {Name: "traffic contents", Classification: "proof boundary", Reason: "The command does not inspect Relay traffic or payloads."}, + {Name: "authorization keys", Classification: "recon safety", Reason: "Authorization rules are counted, but key material is not retrieved or printed."}, + {Name: "App Service backend internals", Classification: "proof boundary", Reason: "App Service Hybrid Connection attachments can show reachability posture, but they do not identify the private listener host, process, or traffic contents."}, + } +} + +func pathMaskingRelaySummary(namespace models.RelayNamespaceAsset, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("namespace %q ranks %d/5 for Relay pathmasking posture", namespace.Name, rank)} + if relayIntValue(namespace.HybridConnectionCount) > 0 { + parts = append(parts, fmt.Sprintf("%d Hybrid Connection(s)", relayIntValue(namespace.HybridConnectionCount))) + } + if relayIntValue(namespace.AuthorizationRuleCount) > 0 { + parts = append(parts, fmt.Sprintf("%d authorization rule(s)", relayIntValue(namespace.AuthorizationRuleCount))) + } + if attachmentCount := pathMaskingRelayAttachmentCount(namespace); attachmentCount > 0 { + parts = append(parts, fmt.Sprintf("%d App Service attachment(s)", attachmentCount)) + } + if controlOK { + parts = append(parts, "current identity can change Relay path posture from visible RBAC") + } else { + parts = append(parts, "current identity Relay write control is not proven") + } + return strings.Join(parts, "; ") + "." +} + +func pathMaskingRelayAttachmentCount(namespace models.RelayNamespaceAsset) int { + total := 0 + for _, connection := range namespace.HybridConnections { + total += len(connection.AppServiceAttachments) + } + return total +} diff --git a/internal/commands/path_masking_test.go b/internal/commands/path_masking_test.go new file mode 100644 index 0000000..bda9bec --- /dev/null +++ b/internal/commands/path_masking_test.go @@ -0,0 +1,27 @@ +package commands + +import ( + "strings" + "testing" + + "harrierops-azure/internal/models" +) + +func TestPathMaskingAPIMRankDoesNotCallUnknownPublicPosturePublic(t *testing.T) { + disabled := "Disabled" + apiCount := 1 + backendCount := 1 + _, reason := pathMaskingAPIMRank(models.ApiMgmtServiceAsset{ + GatewayHostnames: []string{"internal.contoso.test"}, + BackendCount: &backendCount, + APICount: &apiCount, + PublicNetworkAccess: &disabled, + }, false) + + if strings.Contains(reason, "public gateway") { + t.Fatalf("expected non-public APIM posture not to be labeled public, got %q", reason) + } + if !strings.Contains(reason, "gateway, backend indirection") { + t.Fatalf("expected neutral gateway wording, got %q", reason) + } +} diff --git a/internal/commands/registry.go b/internal/commands/registry.go index 3274a8b..f1cbcd0 100644 --- a/internal/commands/registry.go +++ b/internal/commands/registry.go @@ -12,14 +12,17 @@ import ( ) type Request struct { - Tenant string - Subscription string - DevOpsOrganization string - ChainFamily string - PersistenceSurface string - Output models.OutputMode - RoleTrustsMode models.RoleTrustsMode - OutDir string + Tenant string + Subscription string + DevOpsOrganization string + ChainFamily string + PersistenceSurface string + EvasionSurface string + ResourceHijackingSurface string + PathMaskingSurface string + Output models.OutputMode + RoleTrustsMode models.RoleTrustsMode + OutDir string } type Response struct { @@ -35,10 +38,69 @@ type Definition struct { Handler Handler } +type handlerFactory func(providers.Provider, func() time.Time) Handler + type Registry struct { definitions map[string]Definition } +var commandHandlers = map[string]handlerFactory{ + "acr": acrHandler, + "aks": aksHandler, + "api-mgmt": apiMgmtHandler, + "app-credentials": appCredentialsHandler, + "app-services": appServicesHandler, + "appinsights": appInsightsHandler, + "application-gateway": applicationGatewayHandler, + "arm-deployments": armDeploymentsHandler, + "auth-policies": authPoliciesHandler, + "automation": automationHandler, + "azure-ml": azureMLHandler, + "chains": chainsHandler, + "container-apps": containerAppsHandler, + "container-apps-jobs": containerAppsJobsHandler, + "container-instances": containerInstancesHandler, + "cross-tenant": crossTenantHandler, + "databases": databasesHandler, + "dcr": dcrHandler, + "devops": devopsHandler, + "diagnostic-settings": diagnosticSettingsHandler, + "dns": dnsHandler, + "endpoints": endpointsHandler, + "env-vars": envVarsHandler, + "event-grid": eventGridHandler, + "evasion": evasionHandler, + "functions": functionsHandler, + "inventory": inventoryHandler, + "keyvault": keyVaultHandler, + "lighthouse": lighthouseHandler, + "logic-apps": logicAppsHandler, + "managed-identities": managedIdentitiesHandler, + "monitoring-sinks": monitoringSinksHandler, + "network-effective": networkEffectiveHandler, + "network-ports": networkPortsHandler, + "nics": nicsHandler, + "pathmasking": pathMaskingHandler, + "permissions": permissionsHandler, + "persistence": persistenceHandler, + "principals": principalsHandler, + "privesc": privescHandler, + "rbac": rbacHandler, + "relay": relayHandler, + "resource-trusts": resourceTrustsHandler, + "resourcehijacking": resourceHijackingHandler, + "role-trusts": roleTrustsHandler, + "snapshots-disks": snapshotsDisksHandler, + "storage": storageHandler, + "tokens-credentials": tokensCredentialsHandler, + "vm-extensions": vmExtensionsHandler, + "vms": vmsHandler, + "vmss": vmssHandler, + "webjobs": webJobsHandler, + "whoami": whoAmIHandler, + "workloads": workloadsHandler, +} + func NewRegistry(provider providers.Provider, now func() time.Time) *Registry { definitions := map[string]Definition{} for _, name := range contracts.CommandNames() { @@ -53,102 +115,11 @@ func NewRegistry(provider providers.Provider, now func() time.Time) *Registry { } func handlerFor(name string, provider providers.Provider, now func() time.Time) Handler { - switch name { - case "whoami": - return whoAmIHandler(provider, now) - case "inventory": - return inventoryHandler(provider, now) - case "automation": - return automationHandler(provider, now) - case "devops": - return devopsHandler(provider, now) - case "acr": - return acrHandler(provider, now) - case "databases": - return databasesHandler(provider, now) - case "storage": - return storageHandler(provider, now) - case "snapshots-disks": - return snapshotsDisksHandler(provider, now) - case "keyvault": - return keyVaultHandler(provider, now) - case "application-gateway": - return applicationGatewayHandler(provider, now) - case "dns": - return dnsHandler(provider, now) - case "aks": - return aksHandler(provider, now) - case "api-mgmt": - return apiMgmtHandler(provider, now) - case "app-credentials": - return appCredentialsHandler(provider, now) - case "app-services": - return appServicesHandler(provider, now) - case "functions": - return functionsHandler(provider, now) - case "webjobs": - return webJobsHandler(provider, now) - case "azure-ml": - return azureMLHandler(provider, now) - case "event-grid": - return eventGridHandler(provider, now) - case "logic-apps": - return logicAppsHandler(provider, now) - case "container-apps": - return containerAppsHandler(provider, now) - case "container-apps-jobs": - return containerAppsJobsHandler(provider, now) - case "container-instances": - return containerInstancesHandler(provider, now) - case "arm-deployments": - return armDeploymentsHandler(provider, now) - case "endpoints": - return endpointsHandler(provider, now) - case "network-ports": - return networkPortsHandler(provider, now) - case "network-effective": - return networkEffectiveHandler(provider, now) - case "nics": - return nicsHandler(provider, now) - case "vms": - return vmsHandler(provider, now) - case "vm-extensions": - return vmExtensionsHandler(provider, now) - case "vmss": - return vmssHandler(provider, now) - case "workloads": - return workloadsHandler(provider, now) - case "rbac": - return rbacHandler(provider, now) - case "principals": - return principalsHandler(provider, now) - case "permissions": - return permissionsHandler(provider, now) - case "privesc": - return privescHandler(provider, now) - case "lighthouse": - return lighthouseHandler(provider, now) - case "cross-tenant": - return crossTenantHandler(provider, now) - case "role-trusts": - return roleTrustsHandler(provider, now) - case "auth-policies": - return authPoliciesHandler(provider, now) - case "resource-trusts": - return resourceTrustsHandler(provider, now) - case "managed-identities": - return managedIdentitiesHandler(provider, now) - case "env-vars": - return envVarsHandler(provider, now) - case "tokens-credentials": - return tokensCredentialsHandler(provider, now) - case "chains": - return chainsHandler(provider, now) - case "persistence": - return persistenceHandler(provider, now) - default: + factory, ok := commandHandlers[name] + if !ok { return nil } + return factory(provider, now) } func (registry *Registry) Run(ctx context.Context, name string, request Request) (Response, error) { diff --git a/internal/commands/registry_test.go b/internal/commands/registry_test.go index 7709f70..cac9c6f 100644 --- a/internal/commands/registry_test.go +++ b/internal/commands/registry_test.go @@ -47,3 +47,39 @@ func TestPersistenceSurfaceBuildersCoverImplementedSurfaces(t *testing.T) { } } } + +func TestEvasionSurfaceBuildersCoverImplementedSurfaces(t *testing.T) { + for _, surfaceName := range contracts.EvasionSurfaceNames() { + surface, ok := contracts.EvasionSurface(surfaceName) + if !ok || surface.Status != contracts.StatusImplemented { + continue + } + if evasionSurfaceBuilders[surfaceName] == nil { + t.Fatalf("expected implemented evasion surface %q to have a builder", surfaceName) + } + } +} + +func TestResourceHijackingSurfaceBuildersCoverImplementedSurfaces(t *testing.T) { + for _, surfaceName := range contracts.ResourceHijackingSurfaceNames() { + surface, ok := contracts.ResourceHijackingSurface(surfaceName) + if !ok || surface.Status != contracts.StatusImplemented { + continue + } + if resourceHijackingSurfaceBuilders[surfaceName] == nil { + t.Fatalf("expected implemented resourcehijacking surface %q to have a builder", surfaceName) + } + } +} + +func TestPathMaskingSurfaceBuildersCoverImplementedSurfaces(t *testing.T) { + for _, surfaceName := range contracts.PathMaskingSurfaceNames() { + surface, ok := contracts.PathMaskingSurface(surfaceName) + if !ok || surface.Status != contracts.StatusImplemented { + continue + } + if pathMaskingSurfaceBuilders[surfaceName] == nil { + t.Fatalf("expected implemented pathmasking surface %q to have a builder", surfaceName) + } + } +} diff --git a/internal/commands/relay.go b/internal/commands/relay.go new file mode 100644 index 0000000..cdd0233 --- /dev/null +++ b/internal/commands/relay.go @@ -0,0 +1,59 @@ +package commands + +import ( + "context" + "sort" + "time" + + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +func relayHandler(provider providers.Provider, now func() time.Time) Handler { + return func(ctx context.Context, request Request) (any, error) { + facts, err := provider.Relay(ctx, request.Tenant, request.Subscription) + if err != nil { + return nil, err + } + namespaces := append([]models.RelayNamespaceAsset{}, facts.Namespaces...) + sort.SliceStable(namespaces, func(i, j int) bool { + if relayPriority(namespaces[i]) != relayPriority(namespaces[j]) { + return relayPriority(namespaces[i]) > relayPriority(namespaces[j]) + } + if relayIntValue(namespaces[i].HybridConnectionCount) != relayIntValue(namespaces[j].HybridConnectionCount) { + return relayIntValue(namespaces[i].HybridConnectionCount) > relayIntValue(namespaces[j].HybridConnectionCount) + } + return namespaces[i].Name < namespaces[j].Name + }) + return models.RelayOutput{ + Findings: []models.Finding{}, + Issues: facts.Issues, + Metadata: runtimeCommandMetadata("relay", now, facts.TenantID, facts.SubscriptionID), + Namespaces: namespaces, + }, nil + } +} + +func relayPriority(namespace models.RelayNamespaceAsset) int { + score := 0 + if relayIntValue(namespace.HybridConnectionCount) > 0 { + score += 3 + } + if relayIntValue(namespace.AuthorizationRuleCount) > 0 { + score += 1 + } + for _, connection := range namespace.HybridConnections { + if relayIntValue(connection.ListenerCount) > 0 { + score += 2 + break + } + } + return score +} + +func relayIntValue(value *int) int { + if value == nil { + return 0 + } + return *value +} diff --git a/internal/commands/resource_hijacking.go b/internal/commands/resource_hijacking.go new file mode 100644 index 0000000..716ede9 --- /dev/null +++ b/internal/commands/resource_hijacking.go @@ -0,0 +1,59 @@ +package commands + +import ( + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +const ( + resourceHijackingCurrentBehavior = "Grouped resourcehijacking walkthroughs. Use `ho-azure resourcehijacking` or `ho-azure resourcehijacking help` to list surfaces, then `ho-azure resourcehijacking ` to run an implemented surface." + resourceHijackingCommandState = contracts.StatusImplemented +) + +var ( + resourceHijackingInputModes = []string{"live"} + resourceHijackingPreferredArtifactMode = []string{"loot", "json"} +) + +var resourceHijackingSurfaceBuilders = map[string]groupedSurfaceBuilder{ + "api-mgmt": buildResourceHijackingAPIMOutput, + "automation": buildResourceHijackingAutomationOutput, + "logic-apps": buildResourceHijackingLogicAppsOutput, +} + +func resourceHijackingHandler(provider providers.Provider, now func() time.Time) Handler { + return groupedFamilyHandler(provider, now, resourceHijackingFamilyConfig()) +} + +func buildResourceHijackingOverview(now func() time.Time, request Request, selectedSurface *string) any { + config := resourceHijackingFamilyConfig() + return models.ResourceHijackingOverviewOutput{ + Metadata: scopedMetadata(now, request, request.Tenant, request.Subscription, config.CommandName), + GroupedCommandName: config.CommandName, + CommandState: config.CommandState, + CurrentBehavior: config.CurrentBehavior, + PlannedInputModes: append([]string{}, config.InputModes...), + PreferredArtifactOrder: append([]string{}, config.PreferredArtifactOrder...), + SelectedSurface: selectedSurface, + Surfaces: groupedFamilySurfaceDescriptors(config), + Issues: []models.Issue{}, + } +} + +func resourceHijackingFamilyConfig() groupedFamilyConfig { + return groupedFamilyConfig{ + CommandName: "resourcehijacking", + CurrentBehavior: resourceHijackingCurrentBehavior, + CommandState: resourceHijackingCommandState, + InputModes: resourceHijackingInputModes, + PreferredArtifactOrder: resourceHijackingPreferredArtifactMode, + Selector: func(request Request) string { return request.ResourceHijackingSurface }, + Overview: buildResourceHijackingOverview, + SurfaceNames: contracts.ResourceHijackingSurfaceNames, + SurfaceContract: contracts.ResourceHijackingSurface, + SurfaceBuilders: resourceHijackingSurfaceBuilders, + } +} diff --git a/internal/commands/resource_hijacking_api_mgmt.go b/internal/commands/resource_hijacking_api_mgmt.go new file mode 100644 index 0000000..32b6139 --- /dev/null +++ b/internal/commands/resource_hijacking_api_mgmt.go @@ -0,0 +1,250 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var resourceHijackingAPIMSteps = []familyStepDefinition{ + {Action: "select trusted gateway", APISurface: "Microsoft.ApiManagement/service", DownstreamEffect: "Keeps clients on the existing APIM hostname and API surface.", Boundary: "Gateway posture does not prove traffic volume."}, + {Action: "identify backend control point", APISurface: "Microsoft.ApiManagement/service/backends", DownstreamEffect: "Shows where APIM can forward requests behind the stable front door.", Boundary: "Backend hostnames do not prove ownership or runtime reachability."}, + {Action: "change backend or routing policy", APISurface: "APIM backend or policy write", NeedsWrite: true, DownstreamEffect: "Can redirect selected API traffic while the published APIM surface remains healthy.", Boundary: "This command does not collect policy XML bodies by default."}, + {Action: "preserve subscriptions and named values", APISurface: "APIM subscriptions and named values", NeedsWrite: true, DownstreamEffect: "Existing consumers, subscription gates, and stored config can keep the route looking operational.", Boundary: "Named-value values and secrets are not printed."}, + {Action: "blend as API operations change", APISurface: "APIM service configuration", DownstreamEffect: "Normal cover stories include backend migration, failover, version routing, and blue/green release work.", Boundary: "Cover story is not an intent claim."}, +} + +func buildResourceHijackingAPIMOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + apiMgmt, err := apiMgmtFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.ResourceHijackingAPIMTarget, 0, len(apiMgmt.ApiManagementServices)) + for _, service := range apiMgmt.ApiManagementServices { + control, controlOK := resourceHijackingAPIMControl(service.ID, evidence.principal.currentIdentityAssignments) + rank, reason := resourceHijackingAPIMTakeoverRank(service, controlOK) + targets = append(targets, models.ResourceHijackingAPIMTarget{ + ID: service.ID, + Name: service.Name, + ResourceGroup: service.ResourceGroup, + Location: service.Location, + TakeoverRank: rank, + TakeoverReason: reason, + CapabilitySteps: resourceHijackingAPIMCapabilitySteps(controlOK), + CurrentIdentityContext: resourceHijackingRoleContext(evidence.principal.currentIdentity, control, controlOK, "APIM backend or policy write control", "APIM backend/policy write"), + CurrentState: resourceHijackingAPIMState(service), + NotCollectedByDefault: resourceHijackingAPIMNotCollectedByDefault(), + Summary: resourceHijackingAPIMSummary(service, rank, controlOK), + RelatedIDs: mergeRelatedIDs(service.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].TakeoverRank != targets[j].TakeoverRank { + return targets[i].TakeoverRank > targets[j].TakeoverRank + } + return targets[i].Name < targets[j].Name + }) + + 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", + ), + GroupedCommandName: "resourcehijacking", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Issues: issues, + }, nil +} + +func resourceHijackingAPIMControl(resourceID string, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + actions := []string{ + "Microsoft.ApiManagement/service/write", + "Microsoft.ApiManagement/service/backends/write", + "Microsoft.ApiManagement/service/apis/policies/write", + "Microsoft.ApiManagement/service/policies/write", + } + return resourceHijackingBestControl(resourceID, assignments, actions, "Owner", "Contributor", "API Management Service Contributor") +} + +func resourceHijackingBestControl(resourceID string, assignments []models.RoleAssignment, actions []string, roleNames ...string) (persistenceCurrentIdentityControl, bool) { + bestRank := 99 + best := persistenceCurrentIdentityControl{} + for _, assignment := range assignments { + allowed := false + for _, action := range actions { + if persistenceRoleAssignmentAllowsNamedOrActionControl(assignment, action, roleNames...) { + allowed = true + break + } + } + if !allowed { + continue + } + rank, ok := persistenceScopeRank(assignment.ScopeID, resourceID) + if !ok || rank >= bestRank { + continue + } + bestRank = rank + best = persistenceCurrentIdentityControl{ + RoleName: fmt.Sprintf("%s at %s", assignment.RoleName, persistenceScopeLabel(assignment.ScopeID)), + ScopeID: assignment.ScopeID, + } + } + return best, bestRank != 99 +} + +func resourceHijackingRoleContext( + currentIdentity models.PermissionRow, + control persistenceCurrentIdentityControl, + controlOK bool, + controlName string, + controlLabel string, +) *models.ResourceHijackingRoleContext { + if strings.TrimSpace(currentIdentity.DisplayName) == "" && !controlOK { + return nil + } + name := firstNonEmpty(currentIdentity.DisplayName, "current identity") + roleNames := append([]string{}, currentIdentity.HighImpactRoles...) + if len(roleNames) == 0 { + roleNames = append(roleNames, currentIdentity.AllRoleNames...) + } + scopeIDs := append([]string{}, currentIdentity.ScopeIDs...) + summary := "Current foothold identity is visible, but " + controlName + " is not proven here." + label := "not proven" + if controlOK { + summary = fmt.Sprintf("Current foothold `%s` has visible %s.", name, controlName) + roleNames = []string{evasionControlRoleName(control)} + scopeIDs = []string{control.ScopeID} + label = controlLabel + } + return &models.ResourceHijackingRoleContext{ + Name: name, + Kind: "current-foothold", + PrincipalID: stringPtrIf(currentIdentity.PrincipalID), + RoleNames: dedupeStrings(roleNames), + ScopeIDs: dedupeStrings(scopeIDs), + ControlLabel: label, + Summary: summary, + } +} + +func resourceHijackingAPIMCapabilitySteps(controlOK bool) []models.ResourceHijackingCapabilityStep { + return familyCapabilitySteps(resourceHijackingAPIMSteps, controlOK) +} + +func resourceHijackingAPIMState(service models.ApiMgmtServiceAsset) models.ResourceHijackingAPIMState { + return models.ResourceHijackingAPIMState{ + State: service.State, + PublicNetworkAccess: service.PublicNetworkAccess, + VirtualNetworkType: service.VirtualNetworkType, + GatewayHostnames: append([]string{}, service.GatewayHostnames...), + BackendHostnames: append([]string{}, service.BackendHostnames...), + APICount: service.APICount, + SubscriptionCount: service.SubscriptionCount, + ActiveSubscriptionCount: service.ActiveSubscriptionCount, + BackendCount: service.BackendCount, + PolicyCount: service.PolicyCount, + PolicyControlTypes: append([]string{}, service.PolicyControlTypes...), + NamedValueSecretCount: service.NamedValueSecretCount, + NamedValueKeyVaultCount: service.NamedValueKeyVaultCount, + WorkloadIdentityType: service.WorkloadIdentityType, + Posture: resourceHijackingAPIMPosture(service), + } +} + +func resourceHijackingAPIMPosture(service models.ApiMgmtServiceAsset) string { + parts := familyAPIMPostureParts(service, familyAPIMPostureOptions{ + IncludeActiveSubscriptionCount: true, + IncludeNamedValueSecretPosture: true, + }) + if len(parts) == 0 { + return "APIM service visible without stronger routing posture" + } + return strings.Join(parts, "; ") +} + +func resourceHijackingAPIMTakeoverRank(service models.ApiMgmtServiceAsset, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + hasGateway := len(service.GatewayHostnames) > 0 + hasBackend := len(service.BackendHostnames) > 0 || apiMgmtIntValue(service.BackendCount) > 0 || len(service.PolicyControlTypes) > 0 + hasConsumers := apiMgmtIntValue(service.ActiveSubscriptionCount) > 0 || apiMgmtIntValue(service.APISubscriptionRequiredCount) > 0 + switch { + case hasGateway && hasBackend && hasConsumers: + rank = 5 + reasons = append(reasons, "trusted gateway, backend target, and consumer/subscription posture are all visible") + case hasGateway && hasBackend: + rank = 4 + reasons = append(reasons, "trusted gateway and backend target posture are visible") + case hasGateway || hasBackend: + rank = 3 + reasons = append(reasons, "partial gateway or backend routing posture is visible") + case apiMgmtIntValue(service.APICount) > 0: + rank = 2 + reasons = append(reasons, "APIM APIs are visible but stronger backend routing posture is not") + } + if controlOK { + reasons = append(reasons, "current identity has visible APIM backend or policy write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic takeover ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func resourceHijackingAPIMNotCollectedByDefault() []models.ResourceHijackingBoundaryNote { + return []models.ResourceHijackingBoundaryNote{ + {Name: "policy XML bodies", Classification: "recon safety", Reason: "The flat helper parses safe policy control types, but does not print raw policy XML or named-value expansions."}, + {Name: "named-value values", Classification: "recon safety", Reason: "Default output reports secret and Key Vault named-value counts without printing stored values."}, + {Name: "live request flow", Classification: "proof boundary", Reason: "Management-plane posture cannot prove traffic was routed, captured, or modified."}, + {Name: "backend ownership", Classification: "proof boundary", Reason: "A backend hostname does not prove who controls that endpoint or whether it is reachable."}, + {Name: "activity history", Classification: "API/noise", Reason: "Broad APIM history pulls are not needed for default posture and should be a narrow follow-up only when timing or actor proof matters."}, + } +} + +func resourceHijackingAPIMSummary(service models.ApiMgmtServiceAsset, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("service %q ranks %d/5 for APIM resource-hijack posture", service.Name, rank)} + if len(service.GatewayHostnames) > 0 { + parts = append(parts, fmt.Sprintf("%d gateway hostname(s)", len(service.GatewayHostnames))) + } + if len(service.BackendHostnames) > 0 { + parts = append(parts, fmt.Sprintf("%d backend hostname(s)", len(service.BackendHostnames))) + } + if controlOK { + parts = append(parts, "current identity can modify APIM backend or policy posture from visible RBAC") + } else { + parts = append(parts, "current identity backend or policy write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/resource_hijacking_automation.go b/internal/commands/resource_hijacking_automation.go new file mode 100644 index 0000000..94add88 --- /dev/null +++ b/internal/commands/resource_hijacking_automation.go @@ -0,0 +1,206 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var resourceHijackingAutomationSteps = []familyStepDefinition{ + {Action: "select trusted automation account", APISurface: "Microsoft.Automation/automationAccounts", DownstreamEffect: "Keeps the existing operations automation account, modules, assets, and expected maintenance context in place.", Boundary: "Account posture does not prove any job ran."}, + {Action: "edit published runbook", APISurface: "Microsoft.Automation/automationAccounts/runbooks", NeedsWrite: true, DownstreamEffect: "Can change script logic that operators already expect Azure Automation to run.", Boundary: "Default output does not print runbook script content."}, + {Action: "reuse schedule or webhook trigger", APISurface: "job schedules and webhooks", NeedsWrite: true, DownstreamEffect: "Can preserve the existing invocation path while changing what the runbook does.", Boundary: "Trigger posture does not prove invocation."}, + {Action: "reuse automation identity or worker context", APISurface: "automation account identity and hybrid workers", NeedsWrite: true, DownstreamEffect: "Can run altered automation through already-integrated identity, worker, or secure-asset context.", Boundary: "Host state, job output, and secure asset values are not collected."}, + {Action: "blend as maintenance automation", APISurface: "automation account configuration", DownstreamEffect: "Normal cover stories include patching, remediation, cleanup, and scheduled maintenance script updates.", Boundary: "Cover story is not an intent claim."}, +} + +func buildResourceHijackingAutomationOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + automation, err := automationFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.ResourceHijackingAutomationTarget, 0, len(automation.AutomationAccounts)) + for _, account := range automation.AutomationAccounts { + control, controlOK := persistenceAutomationControl(account.ID, evidence.principal.currentIdentityAssignments) + rank, reason := resourceHijackingAutomationTakeoverRank(account, controlOK) + targets = append(targets, models.ResourceHijackingAutomationTarget{ + ID: account.ID, + Name: account.Name, + ResourceGroup: account.ResourceGroup, + Location: account.Location, + TakeoverRank: rank, + TakeoverReason: reason, + CapabilitySteps: resourceHijackingAutomationCapabilitySteps(controlOK), + CurrentIdentityContext: resourceHijackingRoleContext(evidence.principal.currentIdentity, control, controlOK, "Automation account or runbook write control", "automation write"), + CurrentState: resourceHijackingAutomationState(account), + NotCollectedByDefault: resourceHijackingAutomationNotCollectedByDefault(), + Summary: resourceHijackingAutomationSummary(account, rank, controlOK), + RelatedIDs: mergeRelatedIDs(account.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].TakeoverRank != targets[j].TakeoverRank { + return targets[i].TakeoverRank > targets[j].TakeoverRank + } + return targets[i].Name < targets[j].Name + }) + + 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", + ), + GroupedCommandName: "resourcehijacking", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Issues: issues, + }, nil +} + +func resourceHijackingAutomationCapabilitySteps(controlOK bool) []models.ResourceHijackingCapabilityStep { + return familyCapabilitySteps(resourceHijackingAutomationSteps, controlOK) +} + +func resourceHijackingAutomationState(account models.AutomationAccountAsset) models.ResourceHijackingAutomationState { + return models.ResourceHijackingAutomationState{ + State: account.State, + IdentityType: account.IdentityType, + PublishedRunbookCount: account.PublishedRunbookCount, + PublishedRunbookNames: append([]string{}, account.PublishedRunbookNames...), + RunbookTypes: append([]string{}, account.RunbookTypes...), + RunbookCommandClues: append([]string{}, account.RunbookCommandClues...), + RunbookResourceClues: append([]string{}, account.RunbookResourceClues...), + ScheduleCount: account.ScheduleCount, + JobScheduleCount: account.JobScheduleCount, + WebhookCount: account.WebhookCount, + HybridWorkerGroupCount: account.HybridWorkerGroupCount, + PrimaryStartMode: account.PrimaryStartMode, + PrimaryRunbookName: account.PrimaryRunbookName, + ScheduleRunbookNames: append([]string{}, account.ScheduleRunbookNames...), + WebhookRunbookNames: append([]string{}, account.WebhookRunbookNames...), + ConsequenceTypes: append([]string{}, account.ConsequenceTypes...), + Posture: resourceHijackingAutomationPosture(account), + } +} + +func resourceHijackingAutomationPosture(account models.AutomationAccountAsset) string { + parts := []string{} + if apiMgmtIntValue(account.PublishedRunbookCount) > 0 { + parts = append(parts, fmt.Sprintf("%d published runbook(s)", apiMgmtIntValue(account.PublishedRunbookCount))) + } + if len(account.RunbookTypes) > 0 { + parts = append(parts, "runbook types "+strings.Join(account.RunbookTypes, ", ")) + } + if len(account.RunbookCommandClues) > 0 { + parts = append(parts, "command clues "+strings.Join(account.RunbookCommandClues, ", ")) + } + if len(account.RunbookResourceClues) > 0 { + parts = append(parts, "resource clues "+strings.Join(account.RunbookResourceClues, ", ")) + } + if apiMgmtIntValue(account.JobScheduleCount) > 0 || apiMgmtIntValue(account.WebhookCount) > 0 { + parts = append(parts, "schedule or webhook trigger posture") + } + if strings.TrimSpace(stringPtrValue(account.IdentityType)) != "" { + parts = append(parts, "managed identity posture") + } + if apiMgmtIntValue(account.HybridWorkerGroupCount) > 0 { + parts = append(parts, "hybrid worker posture") + } + if len(account.ConsequenceTypes) > 0 { + parts = append(parts, "consequence types "+strings.Join(account.ConsequenceTypes, ", ")) + } + if len(parts) == 0 { + return "Automation account visible without stronger runbook, trigger, or identity posture" + } + return strings.Join(parts, "; ") +} + +func resourceHijackingAutomationTakeoverRank(account models.AutomationAccountAsset, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + hasRunbook := apiMgmtIntValue(account.PublishedRunbookCount) > 0 || len(account.PublishedRunbookNames) > 0 + hasRunbookPosture := hasRunbook && (len(account.RunbookTypes) > 0 || len(account.RunbookCommandClues) > 0 || len(account.RunbookResourceClues) > 0) + hasTrigger := apiMgmtIntValue(account.JobScheduleCount) > 0 || apiMgmtIntValue(account.WebhookCount) > 0 + hasIdentity := strings.TrimSpace(stringPtrValue(account.IdentityType)) != "" + hasWorker := apiMgmtIntValue(account.HybridWorkerGroupCount) > 0 + switch { + case hasRunbookPosture && hasTrigger && hasIdentity: + rank = 5 + reasons = append(reasons, "published runbook posture, trigger posture, and automation identity are visible") + case hasRunbookPosture && hasTrigger && hasWorker: + rank = 4 + reasons = append(reasons, "published runbook posture, trigger posture, and hybrid worker context are visible") + case hasRunbook && hasTrigger: + rank = 3 + reasons = append(reasons, "published runbook and trigger posture are visible") + case hasRunbook || hasTrigger: + rank = 2 + reasons = append(reasons, "partial runbook or trigger posture is visible") + } + if controlOK { + reasons = append(reasons, "current identity has visible Automation account or runbook write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic takeover ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func resourceHijackingAutomationNotCollectedByDefault() []models.ResourceHijackingBoundaryNote { + return []models.ResourceHijackingBoundaryNote{ + {Name: "runbook script content", Classification: "recon safety", Reason: "The live helper reports safe runbook type and trigger posture by default; content-derived command/resource clues require a narrower review path and raw script bodies are not printed."}, + {Name: "secure asset values", Classification: "recon safety", Reason: "Automation credentials, certificates, connections, and encrypted variables are not safe default output."}, + {Name: "job output and status history", Classification: "proof boundary", Reason: "Management-plane posture cannot prove a changed runbook executed or what it produced."}, + {Name: "hybrid worker host state", Classification: "proof boundary", Reason: "Automation account posture cannot prove guest-side host impact without worker or host evidence."}, + {Name: "activity history", Classification: "API/noise", Reason: "Broad Automation history pulls are not needed for default posture and should be a narrow follow-up for timing or actor proof."}, + } +} + +func resourceHijackingAutomationSummary(account models.AutomationAccountAsset, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("account %q ranks %d/5 for Automation resource-hijack posture", account.Name, rank)} + if apiMgmtIntValue(account.PublishedRunbookCount) > 0 { + parts = append(parts, fmt.Sprintf("%d published runbook(s)", apiMgmtIntValue(account.PublishedRunbookCount))) + } + if apiMgmtIntValue(account.JobScheduleCount) > 0 { + parts = append(parts, fmt.Sprintf("%d job schedule(s)", apiMgmtIntValue(account.JobScheduleCount))) + } + if apiMgmtIntValue(account.WebhookCount) > 0 { + parts = append(parts, fmt.Sprintf("%d webhook(s)", apiMgmtIntValue(account.WebhookCount))) + } + if controlOK { + parts = append(parts, "current identity can modify Automation posture from visible RBAC") + } else { + parts = append(parts, "current identity Automation write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/commands/resource_hijacking_logic_apps.go b/internal/commands/resource_hijacking_logic_apps.go new file mode 100644 index 0000000..efe1272 --- /dev/null +++ b/internal/commands/resource_hijacking_logic_apps.go @@ -0,0 +1,166 @@ +package commands + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "harrierops-azure/internal/contracts" + "harrierops-azure/internal/models" + "harrierops-azure/internal/providers" +) + +var resourceHijackingLogicAppSteps = []familyStepDefinition{ + {Action: "select trusted workflow", APISurface: "Microsoft.Logic/workflows", DownstreamEffect: "Keeps the existing automation resource, trigger path, and operational context in place.", Boundary: "Workflow posture does not prove the workflow ran."}, + {Action: "edit workflow definition", APISurface: "workflow definition", NeedsWrite: true, DownstreamEffect: "Can add, remove, or repurpose actions while the trusted Logic App remains the same resource.", Boundary: "Default output does not print full workflow definition bodies."}, + {Action: "repurpose trigger", APISurface: "request, recurrence, api-connection, or event trigger", NeedsWrite: true, DownstreamEffect: "Can keep an existing inbound, scheduled, or connector trigger while changing what happens after it fires.", Boundary: "Trigger posture does not prove trigger invocation."}, + {Action: "reuse connector or identity context", APISurface: "workflow identity and connection references", NeedsWrite: true, DownstreamEffect: "Can run altered workflow logic through already-integrated connectors or managed identity context.", Boundary: "Connector credential values and secret material are not collected."}, + {Action: "blend as integration maintenance", APISurface: "workflow configuration", DownstreamEffect: "Normal cover stories include connector refresh, integration repair, retry handling, or workflow modernization.", Boundary: "Cover story is not an intent claim."}, +} + +func buildResourceHijackingLogicAppsOutput( + ctx context.Context, + provider providers.Provider, + now func() time.Time, + request Request, + 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) + + logicApps, err := logicAppsFuture.wait() + if err != nil { + return nil, err + } + evidence, err := evidenceFutures.wait() + if err != nil { + return nil, err + } + + targets := make([]models.ResourceHijackingLogicAppTarget, 0, len(logicApps.Workflows)) + for _, workflow := range logicApps.Workflows { + control, controlOK := resourceHijackingLogicAppControl(workflow.ID, evidence.principal.currentIdentityAssignments) + rank, reason := resourceHijackingLogicAppTakeoverRank(workflow, controlOK) + targets = append(targets, models.ResourceHijackingLogicAppTarget{ + ID: workflow.ID, + Name: workflow.Name, + ResourceGroup: workflow.ResourceGroup, + Location: workflow.Location, + TakeoverRank: rank, + TakeoverReason: reason, + CapabilitySteps: resourceHijackingLogicAppCapabilitySteps(controlOK), + CurrentIdentityContext: resourceHijackingRoleContext(evidence.principal.currentIdentity, control, controlOK, "Logic App workflow write control", "workflow write"), + CurrentState: resourceHijackingLogicAppState(workflow), + NotCollectedByDefault: resourceHijackingLogicAppNotCollectedByDefault(), + Summary: resourceHijackingLogicAppSummary(workflow, rank, controlOK), + RelatedIDs: mergeRelatedIDs(workflow.RelatedIDs), + }) + } + sort.SliceStable(targets, func(i, j int) bool { + if targets[i].TakeoverRank != targets[j].TakeoverRank { + return targets[i].TakeoverRank > targets[j].TakeoverRank + } + return targets[i].Name < targets[j].Name + }) + + 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", + ), + GroupedCommandName: "resourcehijacking", + Surface: contract.Name, + InputMode: "live", + CommandState: contract.Status, + Summary: contract.Summary, + BackingCommands: append([]string{}, contract.BackingCommands...), + Targets: targets, + Issues: issues, + }, nil +} + +func resourceHijackingLogicAppControl(resourceID string, assignments []models.RoleAssignment) (persistenceCurrentIdentityControl, bool) { + return resourceHijackingBestControl( + resourceID, + assignments, + []string{"Microsoft.Logic/workflows/write"}, + "Owner", + "Contributor", + "Logic App Contributor", + ) +} + +func resourceHijackingLogicAppCapabilitySteps(controlOK bool) []models.ResourceHijackingCapabilityStep { + return familyCapabilitySteps(resourceHijackingLogicAppSteps, controlOK) +} + +func resourceHijackingLogicAppState(workflow models.LogicAppWorkflowAsset) models.ResourceHijackingLogicAppState { + return familyLogicAppState(workflow, resourceHijackingLogicAppPosture(workflow)) +} + +func resourceHijackingLogicAppPosture(workflow models.LogicAppWorkflowAsset) string { + return familyLogicAppPosture(workflow, "Logic App workflow visible without stronger trigger, action, or identity posture") +} + +func resourceHijackingLogicAppTakeoverRank(workflow models.LogicAppWorkflowAsset, controlOK bool) (int, string) { + rank := 1 + reasons := []string{} + hasTrigger := workflow.ExternallyCallableRequestTrigger || len(workflow.TriggerTypes) > 0 || workflow.RecurrenceSummary != nil + hasDownstream := len(workflow.DownstreamActionKinds) > 0 || len(workflow.DownstreamResourceReferences) > 0 || len(workflow.ConnectorReferences) > 0 + hasIdentity := strings.TrimSpace(stringPtrValue(workflow.IdentityType)) != "" + switch { + case workflow.ExternallyCallableRequestTrigger && hasDownstream && hasIdentity: + rank = 5 + reasons = append(reasons, "external trigger, downstream action posture, and workflow identity are visible") + case hasTrigger && hasDownstream && hasIdentity: + rank = 4 + reasons = append(reasons, "trigger, downstream action posture, and workflow identity are visible") + case hasTrigger && hasDownstream: + rank = 3 + reasons = append(reasons, "trigger and downstream action posture are visible") + case hasTrigger || hasDownstream: + rank = 2 + reasons = append(reasons, "partial trigger or downstream action posture is visible") + } + if controlOK { + reasons = append(reasons, "current identity has visible Logic App workflow write control") + } + if len(reasons) == 0 { + reasons = append(reasons, "visible posture does not support a stronger dynamic takeover ranking") + } + return rank, strings.Join(reasons, "; ") +} + +func resourceHijackingLogicAppNotCollectedByDefault() []models.ResourceHijackingBoundaryNote { + return []models.ResourceHijackingBoundaryNote{ + {Name: "full workflow definition body", Classification: "collector issue", Reason: "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default."}, + {Name: "connector credential values", Classification: "recon safety", Reason: "Connector secrets and connection credential material are not safe default output."}, + {Name: "run history", Classification: "proof boundary", Reason: "Management-plane posture cannot prove the modified workflow ran or completed downstream actions."}, + {Name: "data handled by actions", Classification: "proof boundary", Reason: "The command does not inspect connector or action payload content."}, + {Name: "activity history", Classification: "API/noise", Reason: "Broad workflow change history is not needed for default posture and should be a narrow follow-up for timing or actor proof."}, + } +} + +func resourceHijackingLogicAppSummary(workflow models.LogicAppWorkflowAsset, rank int, controlOK bool) string { + parts := []string{fmt.Sprintf("workflow %q ranks %d/5 for Logic App resource-hijack posture", workflow.Name, rank)} + if len(workflow.TriggerTypes) > 0 { + parts = append(parts, fmt.Sprintf("%d trigger type(s)", len(workflow.TriggerTypes))) + } + if len(workflow.DownstreamActionKinds) > 0 { + parts = append(parts, fmt.Sprintf("%d downstream action kind(s)", len(workflow.DownstreamActionKinds))) + } + if controlOK { + parts = append(parts, "current identity can modify workflow posture from visible RBAC") + } else { + parts = append(parts, "current identity workflow write control is not proven") + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/contracts/commands.go b/internal/contracts/commands.go index 273d4ff..dfda1bc 100644 --- a/internal/contracts/commands.go +++ b/internal/contracts/commands.go @@ -116,6 +116,58 @@ var commandContracts = map[string]CommandContract{ "metadata", }, }, + "dcr": { + Name: "dcr", + Section: "resource", + Status: StatusImplemented, + Model: "DCROutput", + OperatorQuestion: "Which Data Collection Rules can reshape Azure Monitor collection, routing, destinations, associations, or transformations from the management plane?", + TopLevelFields: []string{ + "dcrs", + "findings", + "issues", + "metadata", + }, + }, + "diagnostic-settings": { + Name: "diagnostic-settings", + Section: "resource", + Status: StatusImplemented, + Model: "DiagnosticSettingsOutput", + OperatorQuestion: "Which visible resources can reshape Azure telemetry export through diagnostic settings, selected categories, and sink routing?", + TopLevelFields: []string{ + "sources", + "findings", + "issues", + "metadata", + }, + }, + "monitoring-sinks": { + Name: "monitoring-sinks", + Section: "resource", + Status: StatusImplemented, + Model: "MonitoringSinksOutput", + OperatorQuestion: "Which visible or declared monitoring destinations can DCRs and diagnostic settings route Azure telemetry toward?", + TopLevelFields: []string{ + "sinks", + "findings", + "issues", + "metadata", + }, + }, + "relay": { + Name: "relay", + Section: "network", + Status: StatusImplemented, + Model: "RelayOutput", + OperatorQuestion: "Which Azure Relay namespaces and Hybrid Connections expose the strongest cloud rendezvous and private-path masking posture?", + TopLevelFields: []string{ + "findings", + "issues", + "metadata", + "namespaces", + }, + }, "dns": { Name: "dns", Section: "network", @@ -272,6 +324,20 @@ var commandContracts = map[string]CommandContract{ "metadata", }, }, + "appinsights": { + Name: "appinsights", + Section: "resource", + Status: StatusImplemented, + Model: "AppInsightsOutput", + OperatorQuestion: "Which Application Insights components and instrumented app settings expose visible sampling, filtering, or telemetry-level posture clues?", + TopLevelFields: []string{ + "components", + "targets", + "findings", + "issues", + "metadata", + }, + }, "app-credentials": { Name: "app-credentials", Section: "identity", @@ -626,6 +692,48 @@ var commandContracts = map[string]CommandContract{ "issues", }, }, + "evasion": { + Name: "evasion", + Section: "orchestration", + Status: StatusImplemented, + Model: "EvasionOutput", + OperatorQuestion: "Which Azure-native evasion surface most quietly changes defender truth from current management-plane evidence?", + TopLevelFields: []string{ + "metadata", + "grouped_command_name", + "selected_surface", + "surfaces", + "issues", + }, + }, + "resourcehijacking": { + Name: "resourcehijacking", + Section: "orchestration", + Status: StatusImplemented, + Model: "ResourceHijackingOutput", + OperatorQuestion: "Which existing Azure resource can current access most directly commandeer, redirect, replace, or repurpose?", + TopLevelFields: []string{ + "metadata", + "grouped_command_name", + "selected_surface", + "surfaces", + "issues", + }, + }, + "pathmasking": { + Name: "pathmasking", + Section: "orchestration", + Status: StatusImplemented, + Model: "PathMaskingOutput", + OperatorQuestion: "Which Azure-native middle layer most obscures the real path between caller, cloud surface, and backend?", + TopLevelFields: []string{ + "metadata", + "grouped_command_name", + "selected_surface", + "surfaces", + "issues", + }, + }, } func placeholderCommand(name string, section string, model string) CommandContract { diff --git a/internal/contracts/evasion.go b/internal/contracts/evasion.go new file mode 100644 index 0000000..4f388f9 --- /dev/null +++ b/internal/contracts/evasion.go @@ -0,0 +1,55 @@ +package contracts + +import "sort" + +type SurfaceContract struct { + GroupCommand string + Name string + Status string + Summary string + OperatorQuestion string + BackingCommands []string +} + +type EvasionSurfaceContract = SurfaceContract + +var evasionSurfaceContracts = map[string]EvasionSurfaceContract{ + "dcr": { + GroupCommand: "evasion", + Name: "dcr", + Status: StatusImplemented, + Summary: "Review Data Collection Rules for collection, stream, destination, association, and transformation posture that can quietly reshape monitoring truth.", + OperatorQuestion: "How far can current access take me through DCR collection, routing, association, and transformation levers before the proof boundary moves into runtime logs or agent state?", + BackingCommands: []string{"dcr", "permissions", "rbac"}, + }, + "diagnostic-settings": { + GroupCommand: "evasion", + Name: "diagnostic-settings", + Status: StatusImplemented, + Summary: "Review diagnostic settings for source resources, exported categories, metrics, destinations, and visible telemetry-routing posture.", + OperatorQuestion: "How far can current access take me through diagnostic setting category and destination levers before the proof boundary moves into sink contents, history, or detector wiring?", + BackingCommands: []string{"diagnostic-settings", "permissions", "rbac"}, + }, + "appinsights": { + GroupCommand: "evasion", + Name: "appinsights", + Status: StatusImplemented, + Summary: "Review Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture clues.", + OperatorQuestion: "How far can current access take me through visible Application Insights instrumentation, sampling, filtering, and logging-level levers before the proof boundary moves into code, runtime, or telemetry content?", + BackingCommands: []string{"appinsights", "permissions", "rbac"}, + }, +} + +func EvasionSurface(name string) (EvasionSurfaceContract, bool) { + contract, ok := evasionSurfaceContracts[name] + return contract, ok +} + +func EvasionSurfaceNames() []string { + names := make([]string, 0, len(evasionSurfaceContracts)) + for name := range evasionSurfaceContracts { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/contracts/path_masking.go b/internal/contracts/path_masking.go new file mode 100644 index 0000000..0c0a174 --- /dev/null +++ b/internal/contracts/path_masking.go @@ -0,0 +1,46 @@ +package contracts + +import "sort" + +type PathMaskingSurfaceContract = SurfaceContract + +var pathMaskingSurfaceContracts = map[string]PathMaskingSurfaceContract{ + "api-mgmt": { + GroupCommand: "pathmasking", + Name: "api-mgmt", + Status: StatusImplemented, + Summary: "Review API Management services for gateway, backend, hostname, subscription, and named-value posture that can mask the true public-to-backend path.", + OperatorQuestion: "How far can current access take me through APIM gateway, route, transform, and backend indirection before the proof boundary moves into policy bodies, live traffic, or backend ownership?", + BackingCommands: []string{"api-mgmt", "permissions", "rbac"}, + }, + "logic-apps": { + GroupCommand: "pathmasking", + Name: "logic-apps", + Status: StatusImplemented, + Summary: "Review Logic Apps for request, schedule, connector, HTTP action, and identity posture that can relay activity through trusted integration workflows.", + OperatorQuestion: "Which visible workflows can current access reuse or modify as a trusted relay path before the proof boundary moves into run history, connector payloads, or credential material?", + BackingCommands: []string{"logic-apps", "permissions", "rbac"}, + }, + "relay": { + GroupCommand: "pathmasking", + Name: "relay", + Status: StatusImplemented, + Summary: "Review Azure Relay namespaces and Hybrid Connections for cloud rendezvous points that can blur the path to private listeners or internal services.", + OperatorQuestion: "Which Relay namespaces and Hybrid Connections give current access a visible private-path rendezvous before the proof boundary moves into listener runtime, backend process, or traffic contents?", + BackingCommands: []string{"relay", "permissions", "rbac"}, + }, +} + +func PathMaskingSurface(name string) (PathMaskingSurfaceContract, bool) { + contract, ok := pathMaskingSurfaceContracts[name] + return contract, ok +} + +func PathMaskingSurfaceNames() []string { + names := make([]string, 0, len(pathMaskingSurfaceContracts)) + for name := range pathMaskingSurfaceContracts { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/contracts/resource_hijacking.go b/internal/contracts/resource_hijacking.go new file mode 100644 index 0000000..9073b75 --- /dev/null +++ b/internal/contracts/resource_hijacking.go @@ -0,0 +1,46 @@ +package contracts + +import "sort" + +type ResourceHijackingSurfaceContract = SurfaceContract + +var resourceHijackingSurfaceContracts = map[string]ResourceHijackingSurfaceContract{ + "api-mgmt": { + GroupCommand: "resourcehijacking", + Name: "api-mgmt", + Status: StatusImplemented, + Summary: "Review API Management services for gateway, backend, subscription, named-value, and identity posture that can redirect a trusted API surface.", + OperatorQuestion: "How far can current access take me through APIM backend and routing-control levers before the proof boundary moves into policy bodies, traffic logs, or backend ownership?", + BackingCommands: []string{"api-mgmt", "permissions", "rbac"}, + }, + "automation": { + GroupCommand: "resourcehijacking", + Name: "automation", + Status: StatusImplemented, + Summary: "Review Azure Automation accounts for published runbook, schedule, webhook, hybrid worker, secure asset, and identity posture that can repurpose trusted operations automation.", + OperatorQuestion: "How far can current access take me through Automation runbook, trigger, execution context, and account-control levers before the proof boundary moves into script content, job output, or runtime host state?", + BackingCommands: []string{"automation", "permissions", "rbac"}, + }, + "logic-apps": { + GroupCommand: "resourcehijacking", + Name: "logic-apps", + Status: StatusImplemented, + Summary: "Review Logic Apps for workflow definition, trigger, downstream action, connector, and identity posture that can repurpose trusted automation.", + OperatorQuestion: "How far can current access take me through Logic App trigger, workflow, action, and identity levers before the proof boundary moves into run history, connector data, or secret material?", + BackingCommands: []string{"logic-apps", "permissions", "rbac"}, + }, +} + +func ResourceHijackingSurface(name string) (ResourceHijackingSurfaceContract, bool) { + contract, ok := resourceHijackingSurfaceContracts[name] + return contract, ok +} + +func ResourceHijackingSurfaceNames() []string { + names := make([]string, 0, len(resourceHijackingSurfaceContracts)) + for name := range resourceHijackingSurfaceContracts { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/models/api_mgmt.go b/internal/models/api_mgmt.go index 89aecfb..5dffdf3 100644 --- a/internal/models/api_mgmt.go +++ b/internal/models/api_mgmt.go @@ -29,6 +29,8 @@ type ApiMgmtServiceAsset struct { ActiveSubscriptionCount *int `json:"active_subscription_count"` BackendCount *int `json:"backend_count"` BackendHostnames []string `json:"backend_hostnames"` + PolicyCount *int `json:"policy_count"` + PolicyControlTypes []string `json:"policy_control_types"` NamedValueCount *int `json:"named_value_count"` NamedValueSecretCount *int `json:"named_value_secret_count"` NamedValueKeyVaultCount *int `json:"named_value_key_vault_count"` diff --git a/internal/models/appinsights.go b/internal/models/appinsights.go new file mode 100644 index 0000000..97dca7b --- /dev/null +++ b/internal/models/appinsights.go @@ -0,0 +1,37 @@ +package models + +type AppInsightsComponent struct { + ID string `json:"id"` + Name string `json:"name"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + Kind *string `json:"kind,omitempty"` + ApplicationType *string `json:"application_type,omitempty"` + WorkspaceResourceID *string `json:"workspace_resource_id,omitempty"` + IngestionMode *string `json:"ingestion_mode,omitempty"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type AppInsightsAppTarget struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + InstrumentationClues []string `json:"instrumentation_clues"` + SamplingClues []string `json:"sampling_clues"` + FilteringClues []string `json:"filtering_clues"` + LoggingLevelClues []string `json:"logging_level_clues"` + VisibleTelemetryTypes []string `json:"visible_telemetry_types"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type AppInsightsOutput struct { + Components []AppInsightsComponent `json:"components"` + Targets []AppInsightsAppTarget `json:"targets"` + Findings []Finding `json:"findings"` + Issues []Issue `json:"issues"` + Metadata RuntimeCommandMetadata `json:"metadata"` +} diff --git a/internal/models/automation.go b/internal/models/automation.go index 674327e..6071ab6 100644 --- a/internal/models/automation.go +++ b/internal/models/automation.go @@ -14,6 +14,9 @@ type AutomationAccountAsset struct { RunbookCount *int `json:"runbook_count"` PublishedRunbookCount *int `json:"published_runbook_count"` PublishedRunbookNames []string `json:"published_runbook_names"` + RunbookTypes []string `json:"runbook_types"` + RunbookCommandClues []string `json:"runbook_command_clues"` + RunbookResourceClues []string `json:"runbook_resource_clues"` ScheduleCount *int `json:"schedule_count"` ScheduleDefinitions []string `json:"schedule_definitions"` JobScheduleCount *int `json:"job_schedule_count"` diff --git a/internal/models/dcr.go b/internal/models/dcr.go new file mode 100644 index 0000000..e508402 --- /dev/null +++ b/internal/models/dcr.go @@ -0,0 +1,67 @@ +package models + +type DCRDataSource struct { + Name string `json:"name"` + Type string `json:"type"` + Streams []string `json:"streams"` + TransformKqlPresent bool `json:"transform_kql_present"` + TransformKqlFingerprint *string `json:"transform_kql_fingerprint,omitempty"` + TransformKqlLength *int `json:"transform_kql_length,omitempty"` +} + +type DCRDataFlow struct { + Streams []string `json:"streams"` + Destinations []string `json:"destinations"` + OutputStream *string `json:"output_stream,omitempty"` + BuiltInTransform *string `json:"built_in_transform,omitempty"` + TransformKqlPresent bool `json:"transform_kql_present"` + TransformKqlFingerprint *string `json:"transform_kql_fingerprint,omitempty"` + TransformKqlLength *int `json:"transform_kql_length,omitempty"` +} + +type DCRDestination struct { + Name string `json:"name"` + Type string `json:"type"` + ResourceID *string `json:"resource_id,omitempty"` + Detail *string `json:"detail,omitempty"` +} + +type DCRAssociation struct { + ID string `json:"id"` + Name string `json:"name"` + TargetID string `json:"target_id"` + DataCollectionRuleID *string `json:"data_collection_rule_id,omitempty"` + DataCollectionEndpointID *string `json:"data_collection_endpoint_id,omitempty"` + Description *string `json:"description,omitempty"` +} + +type DCRAsset struct { + ID string `json:"id"` + Name string `json:"name"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + Kind *string `json:"kind,omitempty"` + Description *string `json:"description,omitempty"` + DataCollectionEndpointID *string `json:"data_collection_endpoint_id,omitempty"` + DataSources []DCRDataSource `json:"data_sources"` + DataFlows []DCRDataFlow `json:"data_flows"` + Destinations []DCRDestination `json:"destinations"` + Associations []DCRAssociation `json:"associations"` + DataSourceTypes []string `json:"data_source_types"` + Streams []string `json:"streams"` + HighSignalStreams []string `json:"high_signal_streams"` + DestinationTypes []string `json:"destination_types"` + TransformationCount int `json:"transformation_count"` + AssociationCount int `json:"association_count"` + RelatedIDs []string `json:"related_ids"` + Summary string `json:"summary"` +} + +type DCRMetadata = RuntimeCommandMetadata + +type DCROutput struct { + DCRs []DCRAsset `json:"dcrs"` + Findings []Finding `json:"findings"` + Issues []Issue `json:"issues"` + Metadata DCRMetadata `json:"metadata"` +} diff --git a/internal/models/diagnostic_settings.go b/internal/models/diagnostic_settings.go new file mode 100644 index 0000000..0b6fa68 --- /dev/null +++ b/internal/models/diagnostic_settings.go @@ -0,0 +1,62 @@ +package models + +type DiagnosticSettingsDestination struct { + Type string `json:"type"` + ResourceID *string `json:"resource_id,omitempty"` + Detail *string `json:"detail,omitempty"` +} + +type DiagnosticSettingsCategory struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` +} + +type DiagnosticSettingAsset struct { + ID string `json:"id"` + Name string `json:"name"` + SourceResourceID string `json:"source_resource_id"` + Destinations []DiagnosticSettingsDestination `json:"destinations"` + Logs []DiagnosticSettingsCategory `json:"logs"` + Metrics []DiagnosticSettingsCategory `json:"metrics"` + EnabledCategories []string `json:"enabled_categories"` + DisabledCategories []string `json:"disabled_categories"` + CategoryGroups []string `json:"category_groups"` + HighSignalCategories []string `json:"high_signal_categories"` + DestinationTypes []string `json:"destination_types"` + RelatedIDs []string `json:"related_ids"` + Summary string `json:"summary"` +} + +type DiagnosticSettingsSource struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + DiagnosticSettings []DiagnosticSettingAsset `json:"diagnostic_settings"` + DiagnosticSettingCount int `json:"diagnostic_setting_count"` + EnabledCategories []string `json:"enabled_categories"` + DisabledCategories []string `json:"disabled_categories"` + SupportedCategories []string `json:"supported_categories"` + NotExportedSupported []string `json:"not_exported_supported_categories"` + SupportedCategoryCatalog bool `json:"supported_category_catalog"` + CategoryGroups []string `json:"category_groups"` + HighSignalCategories []string `json:"high_signal_categories"` + DestinationTypes []string `json:"destination_types"` + HasDiagnosticSettings bool `json:"has_diagnostic_settings"` + HasPartialLogPosture bool `json:"has_partial_log_posture"` + HasHighSignalLogPosture bool `json:"has_high_signal_log_posture"` + HasNonWorkspaceDestination bool `json:"has_non_workspace_destination"` + RelatedIDs []string `json:"related_ids"` + Summary string `json:"summary"` +} + +type DiagnosticSettingsMetadata = RuntimeCommandMetadata + +type DiagnosticSettingsOutput struct { + Sources []DiagnosticSettingsSource `json:"sources"` + Findings []Finding `json:"findings"` + Issues []Issue `json:"issues"` + Metadata DiagnosticSettingsMetadata `json:"metadata"` +} diff --git a/internal/models/evasion.go b/internal/models/evasion.go new file mode 100644 index 0000000..56594ba --- /dev/null +++ b/internal/models/evasion.go @@ -0,0 +1,142 @@ +package models + +type EvasionSurfaceDescriptor = FamilySurfaceDescriptor + +type EvasionOverviewOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + CommandState string `json:"command_state"` + CurrentBehavior string `json:"current_behavior"` + PlannedInputModes []string `json:"planned_input_modes"` + PreferredArtifactOrder []string `json:"preferred_artifact_order"` + SelectedSurface *string `json:"selected_surface"` + Surfaces []EvasionSurfaceDescriptor `json:"surfaces"` + Issues []Issue `json:"issues"` +} + +type EvasionCapabilityStep = FamilyCapabilityStep + +type EvasionRoleContext = FamilyRoleContext + +type EvasionBoundaryNote = FamilyBoundaryNote + +type EvasionDCRState struct { + DataSourceTypes []string `json:"data_source_types"` + Streams []string `json:"streams"` + HighSignalStreams []string `json:"high_signal_streams"` + DestinationTypes []string `json:"destination_types"` + AssociationTargets []string `json:"association_targets"` + TransformationCount int `json:"transformation_count"` + AssociationCount int `json:"association_count"` + TransformationPosture string `json:"transformation_posture"` + DestinationPosture string `json:"destination_posture"` +} + +type EvasionDiagnosticSettingsState struct { + SourceType string `json:"source_type"` + DiagnosticSettingCount int `json:"diagnostic_setting_count"` + EnabledCategories []string `json:"enabled_categories"` + NotExportedCategories []string `json:"not_exported_categories"` + SupportedCategories []string `json:"supported_categories"` + SupportedCategoryProof bool `json:"supported_category_proof"` + CategoryGroups []string `json:"category_groups"` + HighSignalCategories []string `json:"high_signal_categories"` + DestinationTypes []string `json:"destination_types"` + HasNonWorkspaceSink bool `json:"has_non_workspace_sink"` + ExportPosture string `json:"export_posture"` + DestinationPosture string `json:"destination_posture"` +} + +type EvasionAppInsightsState struct { + Kind string `json:"kind"` + InstrumentationClues []string `json:"instrumentation_clues"` + SamplingClues []string `json:"sampling_clues"` + FilteringClues []string `json:"filtering_clues"` + LoggingLevelClues []string `json:"logging_level_clues"` + VisibleTelemetryTypes []string `json:"visible_telemetry_types"` + Posture string `json:"posture"` +} + +type EvasionDCR struct { + ID string `json:"id"` + Name string `json:"dcr"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + DisruptionRank int `json:"disruption_rank"` + DisruptionReason string `json:"disruption_reason"` + CapabilitySteps []EvasionCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *EvasionRoleContext `json:"current_identity_context,omitempty"` + CurrentState EvasionDCRState `json:"current_state"` + NotCollectedByDefault []EvasionBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type EvasionDiagnosticSettingsSource struct { + ID string `json:"id"` + Name string `json:"source"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + DisruptionRank int `json:"disruption_rank"` + DisruptionReason string `json:"disruption_reason"` + CapabilitySteps []EvasionCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *EvasionRoleContext `json:"current_identity_context,omitempty"` + CurrentState EvasionDiagnosticSettingsState `json:"current_state"` + NotCollectedByDefault []EvasionBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type EvasionAppInsightsTarget struct { + ID string `json:"id"` + Name string `json:"target"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + DisruptionRank int `json:"disruption_rank"` + DisruptionReason string `json:"disruption_reason"` + CapabilitySteps []EvasionCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *EvasionRoleContext `json:"current_identity_context,omitempty"` + CurrentState EvasionAppInsightsState `json:"current_state"` + NotCollectedByDefault []EvasionBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type EvasionDCROutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + MonitoringSinks []MonitoringSinkAsset `json:"monitoring_sinks"` + DCRs []EvasionDCR `json:"dcrs"` + Issues []Issue `json:"issues"` +} + +type EvasionDiagnosticSettingsOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + MonitoringSinks []MonitoringSinkAsset `json:"monitoring_sinks"` + Sources []EvasionDiagnosticSettingsSource `json:"sources"` + Issues []Issue `json:"issues"` +} + +type EvasionAppInsightsOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []EvasionAppInsightsTarget `json:"targets"` + Components []AppInsightsComponent `json:"components"` + Issues []Issue `json:"issues"` +} diff --git a/internal/models/family.go b/internal/models/family.go new file mode 100644 index 0000000..1a8938f --- /dev/null +++ b/internal/models/family.go @@ -0,0 +1,49 @@ +package models + +type FamilySurfaceDescriptor struct { + Surface string `json:"surface"` + State string `json:"state"` + Summary string `json:"summary"` + OperatorQuestion string `json:"operator_question"` + BackingCommands []string `json:"backing_commands"` +} + +type FamilyCapabilityStep struct { + Action string `json:"action"` + APISurface string `json:"api_surface"` + Status string `json:"status"` + CanAct bool `json:"-"` + DownstreamEffect string `json:"downstream_effect"` + Boundary string `json:"boundary"` +} + +type FamilyRoleContext struct { + Name string `json:"name"` + Kind string `json:"kind"` + PrincipalID *string `json:"principal_id,omitempty"` + RoleNames []string `json:"role_names"` + ScopeIDs []string `json:"scope_ids"` + ControlLabel string `json:"control_label,omitempty"` + Summary string `json:"summary"` +} + +type FamilyBoundaryNote struct { + Name string `json:"name"` + Classification string `json:"classification"` + Reason string `json:"reason"` +} + +type FamilyLogicAppState struct { + Platform *string `json:"platform,omitempty"` + State *string `json:"state,omitempty"` + TriggerTypes []string `json:"trigger_types"` + ExternallyCallableRequestTrigger bool `json:"externally_callable_request_trigger"` + RecurrenceSummary *string `json:"recurrence_summary,omitempty"` + DownstreamActionKinds []string `json:"downstream_action_kinds"` + ConnectorReferences []string `json:"connector_references"` + ParameterNames []string `json:"parameter_names"` + DownstreamResourceReferences []string `json:"downstream_resource_references"` + IdentityType *string `json:"identity_type,omitempty"` + IdentityIDs []string `json:"identity_ids"` + Posture string `json:"posture"` +} diff --git a/internal/models/logic_apps.go b/internal/models/logic_apps.go index 09a9ac4..9a1541a 100644 --- a/internal/models/logic_apps.go +++ b/internal/models/logic_apps.go @@ -16,10 +16,16 @@ type LogicAppWorkflowAsset struct { PrincipalID *string `json:"principal_id,omitempty"` ClientID *string `json:"client_id,omitempty"` IdentityIDs []string `json:"identity_ids"` + TriggerCount int `json:"trigger_count"` + ActionCount int `json:"action_count"` + BranchCount int `json:"branch_count"` TriggerTypes []string `json:"trigger_types"` ExternallyCallableRequestTrigger bool `json:"externally_callable_request_trigger"` RecurrenceSummary *string `json:"recurrence_summary,omitempty"` DownstreamActionKinds []string `json:"downstream_action_kinds"` + ConnectorReferences []string `json:"connector_references"` + ParameterNames []string `json:"parameter_names"` + DownstreamResourceReferences []string `json:"downstream_resource_references"` Summary string `json:"summary"` RelatedIDs []string `json:"related_ids"` } diff --git a/internal/models/monitoring_sinks.go b/internal/models/monitoring_sinks.go new file mode 100644 index 0000000..f5402a3 --- /dev/null +++ b/internal/models/monitoring_sinks.go @@ -0,0 +1,32 @@ +package models + +type MonitoringSinkReference struct { + SourceCommand string `json:"source_command"` + SourceResourceID string `json:"source_resource_id"` + SourceName string `json:"source_name"` + ReferenceName *string `json:"reference_name,omitempty"` + ReferenceType string `json:"reference_type"` + DestinationDetail *string `json:"destination_detail,omitempty"` +} + +type MonitoringSinkAsset struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + ResourceType string `json:"resource_type"` + ResourceGroup string `json:"resource_group"` + Location string `json:"location"` + VisibilitySource string `json:"visibility_source"` + SentinelEnabled *bool `json:"sentinel_enabled,omitempty"` + References []MonitoringSinkReference `json:"references"` + ReferenceCount int `json:"reference_count"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type MonitoringSinksOutput struct { + Sinks []MonitoringSinkAsset `json:"sinks"` + Findings []Finding `json:"findings"` + Issues []Issue `json:"issues"` + Metadata RuntimeCommandMetadata `json:"metadata"` +} diff --git a/internal/models/path_masking.go b/internal/models/path_masking.go new file mode 100644 index 0000000..621364b --- /dev/null +++ b/internal/models/path_masking.go @@ -0,0 +1,128 @@ +package models + +type PathMaskingSurfaceDescriptor = FamilySurfaceDescriptor + +type PathMaskingOverviewOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + CommandState string `json:"command_state"` + CurrentBehavior string `json:"current_behavior"` + PlannedInputModes []string `json:"planned_input_modes"` + PreferredArtifactOrder []string `json:"preferred_artifact_order"` + SelectedSurface *string `json:"selected_surface"` + Surfaces []PathMaskingSurfaceDescriptor `json:"surfaces"` + Issues []Issue `json:"issues"` +} + +type PathMaskingCapabilityStep = FamilyCapabilityStep + +type PathMaskingRoleContext = FamilyRoleContext + +type PathMaskingBoundaryNote = FamilyBoundaryNote + +type PathMaskingAPIMState struct { + GatewayHostnames []string `json:"gateway_hostnames"` + BackendHostnames []string `json:"backend_hostnames"` + APICount *int `json:"api_count,omitempty"` + SubscriptionCount *int `json:"subscription_count,omitempty"` + PolicyCount *int `json:"policy_count,omitempty"` + PolicyControlTypes []string `json:"policy_control_types"` + NamedValueSecretCount *int `json:"named_value_secret_count,omitempty"` + NamedValueKeyVaultCount *int `json:"named_value_key_vault_count,omitempty"` + PublicNetworkAccess *string `json:"public_network_access,omitempty"` + VirtualNetworkType *string `json:"virtual_network_type,omitempty"` + Posture string `json:"posture"` +} + +type PathMaskingRelayState struct { + ServiceBusEndpoint *string `json:"service_bus_endpoint,omitempty"` + HybridConnectionCount *int `json:"hybrid_connection_count,omitempty"` + AuthorizationRuleCount *int `json:"authorization_rule_count,omitempty"` + HybridConnectionNames []string `json:"hybrid_connection_names"` + ListenerSummary string `json:"listener_summary"` + AppServiceAttachments []string `json:"app_service_attachments"` + Posture string `json:"posture"` +} + +type PathMaskingLogicAppState = FamilyLogicAppState + +type PathMaskingAPIMTarget struct { + ID string `json:"id"` + Name string `json:"api_management_service"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + MaskingRank int `json:"masking_rank"` + MaskingReason string `json:"masking_reason"` + CapabilitySteps []PathMaskingCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *PathMaskingRoleContext `json:"current_identity_context,omitempty"` + CurrentState PathMaskingAPIMState `json:"current_state"` + NotCollectedByDefault []PathMaskingBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type PathMaskingLogicAppTarget struct { + ID string `json:"id"` + Name string `json:"logic_app"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + MaskingRank int `json:"masking_rank"` + MaskingReason string `json:"masking_reason"` + CapabilitySteps []PathMaskingCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *PathMaskingRoleContext `json:"current_identity_context,omitempty"` + CurrentState PathMaskingLogicAppState `json:"current_state"` + NotCollectedByDefault []PathMaskingBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type PathMaskingRelayTarget struct { + ID string `json:"id"` + Name string `json:"relay_namespace"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + MaskingRank int `json:"masking_rank"` + MaskingReason string `json:"masking_reason"` + CapabilitySteps []PathMaskingCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *PathMaskingRoleContext `json:"current_identity_context,omitempty"` + CurrentState PathMaskingRelayState `json:"current_state"` + NotCollectedByDefault []PathMaskingBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type PathMaskingAPIMOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []PathMaskingAPIMTarget `json:"targets"` + Issues []Issue `json:"issues"` +} + +type PathMaskingLogicAppsOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []PathMaskingLogicAppTarget `json:"targets"` + Issues []Issue `json:"issues"` +} + +type PathMaskingRelayOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []PathMaskingRelayTarget `json:"targets"` + Issues []Issue `json:"issues"` +} diff --git a/internal/models/relay.go b/internal/models/relay.go new file mode 100644 index 0000000..19d44ef --- /dev/null +++ b/internal/models/relay.go @@ -0,0 +1,37 @@ +package models + +type RelayHybridConnectionAsset struct { + ID string `json:"id"` + Name string `json:"hybrid_connection"` + RequiresClientAuthorization *bool `json:"requires_client_authorization,omitempty"` + UserMetadata *string `json:"user_metadata,omitempty"` + ListenerCount *int `json:"listener_count,omitempty"` + AppServiceAttachments []string `json:"app_service_attachments"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type RelayNamespaceAsset struct { + ID string `json:"id"` + Name string `json:"namespace"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + SKUName *string `json:"sku_name,omitempty"` + ProvisioningState *string `json:"provisioning_state,omitempty"` + ServiceBusEndpoint *string `json:"service_bus_endpoint,omitempty"` + MetricID *string `json:"metric_id,omitempty"` + HybridConnectionCount *int `json:"hybrid_connection_count,omitempty"` + AuthorizationRuleCount *int `json:"authorization_rule_count,omitempty"` + HybridConnections []RelayHybridConnectionAsset `json:"hybrid_connections"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type RelayMetadata = RuntimeCommandMetadata + +type RelayOutput struct { + Findings []Finding `json:"findings"` + Issues []Issue `json:"issues"` + Metadata RelayMetadata `json:"metadata"` + Namespaces []RelayNamespaceAsset `json:"namespaces"` +} diff --git a/internal/models/resource_hijacking.go b/internal/models/resource_hijacking.go new file mode 100644 index 0000000..bc7d61e --- /dev/null +++ b/internal/models/resource_hijacking.go @@ -0,0 +1,142 @@ +package models + +type ResourceHijackingSurfaceDescriptor = FamilySurfaceDescriptor + +type ResourceHijackingOverviewOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + CommandState string `json:"command_state"` + CurrentBehavior string `json:"current_behavior"` + PlannedInputModes []string `json:"planned_input_modes"` + PreferredArtifactOrder []string `json:"preferred_artifact_order"` + SelectedSurface *string `json:"selected_surface"` + Surfaces []ResourceHijackingSurfaceDescriptor `json:"surfaces"` + Issues []Issue `json:"issues"` +} + +type ResourceHijackingCapabilityStep = FamilyCapabilityStep + +type ResourceHijackingRoleContext = FamilyRoleContext + +type ResourceHijackingBoundaryNote = FamilyBoundaryNote + +type ResourceHijackingAPIMState struct { + State *string `json:"state,omitempty"` + PublicNetworkAccess *string `json:"public_network_access,omitempty"` + VirtualNetworkType *string `json:"virtual_network_type,omitempty"` + GatewayHostnames []string `json:"gateway_hostnames"` + BackendHostnames []string `json:"backend_hostnames"` + APICount *int `json:"api_count,omitempty"` + SubscriptionCount *int `json:"subscription_count,omitempty"` + ActiveSubscriptionCount *int `json:"active_subscription_count,omitempty"` + BackendCount *int `json:"backend_count,omitempty"` + PolicyCount *int `json:"policy_count,omitempty"` + PolicyControlTypes []string `json:"policy_control_types"` + NamedValueSecretCount *int `json:"named_value_secret_count,omitempty"` + NamedValueKeyVaultCount *int `json:"named_value_key_vault_count,omitempty"` + WorkloadIdentityType *string `json:"workload_identity_type,omitempty"` + Posture string `json:"posture"` +} + +type ResourceHijackingAPIMTarget struct { + ID string `json:"id"` + Name string `json:"api_management_service"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + TakeoverRank int `json:"takeover_rank"` + TakeoverReason string `json:"takeover_reason"` + CapabilitySteps []ResourceHijackingCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *ResourceHijackingRoleContext `json:"current_identity_context,omitempty"` + CurrentState ResourceHijackingAPIMState `json:"current_state"` + NotCollectedByDefault []ResourceHijackingBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type ResourceHijackingLogicAppState = FamilyLogicAppState + +type ResourceHijackingLogicAppTarget struct { + ID string `json:"id"` + Name string `json:"logic_app"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + TakeoverRank int `json:"takeover_rank"` + TakeoverReason string `json:"takeover_reason"` + CapabilitySteps []ResourceHijackingCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *ResourceHijackingRoleContext `json:"current_identity_context,omitempty"` + CurrentState ResourceHijackingLogicAppState `json:"current_state"` + NotCollectedByDefault []ResourceHijackingBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type ResourceHijackingAutomationState struct { + State *string `json:"state,omitempty"` + IdentityType *string `json:"identity_type,omitempty"` + PublishedRunbookCount *int `json:"published_runbook_count,omitempty"` + PublishedRunbookNames []string `json:"published_runbook_names"` + RunbookTypes []string `json:"runbook_types"` + RunbookCommandClues []string `json:"runbook_command_clues"` + RunbookResourceClues []string `json:"runbook_resource_clues"` + ScheduleCount *int `json:"schedule_count,omitempty"` + JobScheduleCount *int `json:"job_schedule_count,omitempty"` + WebhookCount *int `json:"webhook_count,omitempty"` + HybridWorkerGroupCount *int `json:"hybrid_worker_group_count,omitempty"` + PrimaryStartMode *string `json:"primary_start_mode,omitempty"` + PrimaryRunbookName *string `json:"primary_runbook_name,omitempty"` + ScheduleRunbookNames []string `json:"schedule_runbook_names"` + WebhookRunbookNames []string `json:"webhook_runbook_names"` + ConsequenceTypes []string `json:"consequence_types"` + Posture string `json:"posture"` +} + +type ResourceHijackingAutomationTarget struct { + ID string `json:"id"` + Name string `json:"automation_account"` + ResourceGroup string `json:"resource_group"` + Location *string `json:"location,omitempty"` + TakeoverRank int `json:"takeover_rank"` + TakeoverReason string `json:"takeover_reason"` + CapabilitySteps []ResourceHijackingCapabilityStep `json:"capability_steps"` + CurrentIdentityContext *ResourceHijackingRoleContext `json:"current_identity_context,omitempty"` + CurrentState ResourceHijackingAutomationState `json:"current_state"` + NotCollectedByDefault []ResourceHijackingBoundaryNote `json:"not_collected_by_default"` + Summary string `json:"summary"` + RelatedIDs []string `json:"related_ids"` +} + +type ResourceHijackingAPIMOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []ResourceHijackingAPIMTarget `json:"targets"` + Issues []Issue `json:"issues"` +} + +type ResourceHijackingLogicAppsOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []ResourceHijackingLogicAppTarget `json:"targets"` + Issues []Issue `json:"issues"` +} + +type ResourceHijackingAutomationOutput struct { + Metadata ScopedCommandMetadata `json:"metadata"` + GroupedCommandName string `json:"grouped_command_name"` + Surface string `json:"surface"` + InputMode string `json:"input_mode"` + CommandState string `json:"command_state"` + Summary string `json:"summary"` + BackingCommands []string `json:"backing_commands"` + Targets []ResourceHijackingAutomationTarget `json:"targets"` + Issues []Issue `json:"issues"` +} diff --git a/internal/providers/azure_api_mgmt.go b/internal/providers/azure_api_mgmt.go index 9d0200e..6b47cdd 100644 --- a/internal/providers/azure_api_mgmt.go +++ b/internal/providers/azure_api_mgmt.go @@ -11,6 +11,8 @@ import ( "harrierops-azure/internal/models" ) +const armApiManagementAPIVersion = "2024-05-01" + func (provider AzureProvider) ApiMgmt(ctx context.Context, tenant string, subscription string) (ApiMgmtFacts, error) { session, err := provider.session(ctx, tenant, subscription) if err != nil { @@ -64,6 +66,7 @@ func (provider AzureProvider) ApiMgmt(ctx context.Context, tenant string, subscr var subscriptions []map[string]any var backends []map[string]any var namedValues []map[string]any + var policies []map[string]any if resourceGroup != "" && serviceName != "" { scopePrefix := "api_mgmt[" + resourceGroup + "/" + serviceName + "]." @@ -71,9 +74,10 @@ func (provider AzureProvider) ApiMgmt(ctx context.Context, tenant string, subscr subscriptions, issues = apiMgmtSubscriptionList(ctx, subscriptionClient, resourceGroup, serviceName, scopePrefix+"subscriptions", issues) backends, issues = apiMgmtBackendList(ctx, backendClient, resourceGroup, serviceName, scopePrefix+"backends", issues) namedValues, issues = apiMgmtNamedValueList(ctx, namedValueClient, resourceGroup, serviceName, scopePrefix+"named_values", issues) + policies, issues = apiMgmtPolicyList(ctx, session, serviceID, apis, scopePrefix+"policies", issues) } - apiMgmtServices = append(apiMgmtServices, apiMgmtServiceSummary(hydrated, apis, subscriptions, backends, namedValues)) + apiMgmtServices = append(apiMgmtServices, apiMgmtServiceSummary(hydrated, apis, subscriptions, backends, namedValues, policies)) } } @@ -169,12 +173,43 @@ func apiMgmtNamedValueList( return rows, issues } +func apiMgmtPolicyList( + ctx context.Context, + session azureSession, + serviceID string, + apis []map[string]any, + scope string, + issues []models.Issue, +) ([]map[string]any, []models.Issue) { + policies := []map[string]any{} + servicePolicies, err := armListObjects(ctx, session.credential, strings.TrimRight(serviceID, "/")+"/policies", armApiManagementAPIVersion) + if err != nil { + issues = append(issues, issueFromError(scope+".service", err)) + } else { + policies = append(policies, servicePolicies...) + } + for _, api := range apis { + apiID := mapStringValue(api, "id") + if apiID == "" { + continue + } + apiPolicies, err := armListObjects(ctx, session.credential, strings.TrimRight(apiID, "/")+"/policies", armApiManagementAPIVersion) + if err != nil { + issues = append(issues, issueFromError(scope+"["+firstNonEmpty(mapStringValue(api, "name"), resourceNameFromID(apiID), "api")+"].api", err)) + continue + } + policies = append(policies, apiPolicies...) + } + return policies, issues +} + func apiMgmtServiceSummary( service map[string]any, apis []map[string]any, subscriptions []map[string]any, backends []map[string]any, namedValues []map[string]any, + policies []map[string]any, ) models.ApiMgmtServiceAsset { properties := mapValue(service, "properties") identity := mapValue(service, "identity") @@ -215,6 +250,8 @@ func apiMgmtServiceSummary( ActiveSubscriptionCount: apiMgmtActiveSubscriptionCount(subscriptions), BackendCount: apiMgmtCountPtr(backends), BackendHostnames: apiMgmtBackendHostnames(backends), + PolicyCount: apiMgmtCountPtr(policies), + PolicyControlTypes: apiMgmtPolicyControlTypes(policies), NamedValueCount: apiMgmtCountPtr(namedValues), NamedValueSecretCount: apiMgmtNamedValueSecretCount(namedValues), NamedValueKeyVaultCount: apiMgmtNamedValueKeyVaultCount(namedValues), @@ -233,6 +270,8 @@ func apiMgmtServiceSummary( apiMgmtActiveSubscriptionCount(subscriptions), apiMgmtCountPtr(backends), apiMgmtBackendHostnames(backends), + apiMgmtCountPtr(policies), + apiMgmtPolicyControlTypes(policies), apiMgmtCountPtr(namedValues), apiMgmtNamedValueSecretCount(namedValues), apiMgmtNamedValueKeyVaultCount(namedValues), @@ -357,6 +396,36 @@ func apiMgmtBackendHostnames(backends []map[string]any) []string { return dedupeStrings(values) } +func apiMgmtPolicyControlTypes(policies []map[string]any) []string { + if policies == nil { + return []string{} + } + values := []string{} + for _, policy := range policies { + text := strings.ToLower(firstNonEmpty( + mapStringValue(policy, "value"), + mapStringValue(mapValue(policy, "properties"), "value", "policyContent", "policy_content"), + )) + switch { + case strings.Contains(text, " 0 { depthParts = append(depthParts, "backend hosts "+strings.Join(backendHostnames, ", ")) } + if len(policyControlTypes) > 0 { + depthParts = append(depthParts, "policy controls "+strings.Join(policyControlTypes, ", ")) + } depthPhrase := "" if len(depthParts) > 0 { diff --git a/internal/providers/azure_appinsights.go b/internal/providers/azure_appinsights.go new file mode 100644 index 0000000..632467a --- /dev/null +++ b/internal/providers/azure_appinsights.go @@ -0,0 +1,231 @@ +package providers + +import ( + "context" + "fmt" + "sort" + "strings" + + "harrierops-azure/internal/models" +) + +func (provider AzureProvider) AppInsights(ctx context.Context, tenant string, subscription string) (AppInsightsFacts, error) { + session, err := provider.session(ctx, tenant, subscription) + if err != nil { + return AppInsightsFacts{}, err + } + + resources, err := armListObjects(ctx, session.credential, "/subscriptions/"+session.subscription.ID+"/resources", armResourcesAPIVersion) + if err != nil { + return AppInsightsFacts{}, err + } + components := []models.AppInsightsComponent{} + for _, resource := range resources { + if !strings.EqualFold(mapStringValue(resource, "type"), "Microsoft.Insights/components") { + continue + } + components = append(components, appInsightsComponentFromResource(resource)) + } + + webAppsState, err := provider.webAppsState(session) + if err != nil { + return AppInsightsFacts{}, fmt.Errorf("build web apps client: %w", err) + } + targets := []models.AppInsightsAppTarget{} + issues := []models.Issue{} + apps, listErr := webAppsState.list(ctx) + if listErr != nil { + issues = append(issues, issueFromError("appinsights.web_apps", listErr)) + } + for _, app := range apps { + if app.assetKind == "" || app.resourceGroup == "" || app.name == "" { + continue + } + settingsMap, err := webAppsState.settingsMap(ctx, app) + if err != nil { + issues = append(issues, issueFromError("appinsights["+app.resourceGroup+"/"+app.name+"].app_settings", err)) + continue + } + target := appInsightsTargetFromSettings(app.appMap, app.assetKind, mapValue(settingsMap, "properties")) + if len(target.InstrumentationClues) == 0 && len(target.SamplingClues) == 0 && len(target.FilteringClues) == 0 && len(target.LoggingLevelClues) == 0 { + continue + } + targets = append(targets, target) + } + + sort.SliceStable(components, func(i, j int) bool { + return components[i].Name < components[j].Name + }) + sort.SliceStable(targets, func(i, j int) bool { + if appInsightsTargetRank(targets[i]) != appInsightsTargetRank(targets[j]) { + return appInsightsTargetRank(targets[i]) > appInsightsTargetRank(targets[j]) + } + return targets[i].Name < targets[j].Name + }) + + return AppInsightsFacts{ + TenantID: session.tenantID, + SubscriptionID: session.subscription.ID, + Components: components, + Targets: targets, + Issues: issues, + }, nil +} + +func appInsightsComponentFromResource(resource map[string]any) models.AppInsightsComponent { + id := mapStringValue(resource, "id") + properties := mapValue(resource, "properties") + component := models.AppInsightsComponent{ + ID: id, + Name: firstNonEmpty(mapStringValue(resource, "name"), resourceNameFromID(id), "unknown"), + ResourceGroup: resourceGroupFromID(id), + Location: mapStringValue(resource, "location"), + Kind: stringPtr(mapStringValue(resource, "kind")), + ApplicationType: stringPtr(mapStringValue(properties, "Application_Type", "applicationType", "application_type")), + WorkspaceResourceID: stringPtr(mapStringValue(properties, "WorkspaceResourceId", "workspaceResourceId", "workspace_resource_id")), + IngestionMode: stringPtr(mapStringValue(properties, "IngestionMode", "ingestionMode", "ingestion_mode")), + RelatedIDs: []string{id}, + } + component.Summary = fmt.Sprintf("Application Insights component %q is visible in %s.", component.Name, firstNonEmpty(component.Location, "unknown location")) + return component +} + +func appInsightsTargetFromSettings(app map[string]any, assetKind string, settings map[string]any) models.AppInsightsAppTarget { + appID := mapStringValue(app, "id") + target := models.AppInsightsAppTarget{ + ID: appID, + Name: firstNonEmpty(mapStringValue(app, "name"), resourceNameFromID(appID), "unknown"), + Kind: assetKind, + ResourceGroup: resourceGroupFromID(appID), + Location: mapStringValue(app, "location"), + RelatedIDs: []string{appID}, + } + for settingName, settingValue := range settings { + class := appInsightsSettingClass(settingName) + clue := appInsightsSettingClue(settingName, settingValue, class) + switch class { + case "instrumentation": + target.InstrumentationClues = append(target.InstrumentationClues, clue) + case "sampling": + target.SamplingClues = append(target.SamplingClues, clue) + case "filtering": + target.FilteringClues = append(target.FilteringClues, clue) + case "logging-level": + target.LoggingLevelClues = append(target.LoggingLevelClues, clue) + } + } + target.InstrumentationClues = sortedUniqueStrings(target.InstrumentationClues) + target.SamplingClues = sortedUniqueStrings(target.SamplingClues) + target.FilteringClues = sortedUniqueStrings(target.FilteringClues) + target.LoggingLevelClues = sortedUniqueStrings(target.LoggingLevelClues) + target.VisibleTelemetryTypes = appInsightsTelemetryTypes(target) + target.Summary = appInsightsTargetSummary(target) + return target +} + +func appInsightsSettingClue(name string, value any, class string) string { + name = strings.TrimSpace(name) + if class != "sampling" && class != "logging-level" { + return name + } + safeValue := appInsightsSafeSettingValue(name, value) + if safeValue == "" { + return name + } + return name + "=" + safeValue +} + +func appInsightsSafeSettingValue(name string, value any) string { + if looksSensitiveSettingName(name) { + return "" + } + text := strings.TrimSpace(stringValue(value)) + if text == "" || appInsightsValueLooksSecret(text) { + return "" + } + if len(text) > 80 { + text = text[:77] + "..." + } + return text +} + +func appInsightsValueLooksSecret(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + if strings.HasPrefix(normalized, "@microsoft.keyvault(") { + return true + } + for _, token := range []string{ + "accountkey=", + "clientsecret=", + "instrumentationkey=", + "sharedaccesskey=", + "sig=", + "password=", + "secret=", + } { + if strings.Contains(normalized, token) { + return true + } + } + return false +} + +func appInsightsSettingClass(name string) string { + normalized := strings.ToLower(strings.TrimSpace(name)) + switch { + case strings.Contains(normalized, "sampling"): + return "sampling" + case strings.Contains(normalized, "telemetryprocessor") || + (strings.Contains(normalized, "filter") || strings.Contains(normalized, "processor")) && + (strings.Contains(normalized, "applicationinsights") || strings.Contains(normalized, "appinsights") || strings.Contains(normalized, "telemetry")): + return "filtering" + case strings.Contains(normalized, "loglevel") && (strings.Contains(normalized, "applicationinsights") || strings.Contains(normalized, "logging")): + return "logging-level" + case strings.Contains(normalized, "applicationinsights") || strings.Contains(normalized, "appinsights") || strings.Contains(normalized, "instrumentationkey"): + return "instrumentation" + default: + return "" + } +} + +func appInsightsTelemetryTypes(target models.AppInsightsAppTarget) []string { + values := []string{} + for _, clue := range append(append([]string{}, target.SamplingClues...), append(target.FilteringClues, target.LoggingLevelClues...)...) { + normalized := strings.ToLower(clue) + switch { + case strings.Contains(normalized, "request"): + values = append(values, "requests") + case strings.Contains(normalized, "depend"): + values = append(values, "dependencies") + case strings.Contains(normalized, "exception") || strings.Contains(normalized, "error"): + values = append(values, "exceptions") + case strings.Contains(normalized, "trace") || strings.Contains(normalized, "log"): + values = append(values, "traces") + } + } + return sortedUniqueStrings(values) +} + +func appInsightsTargetRank(target models.AppInsightsAppTarget) int { + return len(target.FilteringClues)*3 + len(target.SamplingClues)*2 + len(target.LoggingLevelClues) + len(target.InstrumentationClues) +} + +func appInsightsTargetSummary(target models.AppInsightsAppTarget) string { + parts := []string{} + if len(target.InstrumentationClues) > 0 { + parts = append(parts, fmt.Sprintf("%d instrumentation clue(s)", len(target.InstrumentationClues))) + } + if len(target.SamplingClues) > 0 { + parts = append(parts, fmt.Sprintf("%d sampling clue(s)", len(target.SamplingClues))) + } + if len(target.FilteringClues) > 0 { + parts = append(parts, fmt.Sprintf("%d filtering clue(s)", len(target.FilteringClues))) + } + if len(target.LoggingLevelClues) > 0 { + parts = append(parts, fmt.Sprintf("%d logging-level clue(s)", len(target.LoggingLevelClues))) + } + if len(parts) == 0 { + return fmt.Sprintf("%s %q has no visible Application Insights posture clues.", target.Kind, target.Name) + } + return fmt.Sprintf("%s %q has %s visible from app settings.", target.Kind, target.Name, strings.Join(parts, ", ")) +} diff --git a/internal/providers/azure_automation.go b/internal/providers/azure_automation.go index 0cfa4e4..1ff3913 100644 --- a/internal/providers/azure_automation.go +++ b/internal/providers/azure_automation.go @@ -80,6 +80,9 @@ func automationAccountSummary( clientID := stringPtr(mapStringValue(identity, "clientId", "client_id")) identityIDs := automationIdentityIDs(accountID, identity) publishedRunbookCount := automationPublishedRunbookCount(runbooks) + runbookTypes := automationRunbookTypes(runbooks) + runbookCommandClues := []string{} + runbookResourceClues := []string{} encryptedVariableCount := automationEncryptedVariableCount(variables) startModes := automationStartModes( publishedRunbookCount, @@ -139,6 +142,9 @@ func automationAccountSummary( RunbookCount: automationCount(runbooks), PublishedRunbookCount: publishedRunbookCount, PublishedRunbookNames: publishedRunbookNames, + RunbookTypes: runbookTypes, + RunbookCommandClues: runbookCommandClues, + RunbookResourceClues: runbookResourceClues, ScheduleCount: automationCount(schedules), ScheduleDefinitions: automationScheduleDefinitions(schedules), JobScheduleCount: automationCount(jobSchedules), @@ -167,6 +173,9 @@ func automationAccountSummary( identityType, automationCount(runbooks), publishedRunbookCount, + runbookTypes, + runbookCommandClues, + runbookResourceClues, automationCount(schedules), automationCount(jobSchedules), automationCount(webhooks), @@ -312,6 +321,87 @@ func automationPublishedRunbookNames(runbooks []map[string]any) []string { return dedupeStrings(names) } +func automationRunbookTypes(runbooks []map[string]any) []string { + values := []string{} + for _, runbook := range runbooks { + properties := mapValue(runbook, "properties") + values = append(values, firstNonEmpty(mapStringValue(properties, "runbookType", "runbook_type"), mapStringValue(runbook, "runbookType", "runbook_type"))) + } + sort.Strings(values) + return dedupeStrings(values) +} + +func automationRunbookContentClues(ctx context.Context, session azureSession, accountID string, runbooks []map[string]any, issues *[]models.Issue) ([]string, []string) { + commandClues := []string{} + resourceClues := []string{} + for _, runbook := range runbooks { + name := automationRunbookName(runbook) + runbookID := firstNonEmpty(mapStringValue(runbook, "id"), strings.TrimRight(accountID, "/")+"/runbooks/"+name) + if name == "" || runbookID == "" { + continue + } + content, err := authorizedTextGet(ctx, session.credential, armManagementScope, armURL(strings.TrimRight(runbookID, "/")+"/content", armAutomationAPIVersion)) + if err != nil { + *issues = append(*issues, issueFromError("automation.runbook_content["+name+"]", err)) + continue + } + commandClues = append(commandClues, automationRunbookCommandClues(content)...) + resourceClues = append(resourceClues, automationRunbookResourceClues(content)...) + } + sort.Strings(commandClues) + sort.Strings(resourceClues) + return dedupeStrings(commandClues), dedupeStrings(resourceClues) +} + +func automationRunbookCommandClues(content string) []string { + normalized := strings.ToLower(content) + values := []string{} + for _, candidate := range []struct { + needle string + label string + }{ + {"connect-azaccount", "connect-azaccount"}, + {"get-az", "get-az"}, + {"set-az", "set-az"}, + {"new-az", "new-az"}, + {"remove-az", "remove-az"}, + {"restart-az", "restart-az"}, + {"start-az", "start-az"}, + {"stop-az", "stop-az"}, + {"invoke-restmethod", "invoke-restmethod"}, + {"invoke-webrequest", "invoke-webrequest"}, + {"kubectl", "kubectl"}, + {"az ", "azure-cli"}, + } { + if strings.Contains(normalized, candidate.needle) { + values = append(values, candidate.label) + } + } + return values +} + +func automationRunbookResourceClues(content string) []string { + normalized := strings.ToLower(content) + values := []string{} + for _, candidate := range []struct { + needle string + label string + }{ + {"microsoft.web/sites", "app-service"}, + {"microsoft.compute/virtualmachines", "virtual-machine"}, + {"microsoft.automation/automationaccounts", "automation"}, + {"microsoft.keyvault/vaults", "key-vault"}, + {"microsoft.storage/storageaccounts", "storage"}, + {"microsoft.containerservice/managedclusters", "aks"}, + {"management.azure.com", "azure-management-api"}, + } { + if strings.Contains(normalized, candidate.needle) { + values = append(values, candidate.label) + } + } + return values +} + func automationRunbookNamesFromTriggers(items []map[string]any) []string { if items == nil { return []string{} @@ -604,6 +694,9 @@ func automationOperatorSummary( identityType *string, runbookCount *int, publishedRunbookCount *int, + runbookTypes []string, + runbookCommandClues []string, + runbookResourceClues []string, scheduleCount *int, jobScheduleCount *int, webhookCount *int, @@ -619,7 +712,7 @@ func automationOperatorSummary( identityClause = "uses managed identity (" + stringPtrValue(identityType) + ")" } return "Automation account '" + accountName + "' " + identityClause + ". " + - "Visible execution shape: " + automationRunbookClause(runbookCount, publishedRunbookCount) + "; " + + "Visible execution shape: " + automationRunbookClause(runbookCount, publishedRunbookCount, runbookTypes, runbookCommandClues, runbookResourceClues) + "; " + automationTriggerClause(scheduleCount, jobScheduleCount, webhookCount) + "; " + automationWorkerClause(hybridWorkerGroupCount) + ". " + "Secure asset posture: " + automationAssetClause( @@ -631,14 +724,26 @@ func automationOperatorSummary( ) + "." } -func automationRunbookClause(runbookCount *int, publishedRunbookCount *int) string { +func automationRunbookClause(runbookCount *int, publishedRunbookCount *int, runbookTypes []string, commandClues []string, resourceClues []string) string { if runbookCount == nil { return "runbook visibility unreadable" } + parts := []string{} if publishedRunbookCount == nil { - return stringValue(*runbookCount) + " runbook(s)" + parts = append(parts, stringValue(*runbookCount)+" runbook(s)") + } else { + parts = append(parts, stringValue(*publishedRunbookCount)+"/"+stringValue(*runbookCount)+" published runbook(s)") + } + if len(runbookTypes) > 0 { + parts = append(parts, "types "+strings.Join(runbookTypes, ", ")) } - return stringValue(*publishedRunbookCount) + "/" + stringValue(*runbookCount) + " published runbook(s)" + if len(commandClues) > 0 { + parts = append(parts, "command clues "+strings.Join(commandClues, ", ")) + } + if len(resourceClues) > 0 { + parts = append(parts, "resource clues "+strings.Join(resourceClues, ", ")) + } + return strings.Join(parts, "; ") } func automationTriggerClause(scheduleCount *int, jobScheduleCount *int, webhookCount *int) string { diff --git a/internal/providers/azure_dcr.go b/internal/providers/azure_dcr.go new file mode 100644 index 0000000..e5aaca1 --- /dev/null +++ b/internal/providers/azure_dcr.go @@ -0,0 +1,376 @@ +package providers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "harrierops-azure/internal/models" +) + +const armDataCollectionRulesAPIVersion = "2024-03-11" + +func (provider AzureProvider) DCR(ctx context.Context, tenant string, subscription string) (DCRFacts, error) { + session, err := provider.session(ctx, tenant, subscription) + if err != nil { + return DCRFacts{}, err + } + + rulePath := "/subscriptions/" + session.subscription.ID + "/providers/Microsoft.Insights/dataCollectionRules" + rules, err := armListObjects(ctx, session.credential, rulePath, armDataCollectionRulesAPIVersion) + if err != nil { + return DCRFacts{}, err + } + + assets := []models.DCRAsset{} + issues := []models.Issue{} + for _, rule := range rules { + associations, associationIssues := collectDCRAssociations(ctx, session, rule) + issues = append(issues, associationIssues...) + assets = append(assets, dcrAssetFromMap(rule, associations)) + } + + return DCRFacts{ + TenantID: session.tenantID, + SubscriptionID: session.subscription.ID, + DCRs: assets, + Issues: issues, + }, nil +} + +func collectDCRAssociations(ctx context.Context, session azureSession, rule map[string]any) ([]models.DCRAssociation, []models.Issue) { + ruleID := mapStringValue(rule, "id") + if ruleID == "" { + return nil, nil + } + path := strings.TrimRight(ruleID, "/") + "/associations" + rows, err := armListObjects(ctx, session.credential, path, armDataCollectionRulesAPIVersion) + if err != nil { + return nil, []models.Issue{issueFromError("dcr.associations["+ruleID+"]", err)} + } + associations := make([]models.DCRAssociation, 0, len(rows)) + for _, row := range rows { + associations = append(associations, dcrAssociationFromMap(row, ruleID)) + } + sort.SliceStable(associations, func(i, j int) bool { + if associations[i].TargetID != associations[j].TargetID { + return associations[i].TargetID < associations[j].TargetID + } + return associations[i].Name < associations[j].Name + }) + return associations, nil +} + +func dcrAssetFromMap(rule map[string]any, associations []models.DCRAssociation) models.DCRAsset { + properties := mapValue(rule, "properties") + id := mapStringValue(rule, "id") + dataSources := dcrDataSources(properties) + dataFlows := dcrDataFlows(properties) + destinations := dcrDestinations(properties) + streams := dcrStreams(dataSources, dataFlows) + highSignalStreams := dcrHighSignalStreams(streams) + destinationTypes := dcrDestinationTypes(destinations) + dataSourceTypes := dcrDataSourceTypes(dataSources) + relatedIDs := dcrRelatedIDs(id, properties, destinations, associations) + + asset := models.DCRAsset{ + ID: id, + Name: firstNonEmpty(mapStringValue(rule, "name"), resourceNameFromID(id), "unknown"), + ResourceGroup: resourceGroupFromID(id), + Location: mapStringValue(rule, "location"), + Kind: stringPtr(mapStringValue(rule, "kind")), + Description: stringPtr(mapStringValue(properties, "description")), + DataCollectionEndpointID: stringPtr(mapStringValue(properties, "dataCollectionEndpointId")), + DataSources: dataSources, + DataFlows: dataFlows, + Destinations: destinations, + Associations: associations, + DataSourceTypes: dataSourceTypes, + Streams: streams, + HighSignalStreams: highSignalStreams, + DestinationTypes: destinationTypes, + TransformationCount: dcrTransformationCount(dataSources, dataFlows), + AssociationCount: len(associations), + RelatedIDs: relatedIDs, + } + asset.Summary = dcrSummary(asset) + return asset +} + +func dcrDataSources(properties map[string]any) []models.DCRDataSource { + rawSources := mapValue(properties, "dataSources") + sources := []models.DCRDataSource{} + for sourceType, value := range rawSources { + for _, item := range listValue(map[string]any{"value": value}, "value") { + mapped, ok := item.(map[string]any) + if !ok { + continue + } + sources = append(sources, models.DCRDataSource{ + Name: firstNonEmpty(mapStringValue(mapped, "name"), sourceType), + Type: sourceType, + Streams: sortedUniqueStrings(dcrStringList(mapped, "streams", "streamDeclarations")), + TransformKqlPresent: strings.TrimSpace(mapStringValue(mapped, "transformKql")) != "", + TransformKqlFingerprint: dcrTransformFingerprint(mapStringValue(mapped, "transformKql")), + TransformKqlLength: dcrTransformLength(mapStringValue(mapped, "transformKql")), + }) + } + } + sort.SliceStable(sources, func(i, j int) bool { + if sources[i].Type != sources[j].Type { + return sources[i].Type < sources[j].Type + } + return sources[i].Name < sources[j].Name + }) + return sources +} + +func dcrDataFlows(properties map[string]any) []models.DCRDataFlow { + flows := []models.DCRDataFlow{} + for _, item := range listValue(properties, "dataFlows") { + mapped, ok := item.(map[string]any) + if !ok { + continue + } + transformKql := mapStringValue(mapped, "transformKql") + flows = append(flows, models.DCRDataFlow{ + Streams: sortedUniqueStrings(dcrStringList(mapped, "streams")), + Destinations: sortedUniqueStrings(dcrStringList(mapped, "destinations")), + OutputStream: stringPtr(mapStringValue(mapped, "outputStream")), + BuiltInTransform: stringPtr(mapStringValue(mapped, "builtInTransform")), + TransformKqlPresent: strings.TrimSpace(transformKql) != "", + TransformKqlFingerprint: dcrTransformFingerprint(transformKql), + TransformKqlLength: dcrTransformLength(transformKql), + }) + } + sort.SliceStable(flows, func(i, j int) bool { + left := strings.Join(flows[i].Streams, ",") + "|" + strings.Join(flows[i].Destinations, ",") + right := strings.Join(flows[j].Streams, ",") + "|" + strings.Join(flows[j].Destinations, ",") + return left < right + }) + return flows +} + +func dcrDestinations(properties map[string]any) []models.DCRDestination { + rawDestinations := mapValue(properties, "destinations") + destinations := []models.DCRDestination{} + for destinationType, value := range rawDestinations { + for _, item := range dcrDestinationItems(value) { + mapped, ok := item.(map[string]any) + if !ok { + continue + } + destinations = append(destinations, models.DCRDestination{ + Name: firstNonEmpty(mapStringValue(mapped, "name"), destinationType), + Type: destinationType, + ResourceID: stringPtr(dcrDestinationResourceID(mapped)), + Detail: stringPtr(dcrDestinationDetail(mapped)), + }) + } + } + sort.SliceStable(destinations, func(i, j int) bool { + if destinations[i].Type != destinations[j].Type { + return destinations[i].Type < destinations[j].Type + } + return destinations[i].Name < destinations[j].Name + }) + return destinations +} + +func dcrDestinationItems(value any) []any { + if values, ok := value.([]any); ok { + return values + } + if mapped, ok := value.(map[string]any); ok { + if nested := listValue(mapped, "value"); len(nested) > 0 { + return nested + } + return []any{mapped} + } + return nil +} + +func dcrAssociationFromMap(row map[string]any, fallbackRuleID string) models.DCRAssociation { + properties := mapValue(row, "properties") + id := mapStringValue(row, "id") + ruleID := firstNonEmpty(mapStringValue(properties, "dataCollectionRuleId"), fallbackRuleID) + return models.DCRAssociation{ + ID: id, + Name: firstNonEmpty(mapStringValue(row, "name"), resourceNameFromID(id), "unknown"), + TargetID: firstNonEmpty(mapStringValue(properties, "targetResourceId"), dcrAssociationTargetFromID(id)), + DataCollectionRuleID: stringPtr(ruleID), + DataCollectionEndpointID: stringPtr(mapStringValue(properties, "dataCollectionEndpointId")), + Description: stringPtr(mapStringValue(properties, "description")), + } +} + +func dcrAssociationTargetFromID(id string) string { + needle := "/providers/Microsoft.Insights/dataCollectionRuleAssociations/" + index := strings.Index(strings.ToLower(id), strings.ToLower(needle)) + if index < 0 { + return "" + } + return strings.TrimRight(id[:index], "/") +} + +func dcrStringList(input map[string]any, keys ...string) []string { + values := []string{} + for _, key := range keys { + for _, item := range listValue(input, key) { + if value := strings.TrimSpace(stringValue(item)); value != "" { + values = append(values, value) + } + } + } + return values +} + +func dcrDestinationResourceID(destination map[string]any) string { + return firstNonEmpty( + mapStringValue(destination, "workspaceResourceId"), + mapStringValue(destination, "resourceId"), + mapStringValue(destination, "eventHubResourceId"), + mapStringValue(destination, "storageAccountResourceId"), + mapStringValue(destination, "accountResourceId"), + ) +} + +func dcrDestinationDetail(destination map[string]any) string { + return firstNonEmpty( + mapStringValue(destination, "eventHubName"), + mapStringValue(destination, "containerName"), + mapStringValue(destination, "databaseName"), + mapStringValue(destination, "stream"), + mapStringValue(destination, "name"), + ) +} + +func dcrTransformFingerprint(query string) *string { + query = strings.TrimSpace(query) + if query == "" { + return nil + } + sum := sha256.Sum256([]byte(query)) + fingerprint := hex.EncodeToString(sum[:])[:12] + return &fingerprint +} + +func dcrTransformLength(query string) *int { + query = strings.TrimSpace(query) + if query == "" { + return nil + } + length := len(query) + return &length +} + +func dcrTransformationCount(dataSources []models.DCRDataSource, dataFlows []models.DCRDataFlow) int { + count := 0 + for _, source := range dataSources { + if source.TransformKqlPresent { + count++ + } + } + for _, flow := range dataFlows { + if flow.TransformKqlPresent || stringPtrValue(flow.BuiltInTransform) != "" { + count++ + } + } + return count +} + +func dcrDataSourceTypes(dataSources []models.DCRDataSource) []string { + values := []string{} + for _, source := range dataSources { + values = append(values, source.Type) + } + return sortedUniqueStrings(values) +} + +func dcrDestinationTypes(destinations []models.DCRDestination) []string { + values := []string{} + for _, destination := range destinations { + values = append(values, destination.Type) + } + return sortedUniqueStrings(values) +} + +func dcrStreams(dataSources []models.DCRDataSource, dataFlows []models.DCRDataFlow) []string { + values := []string{} + for _, source := range dataSources { + values = append(values, source.Streams...) + } + for _, flow := range dataFlows { + values = append(values, flow.Streams...) + if stringPtrValue(flow.OutputStream) != "" { + values = append(values, stringPtrValue(flow.OutputStream)) + } + } + return sortedUniqueStrings(values) +} + +func dcrHighSignalStreams(streams []string) []string { + values := []string{} + for _, stream := range streams { + if dcrStreamSignalRank(stream) > 0 { + values = append(values, stream) + } + } + sort.SliceStable(values, func(i, j int) bool { + leftRank := dcrStreamSignalRank(values[i]) + rightRank := dcrStreamSignalRank(values[j]) + if leftRank != rightRank { + return leftRank > rightRank + } + return values[i] < values[j] + }) + return values +} + +func dcrStreamSignalRank(stream string) int { + normalized := strings.ToLower(stream) + switch { + case strings.Contains(normalized, "security") || strings.Contains(normalized, "audit") || strings.Contains(normalized, "signin") || strings.Contains(normalized, "auth"): + return 5 + case strings.Contains(normalized, "windowsevent") || strings.Contains(normalized, "event"): + return 4 + case strings.Contains(normalized, "syslog"): + return 3 + case strings.Contains(normalized, "process") || strings.Contains(normalized, "command"): + return 2 + case strings.Contains(normalized, "keyvault") || strings.Contains(normalized, "secret"): + return 2 + default: + return 0 + } +} + +func dcrRelatedIDs(ruleID string, properties map[string]any, destinations []models.DCRDestination, associations []models.DCRAssociation) []string { + values := []string{ruleID, mapStringValue(properties, "dataCollectionEndpointId")} + for _, destination := range destinations { + values = append(values, stringPtrValue(destination.ResourceID)) + } + for _, association := range associations { + values = append(values, association.ID, association.TargetID, stringPtrValue(association.DataCollectionRuleID), stringPtrValue(association.DataCollectionEndpointID)) + } + return sortedUniqueStrings(values) +} + +func dcrSummary(asset models.DCRAsset) string { + parts := []string{ + fmt.Sprintf("DCR %q has %d data source(s), %d data flow(s), %d destination(s), and %d association(s)", asset.Name, len(asset.DataSources), len(asset.DataFlows), len(asset.Destinations), asset.AssociationCount), + } + if asset.TransformationCount > 0 { + parts = append(parts, fmt.Sprintf("%d transformation clue(s) present", asset.TransformationCount)) + } + if len(asset.HighSignalStreams) > 0 { + parts = append(parts, "high-signal streams: "+strings.Join(asset.HighSignalStreams, ", ")) + } + if len(asset.DestinationTypes) > 0 { + parts = append(parts, "destinations: "+strings.Join(asset.DestinationTypes, ", ")) + } + return strings.Join(parts, "; ") + "." +} diff --git a/internal/providers/azure_diagnostic_settings.go b/internal/providers/azure_diagnostic_settings.go new file mode 100644 index 0000000..e54292c --- /dev/null +++ b/internal/providers/azure_diagnostic_settings.go @@ -0,0 +1,384 @@ +package providers + +import ( + "context" + "fmt" + "sort" + "strings" + + "harrierops-azure/internal/models" +) + +const ( + armResourcesAPIVersion = "2021-04-01" + armDiagnosticSettingsAPIVersion = "2021-05-01-preview" +) + +func (provider AzureProvider) DiagnosticSettings(ctx context.Context, tenant string, subscription string) (DiagnosticSettingsFacts, error) { + session, err := provider.session(ctx, tenant, subscription) + if err != nil { + return DiagnosticSettingsFacts{}, err + } + + resourcePath := "/subscriptions/" + session.subscription.ID + "/resources" + resources, err := armListObjects(ctx, session.credential, resourcePath, armResourcesAPIVersion) + if err != nil { + return DiagnosticSettingsFacts{}, err + } + + sources := []models.DiagnosticSettingsSource{ + diagnosticSettingsSubscriptionSource(session.subscription.ID), + } + for _, resource := range resources { + id := mapStringValue(resource, "id") + if id == "" { + continue + } + sources = append(sources, models.DiagnosticSettingsSource{ + ID: id, + Name: firstNonEmpty(mapStringValue(resource, "name"), resourceNameFromID(id), "unknown"), + Type: mapStringValue(resource, "type"), + ResourceGroup: resourceGroupFromID(id), + Location: mapStringValue(resource, "location"), + }) + } + + issues := []models.Issue{} + for index := range sources { + settings, settingIssues := collectDiagnosticSettings(ctx, session, sources[index].ID) + issues = append(issues, settingIssues...) + categories, categoryIssues := collectDiagnosticSettingsCategories(ctx, session, sources[index].ID) + issues = append(issues, categoryIssues...) + sources[index] = diagnosticSettingsHydrateSource(sources[index], settings, categories) + } + + sort.SliceStable(sources, func(i, j int) bool { + if diagnosticSettingsSourceRank(sources[i]) != diagnosticSettingsSourceRank(sources[j]) { + return diagnosticSettingsSourceRank(sources[i]) > diagnosticSettingsSourceRank(sources[j]) + } + if sources[i].Type != sources[j].Type { + return sources[i].Type < sources[j].Type + } + return sources[i].Name < sources[j].Name + }) + + return DiagnosticSettingsFacts{ + TenantID: session.tenantID, + SubscriptionID: session.subscription.ID, + Sources: sources, + Issues: issues, + }, nil +} + +func diagnosticSettingsSubscriptionSource(subscriptionID string) models.DiagnosticSettingsSource { + id := "/subscriptions/" + subscriptionID + return models.DiagnosticSettingsSource{ + ID: id, + Name: "subscription", + Type: "Microsoft.Resources/subscriptions", + Location: "global", + } +} + +func collectDiagnosticSettings(ctx context.Context, session azureSession, sourceID string) ([]models.DiagnosticSettingAsset, []models.Issue) { + path := strings.TrimRight(sourceID, "/") + "/providers/Microsoft.Insights/diagnosticSettings" + rows, err := armListObjects(ctx, session.credential, path, armDiagnosticSettingsAPIVersion) + if err != nil { + return nil, []models.Issue{issueFromError("diagnostic-settings["+sourceID+"]", err)} + } + settings := make([]models.DiagnosticSettingAsset, 0, len(rows)) + for _, row := range rows { + settings = append(settings, diagnosticSettingFromMap(row, sourceID)) + } + sort.SliceStable(settings, func(i, j int) bool { + return settings[i].Name < settings[j].Name + }) + return settings, nil +} + +func collectDiagnosticSettingsCategories(ctx context.Context, session azureSession, sourceID string) ([]models.DiagnosticSettingsCategory, []models.Issue) { + path := strings.TrimRight(sourceID, "/") + "/providers/Microsoft.Insights/diagnosticSettingsCategories" + rows, err := armListObjects(ctx, session.credential, path, armDiagnosticSettingsAPIVersion) + if err != nil { + return nil, []models.Issue{issueFromError("diagnostic-settings-categories["+sourceID+"]", err)} + } + categories := make([]models.DiagnosticSettingsCategory, 0, len(rows)) + for _, row := range rows { + category := diagnosticSettingsSupportedCategoryFromMap(row) + if category.Name == "" { + continue + } + categories = append(categories, category) + } + sort.SliceStable(categories, func(i, j int) bool { + if categories[i].Type != categories[j].Type { + return categories[i].Type < categories[j].Type + } + return categories[i].Name < categories[j].Name + }) + return categories, nil +} + +func diagnosticSettingsSupportedCategoryFromMap(row map[string]any) models.DiagnosticSettingsCategory { + properties := mapValue(row, "properties") + name := firstNonEmpty( + mapStringValue(row, "name"), + mapStringValue(properties, "category"), + resourceNameFromID(mapStringValue(row, "id")), + ) + categoryType := firstNonEmpty(mapStringValue(properties, "categoryType", "category_type"), "log") + return models.DiagnosticSettingsCategory{ + Name: name, + Type: categoryType, + Enabled: true, + } +} + +func diagnosticSettingFromMap(row map[string]any, sourceID string) models.DiagnosticSettingAsset { + properties := mapValue(row, "properties") + id := mapStringValue(row, "id") + logs := diagnosticSettingsCategories(properties, "logs", "log") + metrics := diagnosticSettingsCategories(properties, "metrics", "metric") + destinations := diagnosticSettingsDestinations(properties) + setting := models.DiagnosticSettingAsset{ + ID: id, + Name: firstNonEmpty(mapStringValue(row, "name"), resourceNameFromID(id), "unknown"), + SourceResourceID: sourceID, + Destinations: destinations, + Logs: logs, + Metrics: metrics, + EnabledCategories: diagnosticSettingsEnabledCategories(logs, metrics), + DisabledCategories: diagnosticSettingsDisabledCategories(logs, metrics), + CategoryGroups: diagnosticSettingsCategoryGroups(logs), + HighSignalCategories: diagnosticSettingsHighSignalCategories(logs, metrics), + DestinationTypes: diagnosticSettingsDestinationTypes(destinations), + } + setting.RelatedIDs = diagnosticSettingsRelatedIDs(sourceID, setting) + setting.Summary = diagnosticSettingSummary(setting) + return setting +} + +func diagnosticSettingsCategories(properties map[string]any, key string, categoryType string) []models.DiagnosticSettingsCategory { + categories := []models.DiagnosticSettingsCategory{} + for _, item := range listValue(properties, key) { + mapped, ok := item.(map[string]any) + if !ok { + continue + } + name := firstNonEmpty(mapStringValue(mapped, "category"), mapStringValue(mapped, "categoryGroup"), "unknown") + if name == "unknown" { + continue + } + categories = append(categories, models.DiagnosticSettingsCategory{ + Name: name, + Type: categoryType, + Enabled: diagnosticSettingsBoolValue(mapped["enabled"]), + }) + } + sort.SliceStable(categories, func(i, j int) bool { + if categories[i].Type != categories[j].Type { + return categories[i].Type < categories[j].Type + } + return categories[i].Name < categories[j].Name + }) + return categories +} + +func diagnosticSettingsDestinations(properties map[string]any) []models.DiagnosticSettingsDestination { + candidates := []models.DiagnosticSettingsDestination{ + {Type: "logAnalytics", ResourceID: stringPtr(mapStringValue(properties, "workspaceId")), Detail: stringPtr(mapStringValue(properties, "logAnalyticsDestinationType"))}, + {Type: "storage", ResourceID: stringPtr(mapStringValue(properties, "storageAccountId"))}, + {Type: "eventHubs", ResourceID: stringPtr(mapStringValue(properties, "eventHubAuthorizationRuleId")), Detail: stringPtr(mapStringValue(properties, "eventHubName"))}, + {Type: "marketplacePartner", ResourceID: stringPtr(mapStringValue(properties, "marketplacePartnerId"))}, + } + destinations := []models.DiagnosticSettingsDestination{} + for _, destination := range candidates { + if stringPtrValue(destination.ResourceID) == "" && stringPtrValue(destination.Detail) == "" { + continue + } + destinations = append(destinations, destination) + } + return destinations +} + +func diagnosticSettingsHydrateSource(source models.DiagnosticSettingsSource, settings []models.DiagnosticSettingAsset, supported []models.DiagnosticSettingsCategory) models.DiagnosticSettingsSource { + enabled := []string{} + disabled := []string{} + supportedNames := []string{} + groups := []string{} + highSignal := []string{} + destinationTypes := []string{} + relatedIDs := []string{source.ID} + for _, setting := range settings { + enabled = append(enabled, setting.EnabledCategories...) + disabled = append(disabled, setting.DisabledCategories...) + groups = append(groups, setting.CategoryGroups...) + highSignal = append(highSignal, setting.HighSignalCategories...) + destinationTypes = append(destinationTypes, setting.DestinationTypes...) + relatedIDs = append(relatedIDs, setting.RelatedIDs...) + } + for _, category := range supported { + supportedNames = append(supportedNames, category.Name) + if diagnosticSettingsCategoryLooksHighSignal(category.Name) { + highSignal = append(highSignal, category.Name) + } + } + source.DiagnosticSettings = settings + source.DiagnosticSettingCount = len(settings) + source.EnabledCategories = sortedUniqueStrings(enabled) + source.DisabledCategories = sortedUniqueStrings(disabled) + source.SupportedCategories = sortedUniqueStrings(supportedNames) + source.NotExportedSupported = diagnosticSettingsNotExportedSupported(supported, source.EnabledCategories) + source.SupportedCategoryCatalog = len(supported) > 0 + source.CategoryGroups = sortedUniqueStrings(groups) + source.HighSignalCategories = sortedUniqueStrings(highSignal) + source.DestinationTypes = sortedUniqueStrings(destinationTypes) + source.HasDiagnosticSettings = len(settings) > 0 + source.HasPartialLogPosture = len(source.DisabledCategories) > 0 || len(source.NotExportedSupported) > 0 || (len(source.EnabledCategories) > 0 && len(source.DestinationTypes) > 0) + source.HasHighSignalLogPosture = len(source.HighSignalCategories) > 0 || diagnosticSettingsSourceLooksHighSignal(source) + source.HasNonWorkspaceDestination = diagnosticSettingsHasNonWorkspaceDestination(source.DestinationTypes) + source.RelatedIDs = sortedUniqueStrings(relatedIDs) + source.Summary = diagnosticSettingsSourceSummary(source) + return source +} + +func diagnosticSettingsNotExportedSupported(supported []models.DiagnosticSettingsCategory, enabled []string) []string { + enabledSet := map[string]bool{} + for _, category := range enabled { + enabledSet[strings.ToLower(category)] = true + } + values := []string{} + for _, category := range supported { + if enabledSet["alllogs"] && !strings.EqualFold(category.Type, "metric") { + continue + } + if !enabledSet[strings.ToLower(category.Name)] { + values = append(values, category.Name) + } + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsEnabledCategories(groups ...[]models.DiagnosticSettingsCategory) []string { + values := []string{} + for _, group := range groups { + for _, category := range group { + if category.Enabled { + values = append(values, category.Name) + } + } + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsDisabledCategories(groups ...[]models.DiagnosticSettingsCategory) []string { + values := []string{} + for _, group := range groups { + for _, category := range group { + if !category.Enabled { + values = append(values, category.Name) + } + } + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsCategoryGroups(logs []models.DiagnosticSettingsCategory) []string { + values := []string{} + for _, category := range logs { + if category.Enabled && (strings.EqualFold(category.Name, "allLogs") || strings.Contains(strings.ToLower(category.Name), "audit")) { + values = append(values, category.Name) + } + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsHighSignalCategories(groups ...[]models.DiagnosticSettingsCategory) []string { + values := []string{} + for _, group := range groups { + for _, category := range group { + if diagnosticSettingsCategoryLooksHighSignal(category.Name) { + values = append(values, category.Name) + } + } + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsDestinationTypes(destinations []models.DiagnosticSettingsDestination) []string { + values := []string{} + for _, destination := range destinations { + values = append(values, destination.Type) + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsBoolValue(value any) bool { + boolValue, ok := value.(bool) + return ok && boolValue +} + +func diagnosticSettingsRelatedIDs(sourceID string, setting models.DiagnosticSettingAsset) []string { + values := []string{sourceID, setting.ID} + for _, destination := range setting.Destinations { + values = append(values, stringPtrValue(destination.ResourceID)) + } + return sortedUniqueStrings(values) +} + +func diagnosticSettingsHasNonWorkspaceDestination(types []string) bool { + for _, destinationType := range types { + if destinationType != "logAnalytics" { + return true + } + } + return false +} + +func diagnosticSettingsSourceLooksHighSignal(source models.DiagnosticSettingsSource) bool { + normalized := strings.ToLower(source.Type) + return strings.Contains(normalized, "keyvault") || + strings.Contains(normalized, "storage") || + strings.Contains(normalized, "sql") || + strings.Contains(normalized, "web/sites") || + strings.Contains(normalized, "container") +} + +func diagnosticSettingsCategoryLooksHighSignal(category string) bool { + normalized := strings.ToLower(category) + return strings.Contains(normalized, "audit") || + strings.Contains(normalized, "secret") || + strings.Contains(normalized, "key") || + strings.Contains(normalized, "auth") || + strings.Contains(normalized, "signin") || + strings.Contains(normalized, "firewall") || + strings.Contains(normalized, "request") +} + +func diagnosticSettingsSourceRank(source models.DiagnosticSettingsSource) int { + rank := 0 + if source.HasNonWorkspaceDestination { + rank += 2 + } + if len(source.DisabledCategories) > 0 { + rank += 2 + } + if source.HasHighSignalLogPosture { + rank += 2 + } + if !source.HasDiagnosticSettings && diagnosticSettingsSourceLooksHighSignal(source) { + rank++ + } + return rank +} + +func diagnosticSettingSummary(setting models.DiagnosticSettingAsset) string { + return fmt.Sprintf("diagnostic setting %q exports %d enabled categor(ies), has %d disabled categor(ies), and routes to %s.", setting.Name, len(setting.EnabledCategories), len(setting.DisabledCategories), strings.Join(setting.DestinationTypes, ", ")) +} + +func diagnosticSettingsSourceSummary(source models.DiagnosticSettingsSource) string { + if len(source.DiagnosticSettings) == 0 { + return fmt.Sprintf("%s %q has no visible diagnostic settings.", source.Type, source.Name) + } + return fmt.Sprintf("%s %q has %d diagnostic setting(s), %d enabled categor(ies), %d disabled categor(ies), and destinations: %s.", source.Type, source.Name, len(source.DiagnosticSettings), len(source.EnabledCategories), len(source.DisabledCategories), strings.Join(source.DestinationTypes, ", ")) +} diff --git a/internal/providers/azure_identity.go b/internal/providers/azure_identity.go index 899d6c3..6c10171 100644 --- a/internal/providers/azure_identity.go +++ b/internal/providers/azure_identity.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "sort" @@ -1155,6 +1156,34 @@ func authorizedJSONGetWithToken(ctx context.Context, bearerToken string, rawURL return payload, nil } +func authorizedTextGet(ctx context.Context, credential azcore.TokenCredential, scope string, rawURL string) (string, error) { + token, err := accessToken(ctx, credential, scope) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "text/plain") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("GET %s: %s", rawURL, resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} + func normalizePrincipalType(current string, candidate string) string { current = strings.TrimSpace(current) candidate = strings.TrimSpace(candidate) diff --git a/internal/providers/azure_logic_apps.go b/internal/providers/azure_logic_apps.go index c809aa2..2632fbe 100644 --- a/internal/providers/azure_logic_apps.go +++ b/internal/providers/azure_logic_apps.go @@ -69,6 +69,9 @@ func logicAppWorkflowAsset(workflow map[string]any) models.LogicAppWorkflowAsset triggerTypes := logicAppTriggerTypes(definition) recurrenceSummary := logicAppRecurrenceSummary(definition) downstreamActionKinds := logicAppDownstreamActionKinds(definition) + connectorReferences := logicAppConnectorReferences(definition) + parameterNames := logicAppParameterNames(definition) + resourceReferences := logicAppDownstreamResourceReferences(definition) externallyCallableRequestTrigger := logicAppHasRequestTrigger(definition) classification := logicAppClassification(externallyCallableRequestTrigger, recurrenceSummary != nil, identityType != nil, len(downstreamActionKinds) > 0) identityIDs := logicAppIdentityIDs(workflowID, identity) @@ -86,18 +89,26 @@ func logicAppWorkflowAsset(workflow map[string]any) models.LogicAppWorkflowAsset PrincipalID: stringPtr(mapStringValue(identity, "principalId", "principal_id")), ClientID: stringPtr(mapStringValue(identity, "clientId", "client_id")), IdentityIDs: identityIDs, + TriggerCount: len(mapValue(definition, "triggers")), + ActionCount: logicAppActionCount(definition), + BranchCount: logicAppBranchCount(definition), TriggerTypes: triggerTypes, ExternallyCallableRequestTrigger: externallyCallableRequestTrigger, RecurrenceSummary: recurrenceSummary, DownstreamActionKinds: downstreamActionKinds, + ConnectorReferences: connectorReferences, + ParameterNames: parameterNames, + DownstreamResourceReferences: resourceReferences, Summary: logicAppOperatorSummary( externallyCallableRequestTrigger, recurrenceSummary, identityType, downstreamActionKinds, + connectorReferences, + resourceReferences, classification, ), - RelatedIDs: dedupeStrings(append([]string{workflowID}, identityIDs...)), + RelatedIDs: dedupeStrings(append(append([]string{workflowID}, identityIDs...), resourceReferences...)), } } @@ -188,6 +199,93 @@ func logicAppDownstreamActionKinds(definition map[string]any) []string { return dedupeStrings(categories) } +func logicAppActionCount(definition map[string]any) int { + count := 0 + logicAppWalkActions(mapValue(definition, "actions"), func(action map[string]any) { + count++ + }) + return count +} + +func logicAppBranchCount(definition map[string]any) int { + count := 0 + logicAppWalkActions(mapValue(definition, "actions"), func(action map[string]any) { + if len(mapValue(action, "actions")) > 0 || len(mapValue(mapValue(action, "else"), "actions")) > 0 || len(mapValue(action, "cases")) > 0 { + count++ + } + }) + return count +} + +func logicAppConnectorReferences(definition map[string]any) []string { + values := []string{} + logicAppWalkActions(mapValue(definition, "actions"), func(action map[string]any) { + values = append(values, logicAppStringMatches(action, func(value string) bool { + normalized := strings.ToLower(value) + return strings.Contains(normalized, "/managedapis/") || + strings.Contains(normalized, "apiconnections") || + strings.Contains(normalized, "serviceproviderconnections") + })...) + }) + return dedupeStrings(logicAppShortRefs(values)) +} + +func logicAppParameterNames(definition map[string]any) []string { + return sortedKeys(mapValue(definition, "parameters")) +} + +func logicAppDownstreamResourceReferences(definition map[string]any) []string { + values := []string{} + logicAppWalkActions(mapValue(definition, "actions"), func(action map[string]any) { + values = append(values, logicAppStringMatches(action, func(value string) bool { + normalized := strings.ToLower(value) + return strings.Contains(normalized, "/subscriptions/") && strings.Contains(normalized, "/providers/") + })...) + }) + return dedupeStrings(values) +} + +func logicAppStringMatches(value any, match func(string) bool) []string { + values := []string{} + switch typed := value.(type) { + case map[string]any: + for _, child := range typed { + values = append(values, logicAppStringMatches(child, match)...) + } + case []any: + for _, child := range typed { + values = append(values, logicAppStringMatches(child, match)...) + } + case string: + if match(typed) && !logicAppLooksSecretString(typed) { + values = append(values, typed) + } + } + return values +} + +func logicAppShortRefs(values []string) []string { + result := []string{} + for _, value := range values { + if name := resourceNameFromID(value); name != "" { + result = append(result, name) + continue + } + result = append(result, value) + } + sort.Strings(result) + return result +} + +func logicAppLooksSecretString(value string) bool { + normalized := strings.ToLower(value) + return strings.Contains(normalized, "sig=") || + strings.Contains(normalized, "code=") || + strings.Contains(normalized, "token=") || + strings.Contains(normalized, "secret=") || + strings.Contains(normalized, "sharedaccesssignature") +} + func logicAppWalkActions(actions map[string]any, visit func(map[string]any)) { for _, rawAction := range actions { action, ok := rawAction.(map[string]any) @@ -250,6 +348,8 @@ func logicAppOperatorSummary( recurrenceSummary *string, identityType *string, downstreamActionKinds []string, + connectorReferences []string, + resourceReferences []string, classification string, ) string { parts := []string{} @@ -273,6 +373,12 @@ func logicAppOperatorSummary( if len(downstreamActionKinds) > 0 { parts = append(parts, "Visible actions touch "+strings.Join(downstreamActionKinds, ", ")+".") } + if len(connectorReferences) > 0 { + parts = append(parts, "Connector references are visible ("+strings.Join(connectorReferences, ", ")+").") + } + if len(resourceReferences) > 0 { + parts = append(parts, fmt.Sprintf("%d downstream resource reference(s) are visible.", len(resourceReferences))) + } return strings.TrimSpace(strings.Join(parts, " ")) } diff --git a/internal/providers/azure_monitoring_sinks.go b/internal/providers/azure_monitoring_sinks.go new file mode 100644 index 0000000..7a3c9ce --- /dev/null +++ b/internal/providers/azure_monitoring_sinks.go @@ -0,0 +1,274 @@ +package providers + +import ( + "context" + "strings" + + "harrierops-azure/internal/models" +) + +func (provider AzureProvider) MonitoringSinks(ctx context.Context, tenant string, subscription string) (MonitoringSinksFacts, error) { + session, err := provider.session(ctx, tenant, subscription) + if err != nil { + return MonitoringSinksFacts{}, err + } + + resources, err := armListObjects(ctx, session.credential, "/subscriptions/"+session.subscription.ID+"/resources", armResourcesAPIVersion) + if err != nil { + return MonitoringSinksFacts{}, err + } + + sinks := []models.MonitoringSinkAsset{} + sentinelWorkspaces := monitoringSinksSentinelWorkspaceNames(resources) + for _, resource := range resources { + sink, ok := monitoringSinkFromResource(resource, sentinelWorkspaces) + if !ok { + continue + } + sinks = append(sinks, sink) + } + + issues := []models.Issue{} + dcrFacts, err := provider.DCR(ctx, tenant, subscription) + if err != nil { + issues = append(issues, issueFromError("monitoring-sinks.dcr", err)) + } else { + 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 { + issues = append(issues, diagnosticFacts.Issues...) + monitoringSinksEnsureDiagnosticDestinations(&sinks, diagnosticFacts.Sources) + monitoringSinksAttachDiagnosticReferences(sinks, diagnosticFacts.Sources) + } + + sinks = monitoringSinksFinalize(sinks) + + return MonitoringSinksFacts{ + TenantID: session.tenantID, + SubscriptionID: session.subscription.ID, + Sinks: sinks, + Issues: issues, + }, nil +} + +func monitoringSinkFromResource(resource map[string]any, sentinelWorkspaces map[string]bool) (models.MonitoringSinkAsset, bool) { + id := mapStringValue(resource, "id") + resourceType := mapStringValue(resource, "type") + name := firstNonEmpty(mapStringValue(resource, "name"), resourceNameFromID(id), "unknown") + kind := monitoringSinkKind(resourceType) + if kind == "" { + return models.MonitoringSinkAsset{}, false + } + sink := models.MonitoringSinkAsset{ + ID: id, + Name: name, + Kind: kind, + ResourceType: resourceType, + ResourceGroup: resourceGroupFromID(id), + Location: mapStringValue(resource, "location"), + VisibilitySource: "resource inventory", + RelatedIDs: []string{id}, + } + if kind == "logAnalytics" { + enabled := sentinelWorkspaces[strings.ToLower(name)] + sink.SentinelEnabled = boolPtr(enabled) + if enabled { + sink.Kind = "sentinel" + } + } + return sink, true +} + +func monitoringSinkKind(resourceType string) string { + switch strings.ToLower(strings.TrimSpace(resourceType)) { + case "microsoft.operationalinsights/workspaces": + return "logAnalytics" + case "microsoft.eventhub/namespaces", "microsoft.eventhub/namespaces/authorizationrules": + return "eventHubs" + case "microsoft.storage/storageaccounts": + return "storage" + default: + return "" + } +} + +func monitoringSinksSentinelWorkspaceNames(resources []map[string]any) map[string]bool { + values := map[string]bool{} + for _, resource := range resources { + if !strings.EqualFold(mapStringValue(resource, "type"), "Microsoft.OperationsManagement/solutions") { + continue + } + name := mapStringValue(resource, "name") + normalized := strings.ToLower(name) + if !strings.Contains(normalized, "securityinsights") { + continue + } + if start := strings.Index(name, "("); start >= 0 { + if end := strings.Index(name[start+1:], ")"); end >= 0 { + values[strings.ToLower(name[start+1:start+1+end])] = true + } + } + properties := mapValue(resource, "properties") + workspaceID := firstNonEmpty(mapStringValue(properties, "workspaceResourceId"), mapStringValue(properties, "workspaceResourceID")) + if workspaceName := resourceNameFromID(workspaceID); workspaceName != "" { + values[strings.ToLower(workspaceName)] = true + } + } + return values +} + +func monitoringSinksEnsureDCRDestinations(sinks *[]models.MonitoringSinkAsset, dcrs []models.DCRAsset) { + for _, dcr := range dcrs { + for _, destination := range dcr.Destinations { + monitoringSinksEnsureDestination(sinks, destination.Type, stringPtrValue(destination.ResourceID), stringPtrValue(destination.Detail)) + } + } +} + +func monitoringSinksEnsureDiagnosticDestinations(sinks *[]models.MonitoringSinkAsset, sources []models.DiagnosticSettingsSource) { + for _, source := range sources { + for _, setting := range source.DiagnosticSettings { + for _, destination := range setting.Destinations { + monitoringSinksEnsureDestination(sinks, destination.Type, stringPtrValue(destination.ResourceID), stringPtrValue(destination.Detail)) + } + } + } +} + +func monitoringSinksEnsureDestination(sinks *[]models.MonitoringSinkAsset, destinationType string, resourceID string, detail string) { + key := monitoringSinkDestinationKey(destinationType, resourceID, detail) + if key == "" || monitoringSinksFindIndex(*sinks, key) >= 0 { + return + } + *sinks = append(*sinks, models.MonitoringSinkAsset{ + ID: key, + Name: firstNonEmpty(resourceNameFromID(resourceID), detail, destinationType), + Kind: monitoringSinkKindFromDestination(destinationType), + ResourceType: monitoringSinkResourceType(destinationType, resourceID), + ResourceGroup: resourceGroupFromID(resourceID), + VisibilitySource: "declared destination", + RelatedIDs: []string{key}, + }) +} + +func monitoringSinksAttachDCRReferences(sinks []models.MonitoringSinkAsset, dcrs []models.DCRAsset) { + for _, dcr := range dcrs { + for _, destination := range dcr.Destinations { + key := monitoringSinkDestinationKey(destination.Type, stringPtrValue(destination.ResourceID), stringPtrValue(destination.Detail)) + index := monitoringSinksFindIndex(sinks, key) + if index < 0 { + continue + } + sinks[index].References = append(sinks[index].References, models.MonitoringSinkReference{ + SourceCommand: "dcr", + SourceResourceID: dcr.ID, + SourceName: dcr.Name, + ReferenceName: stringPtr(destination.Name), + ReferenceType: destination.Type, + DestinationDetail: destination.Detail, + }) + } + } +} + +func monitoringSinksAttachDiagnosticReferences(sinks []models.MonitoringSinkAsset, sources []models.DiagnosticSettingsSource) { + for _, source := range sources { + for _, setting := range source.DiagnosticSettings { + for _, destination := range setting.Destinations { + key := monitoringSinkDestinationKey(destination.Type, stringPtrValue(destination.ResourceID), stringPtrValue(destination.Detail)) + index := monitoringSinksFindIndex(sinks, key) + if index < 0 { + continue + } + sinks[index].References = append(sinks[index].References, models.MonitoringSinkReference{ + SourceCommand: "diagnostic-settings", + SourceResourceID: source.ID, + SourceName: source.Name, + ReferenceName: stringPtr(setting.Name), + ReferenceType: destination.Type, + DestinationDetail: destination.Detail, + }) + } + } + } +} + +func monitoringSinksFindIndex(sinks []models.MonitoringSinkAsset, key string) int { + normalized := strings.ToLower(strings.TrimSpace(key)) + if normalized == "" { + return -1 + } + for index, sink := range sinks { + if strings.ToLower(strings.TrimSpace(sink.ID)) == normalized { + return index + } + } + return -1 +} + +func monitoringSinkDestinationKey(destinationType string, resourceID string, detail string) string { + resourceID = strings.TrimSpace(resourceID) + if resourceID != "" { + return resourceID + } + if strings.TrimSpace(detail) == "" { + return "" + } + return "declared:" + monitoringSinkKindFromDestination(destinationType) + ":" + strings.TrimSpace(detail) +} + +func monitoringSinkKindFromDestination(destinationType string) string { + switch strings.ToLower(strings.TrimSpace(destinationType)) { + case "loganalytics": + return "logAnalytics" + case "eventhubs": + return "eventHubs" + case "storage": + return "storage" + case "marketplacepartner": + return "marketplacePartner" + default: + return firstNonEmpty(destinationType, "unknown") + } +} + +func monitoringSinkResourceType(destinationType string, resourceID string) string { + if resourceID != "" { + return resourceTypeFromID(resourceID) + } + switch monitoringSinkKindFromDestination(destinationType) { + case "logAnalytics": + return "Microsoft.OperationalInsights/workspaces" + case "eventHubs": + return "Microsoft.EventHub/namespaces/authorizationRules" + case "storage": + return "Microsoft.Storage/storageAccounts" + default: + return "declared destination" + } +} + +func resourceTypeFromID(resourceID string) string { + normalized := strings.Trim(resourceID, "/") + parts := strings.Split(normalized, "/") + for i := 0; i < len(parts)-2; i++ { + if strings.EqualFold(parts[i], "providers") { + return parts[i+1] + "/" + parts[i+2] + } + } + return "" +} + +func monitoringSinkReferenceIDs(references []models.MonitoringSinkReference) []string { + values := []string{} + for _, reference := range references { + values = append(values, reference.SourceResourceID) + } + return values +} diff --git a/internal/providers/azure_relay.go b/internal/providers/azure_relay.go new file mode 100644 index 0000000..a1f1fe0 --- /dev/null +++ b/internal/providers/azure_relay.go @@ -0,0 +1,208 @@ +package providers + +import ( + "context" + "fmt" + "sort" + "strings" + + "harrierops-azure/internal/models" +) + +const armRelayAPIVersion = "2021-11-01" + +func (provider AzureProvider) Relay(ctx context.Context, tenant string, subscription string) (RelayFacts, error) { + session, err := provider.session(ctx, tenant, subscription) + if err != nil { + return RelayFacts{}, err + } + + namespaces, err := armListObjects(ctx, session.credential, "/subscriptions/"+session.subscription.ID+"/providers/Microsoft.Relay/namespaces", armRelayAPIVersion) + if err != nil { + return RelayFacts{}, err + } + + rows := []models.RelayNamespaceAsset{} + issues := []models.Issue{} + attachments := relayAppServiceHybridConnectionAttachments(ctx, session, &issues) + for _, namespace := range namespaces { + namespaceID := mapStringValue(namespace, "id") + resourceGroup, namespaceName := resourceGroupAndNameFromID(namespaceID) + hybridConnections := []models.RelayHybridConnectionAsset{} + authRules := []map[string]any{} + if resourceGroup != "" && namespaceName != "" { + hybridPath := namespaceID + "/hybridConnections" + items, listErr := armListObjects(ctx, session.credential, hybridPath, armRelayAPIVersion) + if listErr != nil { + issues = append(issues, issueFromError("relay["+resourceGroup+"/"+namespaceName+"].hybrid_connections", listErr)) + } else { + for _, item := range items { + hybridConnections = append(hybridConnections, relayHybridConnectionAsset(namespaceName, item, attachments)) + } + } + rules, rulesErr := armListObjects(ctx, session.credential, namespaceID+"/authorizationRules", armRelayAPIVersion) + if rulesErr != nil { + issues = append(issues, issueFromError("relay["+resourceGroup+"/"+namespaceName+"].authorization_rules", rulesErr)) + } else { + authRules = rules + } + } + rows = append(rows, relayNamespaceAsset(namespace, hybridConnections, authRules)) + } + + return RelayFacts{ + TenantID: session.tenantID, + SubscriptionID: session.subscription.ID, + Namespaces: rows, + Issues: issues, + }, nil +} + +func relayNamespaceAsset(namespace map[string]any, hybridConnections []models.RelayHybridConnectionAsset, authRules []map[string]any) models.RelayNamespaceAsset { + id := mapStringValue(namespace, "id") + properties := mapValue(namespace, "properties") + sku := mapValue(namespace, "sku") + hybridCount := len(hybridConnections) + authRuleCount := len(authRules) + name := firstNonEmpty(mapStringValue(namespace, "name"), resourceNameFromID(id), "unknown") + return models.RelayNamespaceAsset{ + ID: id, + Name: name, + ResourceGroup: resourceGroupFromID(id), + Location: stringPtr(mapStringValue(namespace, "location")), + SKUName: stringPtr(mapStringValue(sku, "name")), + ProvisioningState: stringPtr(mapStringValue(properties, "provisioningState", "provisioning_state")), + ServiceBusEndpoint: stringPtr(mapStringValue(properties, "serviceBusEndpoint", "service_bus_endpoint")), + MetricID: stringPtr(mapStringValue(properties, "metricId", "metric_id")), + HybridConnectionCount: &hybridCount, + AuthorizationRuleCount: &authRuleCount, + HybridConnections: append([]models.RelayHybridConnectionAsset{}, hybridConnections...), + Summary: relayNamespaceSummary(name, hybridCount, authRuleCount), + RelatedIDs: relayRelatedIDs(id, hybridConnections), + } +} + +func relayHybridConnectionAsset(namespaceName string, item map[string]any, attachments []relayAppServiceHybridConnectionAttachment) models.RelayHybridConnectionAsset { + id := mapStringValue(item, "id") + properties := mapValue(item, "properties") + name := firstNonEmpty(mapStringValue(item, "name"), resourceNameFromID(id), "unknown") + apps, relatedIDs := relayAppServiceAttachmentNames(namespaceName, name, attachments) + return models.RelayHybridConnectionAsset{ + ID: id, + Name: name, + RequiresClientAuthorization: optionalBoolPtr(properties, "requiresClientAuthorization", "requires_client_authorization"), + UserMetadata: stringPtr(mapStringValue(properties, "userMetadata", "user_metadata")), + ListenerCount: optionalIntPtr(properties, "listenerCount", "listener_count"), + AppServiceAttachments: apps, + Summary: relayHybridConnectionSummary(namespaceName, name, apps), + RelatedIDs: dedupeStrings(append([]string{id}, relatedIDs...)), + } +} + +func relayHybridConnectionSummary(namespaceName string, connectionName string, attachments []string) string { + summary := fmt.Sprintf("Hybrid Connection %q is visible under relay namespace %q", connectionName, namespaceName) + if len(attachments) > 0 { + summary += fmt.Sprintf(" with App Service attachment(s): %s", strings.Join(attachments, ", ")) + } + return summary + "." +} + +func relayNamespaceSummary(name string, hybridCount int, authRuleCount int) string { + parts := []string{fmt.Sprintf("Relay namespace %q exposes %d hybrid connection(s)", name, hybridCount)} + if authRuleCount > 0 { + parts = append(parts, fmt.Sprintf("%d authorization rule(s)", authRuleCount)) + } + return strings.Join(parts, " and ") + "." +} + +func relayRelatedIDs(namespaceID string, hybridConnections []models.RelayHybridConnectionAsset) []string { + values := []string{namespaceID} + for _, connection := range hybridConnections { + values = append(values, connection.ID) + values = append(values, connection.RelatedIDs...) + } + return dedupeStrings(values) +} + +type relayAppServiceHybridConnectionAttachment struct { + ID string + AppServiceName string + AppServiceID string + RelayNamespace string + HybridConnection string +} + +func relayAppServiceHybridConnectionAttachments(ctx context.Context, session azureSession, issues *[]models.Issue) []relayAppServiceHybridConnectionAttachment { + resources, err := armListObjects(ctx, session.credential, "/subscriptions/"+session.subscription.ID+"/resources", armResourcesAPIVersion) + if err != nil { + *issues = append(*issues, issueFromError("relay.app_service_hybrid_connections", err)) + return []relayAppServiceHybridConnectionAttachment{} + } + attachments := []relayAppServiceHybridConnectionAttachment{} + for _, resource := range resources { + if !strings.EqualFold(mapStringValue(resource, "type"), "Microsoft.Web/sites/hybridConnectionNamespaces/relays") { + continue + } + if attachment := relayAppServiceHybridConnectionAttachmentFromResource(resource); attachment.ID != "" { + attachments = append(attachments, attachment) + } + } + return attachments +} + +func relayAppServiceHybridConnectionAttachmentFromResource(resource map[string]any) relayAppServiceHybridConnectionAttachment { + id := mapStringValue(resource, "id") + nameParts := strings.Split(mapStringValue(resource, "name"), "/") + properties := mapValue(resource, "properties") + appName := relayAppServiceNameFromHybridConnectionID(id) + namespaceName := firstNonEmpty(mapStringValue(properties, "serviceBusNamespace"), relayAppServiceNamespaceFromID(id)) + connectionName := firstNonEmpty(mapStringValue(properties, "relayName"), resourceNameFromID(id)) + if len(nameParts) >= 3 { + appName = firstNonEmpty(appName, nameParts[0]) + namespaceName = firstNonEmpty(namespaceName, nameParts[1]) + connectionName = firstNonEmpty(connectionName, nameParts[2]) + } + return relayAppServiceHybridConnectionAttachment{ + ID: id, + AppServiceName: appName, + AppServiceID: relayAppServiceIDFromHybridConnectionID(id), + RelayNamespace: namespaceName, + HybridConnection: connectionName, + } +} + +func relayAppServiceAttachmentNames(namespaceName string, connectionName string, attachments []relayAppServiceHybridConnectionAttachment) ([]string, []string) { + names := []string{} + relatedIDs := []string{} + for _, attachment := range attachments { + if !strings.EqualFold(attachment.RelayNamespace, namespaceName) || !strings.EqualFold(attachment.HybridConnection, connectionName) { + continue + } + names = append(names, attachment.AppServiceName) + relatedIDs = append(relatedIDs, attachment.ID, attachment.AppServiceID) + } + sort.Strings(names) + return dedupeStrings(names), dedupeStrings(relatedIDs) +} + +func relayAppServiceIDFromHybridConnectionID(id string) string { + marker := "/hybridConnectionNamespaces/" + if index := strings.Index(strings.ToLower(id), strings.ToLower(marker)); index > 0 { + return id[:index] + } + return "" +} + +func relayAppServiceNameFromHybridConnectionID(id string) string { + return resourceNameFromID(relayAppServiceIDFromHybridConnectionID(id)) +} + +func relayAppServiceNamespaceFromID(id string) string { + parts := strings.Split(id, "/") + for index, part := range parts { + if strings.EqualFold(part, "hybridConnectionNamespaces") && index+1 < len(parts) { + return parts[index+1] + } + } + return "" +} diff --git a/internal/providers/family_signal_classification_test.go b/internal/providers/family_signal_classification_test.go new file mode 100644 index 0000000..c41dbae --- /dev/null +++ b/internal/providers/family_signal_classification_test.go @@ -0,0 +1,116 @@ +package providers + +import ( + "strings" + "testing" + + "harrierops-azure/internal/models" +) + +func TestAppInsightsSettingClassBoundaries(t *testing.T) { + tests := []struct { + name string + want string + }{ + {name: "APPLICATIONINSIGHTS_CONNECTION_STRING", want: "instrumentation"}, + {name: "APPINSIGHTS_SAMPLING_PERCENTAGE", want: "sampling"}, + {name: "ApplicationInsights:TelemetryProcessor:DropHealthChecks", want: "filtering"}, + {name: "Logging:LogLevel:ApplicationInsights", want: "logging-level"}, + {name: "FEATURE_FILTER_ENABLED", want: ""}, + {name: "LOGLEVEL_DEFAULT", want: ""}, + {name: "unrelated", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := appInsightsSettingClass(tc.name); got != tc.want { + t.Fatalf("appInsightsSettingClass(%q)=%q, want %q", tc.name, got, tc.want) + } + }) + } +} + +func TestDCRStreamSignalRankBoundaries(t *testing.T) { + tests := []struct { + stream string + want int + }{ + {stream: "Microsoft-SecurityEvent", want: 5}, + {stream: "Microsoft-WindowsEvent", want: 4}, + {stream: "Microsoft-Syslog", want: 3}, + {stream: "Microsoft-Process", want: 2}, + {stream: "Microsoft-Perf", want: 0}, + } + + for _, tc := range tests { + t.Run(tc.stream, func(t *testing.T) { + if got := dcrStreamSignalRank(tc.stream); got != tc.want { + t.Fatalf("dcrStreamSignalRank(%q)=%d, want %d", tc.stream, got, tc.want) + } + }) + } +} + +func TestDiagnosticSettingsSignalClassifiers(t *testing.T) { + if !diagnosticSettingsSourceLooksHighSignal(models.DiagnosticSettingsSource{Type: "Microsoft.KeyVault/vaults"}) { + t.Fatal("expected Key Vault source to be high signal") + } + if diagnosticSettingsSourceLooksHighSignal(models.DiagnosticSettingsSource{Type: "Microsoft.Network/networkWatchers"}) { + t.Fatal("did not expect Network Watcher source to be high signal") + } + if !diagnosticSettingsCategoryLooksHighSignal("AuditEvent") { + t.Fatal("expected AuditEvent category to be high signal") + } + if diagnosticSettingsCategoryLooksHighSignal("AllMetrics") { + t.Fatal("did not expect AllMetrics category to be high signal") + } +} + +func TestDiagnosticSettingsAllLogsCoversSupportedLogCategories(t *testing.T) { + supported := []models.DiagnosticSettingsCategory{ + {Name: "AuditEvent", Type: "log", Enabled: true}, + {Name: "SecretNearExpiryEvent", Type: "log", Enabled: true}, + {Name: "AllMetrics", Type: "metric", Enabled: true}, + } + + got := diagnosticSettingsNotExportedSupported(supported, []string{"allLogs"}) + want := []string{"AllMetrics"} + if len(got) != len(want) || got[0] != want[0] { + t.Fatalf("diagnosticSettingsNotExportedSupported(allLogs)=%v, want %v", got, want) + } +} + +func TestAPIMPolicyControlTypeClassifiers(t *testing.T) { + policies := []map[string]any{ + {"properties": map[string]any{"value": ``}}, + {"properties": map[string]any{"policyContent": ``}}, + {"properties": map[string]any{"value": ``}}, + } + + got := apiMgmtPolicyControlTypes(policies) + want := []string{"backend-routing", "header-auth", "conditional-routing", "request-rewrite", "side-request"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("apiMgmtPolicyControlTypes=%v, want %v", got, want) + } +} + +func TestAutomationRunbookClueClassifiers(t *testing.T) { + content := ` +Connect-AzAccount -Identity +Get-AzResource -ResourceType Microsoft.Web/sites +Invoke-RestMethod -Uri https://management.azure.com/subscriptions/demo/providers/Microsoft.KeyVault/vaults +kubectl get pods +` + + commandClues := automationRunbookCommandClues(content) + wantCommands := []string{"connect-azaccount", "get-az", "invoke-restmethod", "kubectl"} + if strings.Join(commandClues, ",") != strings.Join(wantCommands, ",") { + t.Fatalf("automationRunbookCommandClues=%v, want %v", commandClues, wantCommands) + } + + resourceClues := automationRunbookResourceClues(content) + wantResources := []string{"app-service", "key-vault", "azure-management-api"} + if strings.Join(resourceClues, ",") != strings.Join(wantResources, ",") { + t.Fatalf("automationRunbookResourceClues=%v, want %v", resourceClues, wantResources) + } +} diff --git a/internal/providers/monitoring_sinks_shared.go b/internal/providers/monitoring_sinks_shared.go new file mode 100644 index 0000000..e59f793 --- /dev/null +++ b/internal/providers/monitoring_sinks_shared.go @@ -0,0 +1,75 @@ +package providers + +import ( + "fmt" + "sort" + "strings" + + "harrierops-azure/internal/models" +) + +func monitoringSinkSummary(sink models.MonitoringSinkAsset) string { + parts := []string{fmt.Sprintf("%s sink %q is visible through %s", sink.Kind, sink.Name, sink.VisibilitySource)} + if sink.SentinelEnabled != nil { + if *sink.SentinelEnabled { + parts = append(parts, "Sentinel appears enabled") + } else { + parts = append(parts, "Sentinel enablement not visible") + } + } + if sink.ReferenceCount > 0 { + parts = append(parts, fmt.Sprintf("referenced by %d telemetry route(s)", sink.ReferenceCount)) + } + return strings.Join(parts, "; ") + "." +} + +func monitoringSinkSort(sinks []models.MonitoringSinkAsset) { + sort.SliceStable(sinks, func(i, j int) bool { + if monitoringSinkRank(sinks[i]) != monitoringSinkRank(sinks[j]) { + return monitoringSinkRank(sinks[i]) > monitoringSinkRank(sinks[j]) + } + if sinks[i].Kind != sinks[j].Kind { + return sinks[i].Kind < sinks[j].Kind + } + return sinks[i].Name < sinks[j].Name + }) +} + +func MonitoringSinksFromDCRReferences(dcrs []models.DCRAsset) []models.MonitoringSinkAsset { + sinks := []models.MonitoringSinkAsset{} + monitoringSinksEnsureDCRDestinations(&sinks, dcrs) + monitoringSinksAttachDCRReferences(sinks, dcrs) + return monitoringSinksFinalize(sinks) +} + +func MonitoringSinksFromDiagnosticReferences(sources []models.DiagnosticSettingsSource) []models.MonitoringSinkAsset { + sinks := []models.MonitoringSinkAsset{} + monitoringSinksEnsureDiagnosticDestinations(&sinks, sources) + monitoringSinksAttachDiagnosticReferences(sinks, sources) + return monitoringSinksFinalize(sinks) +} + +func monitoringSinksFinalize(sinks []models.MonitoringSinkAsset) []models.MonitoringSinkAsset { + for index := range sinks { + sinks[index].ReferenceCount = len(sinks[index].References) + sinks[index].RelatedIDs = sortedUniqueStrings(append(sinks[index].RelatedIDs, monitoringSinkReferenceIDs(sinks[index].References)...)) + sinks[index].Summary = monitoringSinkSummary(sinks[index]) + } + monitoringSinkSort(sinks) + return sinks +} + +func monitoringSinkRank(sink models.MonitoringSinkAsset) int { + rank := sink.ReferenceCount + switch sink.Kind { + case "sentinel": + rank += 5 + case "logAnalytics": + rank += 4 + case "eventHubs": + rank += 3 + case "storage": + rank += 2 + } + return rank +} diff --git a/internal/providers/static.go b/internal/providers/static.go index 86c13bc..ef444bc 100644 --- a/internal/providers/static.go +++ b/internal/providers/static.go @@ -9,6 +9,7 @@ import ( type Provider interface { AKS(context.Context, string, string) (AksFacts, error) Acr(context.Context, string, string) (AcrFacts, error) + AppInsights(context.Context, string, string) (AppInsightsFacts, error) AppCredentials(context.Context, string, string) (AppCredentialsFacts, error) Automation(context.Context, string, string) (AutomationFacts, error) Devops(context.Context, string, string, string) (DevopsFacts, error) @@ -20,6 +21,8 @@ type Provider interface { ContainerAppsJobs(context.Context, string, string) (ContainerAppsJobsFacts, error) ContainerInstances(context.Context, string, string) (ContainerInstancesFacts, error) Databases(context.Context, string, string) (DatabasesFacts, error) + DCR(context.Context, string, string) (DCRFacts, error) + DiagnosticSettings(context.Context, string, string) (DiagnosticSettingsFacts, error) Endpoints(context.Context, string, string) (EndpointsFacts, error) EnvVars(context.Context, string, string) (EnvVarsFacts, error) AzureML(context.Context, string, string) (AzureMLFacts, error) @@ -29,6 +32,7 @@ type Provider interface { KeyVault(context.Context, string, string) (KeyVaultFacts, error) LogicApps(context.Context, string, string) (LogicAppsFacts, error) ManagedIdentities(context.Context, string, string) (ManagedIdentitiesFacts, error) + MonitoringSinks(context.Context, string, string) (MonitoringSinksFacts, error) DNS(context.Context, string, string) (DNSFacts, error) NetworkEffective(context.Context, string, string) (NetworkEffectiveFacts, error) NetworkPorts(context.Context, string, string) (NetworkPortsFacts, error) @@ -40,6 +44,7 @@ type Provider interface { CrossTenant(context.Context, string, string) (CrossTenantFacts, error) AuthPolicies(context.Context, string, string) (AuthPoliciesFacts, error) ResourceTrusts(context.Context, string, string) (ResourceTrustsFacts, error) + Relay(context.Context, string, string) (RelayFacts, error) RBAC(context.Context, string, string) (RBACFacts, error) RoleTrusts(context.Context, string, string, models.RoleTrustsMode) (RoleTrustsFacts, error) Storage(context.Context, string, string) (StorageFacts, error) @@ -131,6 +136,35 @@ type DatabasesFacts struct { Issues []models.Issue } +type DCRFacts struct { + TenantID string + SubscriptionID string + DCRs []models.DCRAsset + Issues []models.Issue +} + +type AppInsightsFacts struct { + TenantID string + SubscriptionID string + Components []models.AppInsightsComponent + Targets []models.AppInsightsAppTarget + Issues []models.Issue +} + +type DiagnosticSettingsFacts struct { + TenantID string + SubscriptionID string + Sources []models.DiagnosticSettingsSource + Issues []models.Issue +} + +type MonitoringSinksFacts struct { + TenantID string + SubscriptionID string + Sinks []models.MonitoringSinkAsset + Issues []models.Issue +} + type KeyVaultFacts struct { TenantID string SubscriptionID string @@ -153,6 +187,13 @@ type ResourceTrustsFacts struct { Issues []models.Issue } +type RelayFacts struct { + TenantID string + SubscriptionID string + Namespaces []models.RelayNamespaceAsset + Issues []models.Issue +} + type LighthouseFacts struct { TenantID string SubscriptionID string diff --git a/internal/providers/static_appinsights.go b/internal/providers/static_appinsights.go new file mode 100644 index 0000000..cf52871 --- /dev/null +++ b/internal/providers/static_appinsights.go @@ -0,0 +1,67 @@ +package providers + +import ( + "context" + + "harrierops-azure/internal/models" +) + +func (StaticProvider) AppInsights(_ context.Context, tenant string, subscription string) (AppInsightsFacts, error) { + session := staticFixtureSession(tenant, subscription) + subscriptionID := session.Subscription.ID + componentID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.Insights/components/ai-public-api" + appID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + functionID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + + facts := AppInsightsFacts{ + TenantID: session.TenantID, + SubscriptionID: subscriptionID, + Components: []models.AppInsightsComponent{ + { + ID: componentID, + Name: "ai-public-api", + ResourceGroup: "rg-monitor", + Location: "eastus", + Kind: models.StringPtr("web"), + ApplicationType: models.StringPtr("web"), + WorkspaceResourceID: models.StringPtr("/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod"), + IngestionMode: models.StringPtr("LogAnalytics"), + Summary: "Application Insights component \"ai-public-api\" is visible in eastus.", + RelatedIDs: []string{componentID}, + }, + }, + Targets: []models.AppInsightsAppTarget{ + { + ID: appID, + Name: "app-public-api", + Kind: "AppService", + ResourceGroup: "rg-apps", + Location: "eastus", + InstrumentationClues: []string{"APPLICATIONINSIGHTS_CONNECTION_STRING"}, + SamplingClues: []string{"ApplicationInsights__Sampling__Percentage=25"}, + FilteringClues: []string{"ApplicationInsights__TelemetryProcessor__HealthCheckFilter"}, + LoggingLevelClues: []string{"Logging__ApplicationInsights__LogLevel__Default=Warning"}, + VisibleTelemetryTypes: []string{"traces"}, + RelatedIDs: []string{appID}, + }, + { + ID: functionID, + Name: "func-orders", + Kind: "FunctionApp", + ResourceGroup: "rg-apps", + Location: "eastus", + InstrumentationClues: []string{"APPINSIGHTS_INSTRUMENTATIONKEY"}, + SamplingClues: []string{"AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true"}, + FilteringClues: []string{}, + LoggingLevelClues: []string{}, + VisibleTelemetryTypes: []string{}, + RelatedIDs: []string{functionID}, + }, + }, + Issues: []models.Issue{}, + } + for index := range facts.Targets { + facts.Targets[index].Summary = appInsightsTargetSummary(facts.Targets[index]) + } + return facts, nil +} diff --git a/internal/providers/static_automation.go b/internal/providers/static_automation.go index 57fd421..6fe5684 100644 --- a/internal/providers/static_automation.go +++ b/internal/providers/static_automation.go @@ -28,6 +28,9 @@ func (StaticProvider) Automation(_ context.Context, tenant string, subscription RunbookCount: intPtr(2), PublishedRunbookCount: intPtr(1), PublishedRunbookNames: []string{"Lab-Maintenance"}, + RunbookTypes: []string{"PowerShell"}, + RunbookCommandClues: []string{}, + RunbookResourceClues: []string{}, ScheduleCount: intPtr(1), ScheduleDefinitions: []string{"lab-maintenance-daily: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T03:00:00Z; enabled=true"}, JobScheduleCount: intPtr(1), @@ -51,7 +54,7 @@ func (StaticProvider) Automation(_ context.Context, tenant string, subscription ConsequenceTypes: []string{"run-recurring-execution", "reintroduce-config", "consume-secret-backed-deployment-material"}, MissingExecutionPath: false, MissingTargetMapping: true, - Summary: "Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted).", + Summary: "Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); types PowerShell; runbook content clues not collected by default; 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted).", RelatedIDs: []string{"/subscriptions/" + subscriptionID + "/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet"}, }, { @@ -70,6 +73,9 @@ func (StaticProvider) Automation(_ context.Context, tenant string, subscription RunbookCount: intPtr(7), PublishedRunbookCount: intPtr(6), PublishedRunbookNames: []string{"Baseline-Config", "Nightly-Reconcile", "Redeploy-App", "Reapply-Agent", "Sync-Secrets", "Rotate-Certs"}, + RunbookTypes: []string{"PowerShell", "Python3"}, + RunbookCommandClues: []string{}, + RunbookResourceClues: []string{}, ScheduleCount: intPtr(4), ScheduleDefinitions: []string{ "baseline-nightly: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T01:00:00Z; enabled=true", @@ -108,7 +114,7 @@ func (StaticProvider) Automation(_ context.Context, tenant string, subscription ConsequenceTypes: []string{"run-recurring-execution", "reintroduce-config", "consume-secret-backed-deployment-material"}, MissingExecutionPath: false, MissingTargetMapping: true, - Summary: "Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted).", + Summary: "Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); types PowerShell, Python3; runbook content clues not collected by default; 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted).", RelatedIDs: []string{ "/subscriptions/" + subscriptionID + "/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod", "/subscriptions/" + subscriptionID + "/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system", diff --git a/internal/providers/static_dcr.go b/internal/providers/static_dcr.go new file mode 100644 index 0000000..1193de3 --- /dev/null +++ b/internal/providers/static_dcr.go @@ -0,0 +1,111 @@ +package providers + +import ( + "context" + + "harrierops-azure/internal/models" +) + +func (StaticProvider) DCR(_ context.Context, tenant string, subscription string) (DCRFacts, error) { + session := staticFixtureSession(tenant, subscription) + subscriptionID := session.Subscription.ID + workspaceID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + eventHubID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send" + vmTargetID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01" + vmssTargetID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch" + prodDCRID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host" + migrationDCRID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration" + prodAssocID := vmTargetID + "/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association" + migrationAssocID := vmssTargetID + "/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association" + transformFingerprint := "31c5a1b7dd8e" + transformLength := 84 + + facts := DCRFacts{ + TenantID: session.TenantID, + SubscriptionID: subscriptionID, + DCRs: []models.DCRAsset{ + { + ID: prodDCRID, + Name: "dcr-prod-host", + ResourceGroup: "rg-monitor", + Location: "eastus", + Description: models.StringPtr("Production host collection rule with cost-control transform"), + DataSources: []models.DCRDataSource{ + {Name: "windows-security-events", Type: "windowsEventLogs", Streams: []string{"Microsoft-WindowsEvent"}}, + {Name: "linux-syslog", Type: "syslog", Streams: []string{"Microsoft-Syslog"}}, + }, + DataFlows: []models.DCRDataFlow{ + { + Streams: []string{"Microsoft-WindowsEvent"}, + Destinations: []string{"soc-workspace"}, + TransformKqlPresent: true, + TransformKqlFingerprint: &transformFingerprint, + TransformKqlLength: &transformLength, + }, + { + Streams: []string{"Microsoft-Syslog"}, + Destinations: []string{"soc-workspace"}, + }, + }, + Destinations: []models.DCRDestination{ + {Name: "soc-workspace", Type: "logAnalytics", ResourceID: &workspaceID, Detail: models.StringPtr("soc-workspace")}, + }, + Associations: []models.DCRAssociation{ + { + ID: prodAssocID, + Name: "prod-host-association", + TargetID: vmTargetID, + DataCollectionRuleID: &prodDCRID, + Description: models.StringPtr("Production host association"), + }, + }, + DataSourceTypes: []string{"syslog", "windowsEventLogs"}, + Streams: []string{"Microsoft-Syslog", "Microsoft-WindowsEvent"}, + HighSignalStreams: []string{"Microsoft-WindowsEvent", "Microsoft-Syslog"}, + DestinationTypes: []string{"logAnalytics"}, + TransformationCount: 1, + AssociationCount: 1, + RelatedIDs: []string{prodAssocID, prodDCRID, vmTargetID, workspaceID}, + }, + { + ID: migrationDCRID, + Name: "dcr-ama-migration", + ResourceGroup: "rg-monitor", + Location: "eastus", + Description: models.StringPtr("AMA migration routing for batch fleet"), + DataSources: []models.DCRDataSource{ + {Name: "perf-default", Type: "performanceCounters", Streams: []string{"Microsoft-Perf"}}, + {Name: "custom-text", Type: "logFiles", Streams: []string{"Custom-AppText_CL"}}, + }, + DataFlows: []models.DCRDataFlow{ + { + Streams: []string{"Microsoft-Perf", "Custom-AppText_CL"}, + Destinations: []string{"migration-eventhub"}, + }, + }, + Destinations: []models.DCRDestination{ + {Name: "migration-eventhub", Type: "eventHubs", ResourceID: &eventHubID, Detail: models.StringPtr("monitoring-migration")}, + }, + Associations: []models.DCRAssociation{ + { + ID: migrationAssocID, + Name: "batch-migration-association", + TargetID: vmssTargetID, + DataCollectionRuleID: &migrationDCRID, + Description: models.StringPtr("Batch fleet AMA migration"), + }, + }, + DataSourceTypes: []string{"logFiles", "performanceCounters"}, + Streams: []string{"Custom-AppText_CL", "Microsoft-Perf"}, + DestinationTypes: []string{"eventHubs"}, + AssociationCount: 1, + RelatedIDs: []string{eventHubID, migrationAssocID, migrationDCRID, vmssTargetID}, + }, + }, + Issues: []models.Issue{}, + } + for index := range facts.DCRs { + facts.DCRs[index].Summary = dcrSummary(facts.DCRs[index]) + } + return facts, nil +} diff --git a/internal/providers/static_diagnostic_settings.go b/internal/providers/static_diagnostic_settings.go new file mode 100644 index 0000000..e4afc96 --- /dev/null +++ b/internal/providers/static_diagnostic_settings.go @@ -0,0 +1,125 @@ +package providers + +import ( + "context" + + "harrierops-azure/internal/models" +) + +func (StaticProvider) DiagnosticSettings(_ context.Context, tenant string, subscription string) (DiagnosticSettingsFacts, 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" + keyVaultID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod" + storageID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod" + appID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod" + + keyVaultSetting := models.DiagnosticSettingAsset{ + ID: keyVaultID + "/providers/Microsoft.Insights/diagnosticSettings/send-audit", + Name: "send-audit", + SourceResourceID: keyVaultID, + Destinations: []models.DiagnosticSettingsDestination{ + {Type: "logAnalytics", ResourceID: &workspaceID, Detail: models.StringPtr("Dedicated")}, + }, + Logs: []models.DiagnosticSettingsCategory{ + {Name: "AuditEvent", Type: "log", Enabled: false}, + }, + Metrics: []models.DiagnosticSettingsCategory{ + {Name: "AllMetrics", Type: "metric", Enabled: true}, + }, + EnabledCategories: []string{"AllMetrics"}, + DisabledCategories: []string{"AuditEvent"}, + CategoryGroups: []string{"AuditEvent"}, + HighSignalCategories: []string{"AuditEvent"}, + DestinationTypes: []string{"logAnalytics"}, + RelatedIDs: []string{keyVaultID, keyVaultID + "/providers/Microsoft.Insights/diagnosticSettings/send-audit", workspaceID}, + } + keyVaultSetting.Summary = diagnosticSettingSummary(keyVaultSetting) + storageSetting := models.DiagnosticSettingAsset{ + ID: storageID + "/providers/Microsoft.Insights/diagnosticSettings/archive-storage", + Name: "archive-storage", + SourceResourceID: storageID, + Destinations: []models.DiagnosticSettingsDestination{ + {Type: "eventHubs", ResourceID: &eventHubRuleID, Detail: models.StringPtr("monitoring-migration")}, + }, + Logs: []models.DiagnosticSettingsCategory{ + {Name: "StorageRead", Type: "log", Enabled: true}, + {Name: "StorageWrite", Type: "log", Enabled: true}, + }, + Metrics: []models.DiagnosticSettingsCategory{}, + EnabledCategories: []string{"StorageRead", "StorageWrite"}, + HighSignalCategories: []string{}, + DestinationTypes: []string{"eventHubs"}, + RelatedIDs: []string{eventHubRuleID, storageID, storageID + "/providers/Microsoft.Insights/diagnosticSettings/archive-storage"}, + } + storageSetting.Summary = diagnosticSettingSummary(storageSetting) + + facts := DiagnosticSettingsFacts{ + TenantID: session.TenantID, + SubscriptionID: subscriptionID, + Sources: []models.DiagnosticSettingsSource{ + { + ID: keyVaultID, + Name: "kv-prod", + Type: "Microsoft.KeyVault/vaults", + ResourceGroup: "rg-sec", + Location: "eastus", + DiagnosticSettings: []models.DiagnosticSettingAsset{keyVaultSetting}, + DiagnosticSettingCount: 1, + EnabledCategories: []string{"AllMetrics"}, + DisabledCategories: []string{"AuditEvent"}, + SupportedCategories: []string{"AllMetrics", "AuditEvent"}, + NotExportedSupported: []string{"AuditEvent"}, + SupportedCategoryCatalog: true, + CategoryGroups: []string{"AuditEvent"}, + HighSignalCategories: []string{"AuditEvent"}, + DestinationTypes: []string{"logAnalytics"}, + HasDiagnosticSettings: true, + HasPartialLogPosture: true, + HasHighSignalLogPosture: true, + HasNonWorkspaceDestination: false, + RelatedIDs: []string{keyVaultID, keyVaultSetting.ID, workspaceID}, + }, + { + ID: storageID, + Name: "stdataprod", + Type: "Microsoft.Storage/storageAccounts", + ResourceGroup: "rg-data", + Location: "eastus", + DiagnosticSettings: []models.DiagnosticSettingAsset{storageSetting}, + DiagnosticSettingCount: 1, + EnabledCategories: []string{"StorageRead", "StorageWrite"}, + SupportedCategories: []string{"StorageDelete", "StorageRead", "StorageWrite"}, + NotExportedSupported: []string{"StorageDelete"}, + SupportedCategoryCatalog: true, + DestinationTypes: []string{"eventHubs"}, + HasDiagnosticSettings: true, + HasPartialLogPosture: true, + HasHighSignalLogPosture: true, + HasNonWorkspaceDestination: true, + RelatedIDs: []string{eventHubRuleID, storageID, storageSetting.ID}, + }, + { + ID: appID, + Name: "app-prod", + Type: "Microsoft.Web/sites", + ResourceGroup: "rg-app", + Location: "eastus", + DiagnosticSettings: []models.DiagnosticSettingAsset{}, + DiagnosticSettingCount: 0, + SupportedCategories: []string{"AppServiceHTTPLogs", "AppServiceConsoleLogs"}, + NotExportedSupported: []string{"AppServiceConsoleLogs", "AppServiceHTTPLogs"}, + SupportedCategoryCatalog: true, + HasDiagnosticSettings: false, + HasHighSignalLogPosture: true, + RelatedIDs: []string{appID}, + }, + }, + Issues: []models.Issue{}, + } + for index := range facts.Sources { + facts.Sources[index].Summary = diagnosticSettingsSourceSummary(facts.Sources[index]) + } + return facts, nil +} diff --git a/internal/providers/static_logic_apps.go b/internal/providers/static_logic_apps.go index f092138..0cdf3e4 100644 --- a/internal/providers/static_logic_apps.go +++ b/internal/providers/static_logic_apps.go @@ -20,10 +20,16 @@ func (StaticProvider) LogicApps(_ context.Context, tenant string, subscription s Location: models.StringPtr(workflow.location), Platform: models.StringPtr(workflow.platform), State: models.StringPtr(workflow.state), + TriggerCount: len(workflow.triggerTypes), + ActionCount: len(workflow.downstreamActionKinds), + BranchCount: 1, TriggerTypes: workflow.triggerTypes, RecurrenceSummary: staticLogicAppRecurrenceSummaryPtr(workflow.recurrenceSummary), ExternallyCallableRequestTrigger: workflow.externallyCallableRequestTrigger, DownstreamActionKinds: workflow.downstreamActionKinds, + ConnectorReferences: staticLogicAppConnectorRefs(workflow), + ParameterNames: staticLogicAppParameterNames(workflow), + DownstreamResourceReferences: staticLogicAppResourceRefs(subscriptionID, workflow), Summary: workflow.summary, RelatedIDs: staticLogicAppRelatedIDs(subscriptionID, workflow), } diff --git a/internal/providers/static_logic_apps_fixtures.go b/internal/providers/static_logic_apps_fixtures.go index 85a7b4f..8bb5335 100644 --- a/internal/providers/static_logic_apps_fixtures.go +++ b/internal/providers/static_logic_apps_fixtures.go @@ -129,9 +129,43 @@ func staticLogicAppRelatedIDs(subscriptionID string, workflow staticLogicAppWork if workflow.identity != nil { relatedIDs = append(relatedIDs, staticLogicAppIdentityID(subscriptionID, *workflow.identity)) } + relatedIDs = append(relatedIDs, staticLogicAppResourceRefs(subscriptionID, workflow)...) return relatedIDs } +func staticLogicAppConnectorRefs(workflow staticLogicAppWorkflowFixture) []string { + switch workflow.name { + case "la-nightly-sync": + return []string{"azureblob"} + case "la-event-router": + return []string{"eventgrid"} + default: + return []string{} + } +} + +func staticLogicAppParameterNames(workflow staticLogicAppWorkflowFixture) []string { + switch workflow.name { + case "la-inbound-redeploy": + return []string{"automationAccountName", "runbookName"} + case "la-nightly-sync": + return []string{"storageAccountName"} + default: + return []string{} + } +} + +func staticLogicAppResourceRefs(subscriptionID string, workflow staticLogicAppWorkflowFixture) []string { + switch workflow.name { + case "la-inbound-redeploy": + return []string{"/subscriptions/" + subscriptionID + "/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod"} + case "la-event-router": + return []string{"/subscriptions/" + subscriptionID + "/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders"} + default: + return []string{} + } +} + func staticLogicAppOperatorSignal() string { return "Internal Logic App workload pivot; direct control visible." } diff --git a/internal/providers/static_monitoring_sinks.go b/internal/providers/static_monitoring_sinks.go new file mode 100644 index 0000000..4700338 --- /dev/null +++ b/internal/providers/static_monitoring_sinks.go @@ -0,0 +1,62 @@ +package providers + +import ( + "context" + + "harrierops-azure/internal/models" +) + +func (provider StaticProvider) MonitoringSinks(ctx context.Context, tenant string, subscription string) (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) + + sinks := []models.MonitoringSinkAsset{ + { + ID: workspaceID, + Name: "law-soc-prod", + Kind: "sentinel", + ResourceType: "Microsoft.OperationalInsights/workspaces", + ResourceGroup: "rg-monitor", + Location: "eastus", + VisibilitySource: "resource inventory", + SentinelEnabled: boolPtr(true), + RelatedIDs: []string{workspaceID}, + }, + { + ID: eventHubRuleID, + Name: "send", + Kind: "eventHubs", + ResourceType: "Microsoft.EventHub/namespaces/authorizationRules", + ResourceGroup: "rg-monitor", + Location: "eastus", + VisibilitySource: "declared destination", + RelatedIDs: []string{eventHubRuleID}, + }, + { + ID: storageID, + Name: "stdataprod", + Kind: "storage", + ResourceType: "Microsoft.Storage/storageAccounts", + ResourceGroup: "rg-data", + Location: "eastus", + VisibilitySource: "resource inventory", + RelatedIDs: []string{storageID}, + }, + } + monitoringSinksAttachDCRReferences(sinks, dcrFacts.DCRs) + monitoringSinksAttachDiagnosticReferences(sinks, diagnosticFacts.Sources) + sinks = monitoringSinksFinalize(sinks) + + return MonitoringSinksFacts{ + TenantID: session.TenantID, + SubscriptionID: subscriptionID, + Sinks: sinks, + Issues: []models.Issue{}, + }, nil +} diff --git a/internal/providers/static_relay.go b/internal/providers/static_relay.go new file mode 100644 index 0000000..3ab7048 --- /dev/null +++ b/internal/providers/static_relay.go @@ -0,0 +1,58 @@ +package providers + +import ( + "context" + + "harrierops-azure/internal/models" +) + +func (StaticProvider) Relay(_ context.Context, tenant string, subscription string) (RelayFacts, error) { + session := staticFixtureSession(tenant, subscription) + subscriptionID := session.Subscription.ID + namespaceID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod" + hybridID := namespaceID + "/hybridConnections/onprem-orders" + appServiceID := "/subscriptions/" + subscriptionID + "/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + appServiceHybridID := appServiceID + "/hybridConnectionNamespaces/relay-hybrid-prod/relays/onprem-orders" + location := "eastus" + standard := "Standard" + succeeded := "Succeeded" + endpoint := "https://relay-hybrid-prod.servicebus.windows.net:443/" + metricID := namespaceID + requiresAuth := true + listenerCount := 1 + hybridCount := 1 + authRules := 2 + + return RelayFacts{ + TenantID: session.TenantID, + SubscriptionID: subscriptionID, + Namespaces: []models.RelayNamespaceAsset{ + { + ID: namespaceID, + Name: "relay-hybrid-prod", + ResourceGroup: "rg-integration", + Location: &location, + SKUName: &standard, + ProvisioningState: &succeeded, + ServiceBusEndpoint: &endpoint, + MetricID: &metricID, + HybridConnectionCount: &hybridCount, + AuthorizationRuleCount: &authRules, + HybridConnections: []models.RelayHybridConnectionAsset{ + { + ID: hybridID, + Name: "onprem-orders", + RequiresClientAuthorization: &requiresAuth, + ListenerCount: &listenerCount, + AppServiceAttachments: []string{"app-public-api"}, + Summary: "Hybrid Connection \"onprem-orders\" is visible under relay namespace \"relay-hybrid-prod\" with App Service attachment(s): app-public-api.", + RelatedIDs: []string{hybridID, appServiceHybridID, appServiceID}, + }, + }, + Summary: "Relay namespace \"relay-hybrid-prod\" exposes 1 hybrid connection and 2 authorization rule(s), giving Azure a visible cloud rendezvous point for private-path communication.", + RelatedIDs: []string{namespaceID, hybridID, appServiceHybridID, appServiceID}, + }, + }, + Issues: []models.Issue{}, + }, nil +} diff --git a/internal/providers/static_resources.go b/internal/providers/static_resources.go index 03fb6ce..59fbb0e 100644 --- a/internal/providers/static_resources.go +++ b/internal/providers/static_resources.go @@ -1104,6 +1104,8 @@ func (StaticProvider) ApiMgmt(_ context.Context, tenant string, subscription str ActiveSubscriptionCount: &two, BackendCount: &one, BackendHostnames: []string{"orders-internal.contoso.local"}, + PolicyCount: &two, + PolicyControlTypes: []string{"backend-routing", "conditional-routing", "header-auth", "request-rewrite"}, NamedValueCount: &two, NamedValueSecretCount: &one, NamedValueKeyVaultCount: &one, @@ -1123,6 +1125,8 @@ func (StaticProvider) ApiMgmt(_ context.Context, tenant string, subscription str &one, []string{"orders-internal.contoso.local"}, &two, + []string{"backend-routing", "conditional-routing", "header-auth", "request-rewrite"}, + &two, &one, &one, &trueValue, diff --git a/internal/render/appinsights.go b/internal/render/appinsights.go new file mode 100644 index 0000000..0527767 --- /dev/null +++ b/internal/render/appinsights.go @@ -0,0 +1,65 @@ +package render + +import ( + "fmt" + + "harrierops-azure/internal/models" +) + +func appInsightsTable(payload models.AppInsightsOutput) string { + rows := make([][]string, 0, len(payload.Targets)) + for _, target := range payload.Targets { + rows = append(rows, []string{ + target.Name, + target.Kind, + joinOrNone(target.SamplingClues), + joinOrNone(target.FilteringClues), + joinOrNone(target.LoggingLevelClues), + }) + } + output := renderListTable( + "ho-azure appinsights", + []string{"target", "kind", "sampling", "filtering", "logging"}, + rows, + []string{"no instrumented app setting clues", "", "", "", ""}, + appInsightsTakeaway(payload), + ) + if len(payload.Components) > 0 { + componentRows := make([][]string, 0, len(payload.Components)) + for _, component := range payload.Components { + componentRows = append(componentRows, []string{ + component.Name, + component.ResourceGroup, + valueOrFallback(component.IngestionMode, "unknown"), + resourceNameFromIDForTable(stringPtrValue(component.WorkspaceResourceID)), + }) + } + output += "\nComponents\n" + renderAlignedPipeTable([]string{"component", "resource group", "ingestion", "workspace"}, componentRows) + } + output += "\nNot collected by default\n" + output += renderAlignedPipeTable( + []string{"item", "classification", "reason"}, + [][]string{ + {"setting values", "recon safety", "Default output uses setting names as posture clues and does not print instrumentation keys or connection strings."}, + {"code-level processors", "proof boundary", "Telemetry processor bodies usually live in source code or binaries, outside management-plane posture."}, + {"true unsampled count", "proof boundary", "Current posture cannot prove how many events were dropped or retained."}, + {"host.json body", "collector issue", "Function sampling can live in host.json; this helper only uses visible app setting names by default."}, + {"detector failure", "proof boundary", "The command does not inspect detections, so it cannot claim a rule missed activity."}, + }, + ) + return output +} + +func appInsightsTakeaway(payload models.AppInsightsOutput) string { + sampling := 0 + filtering := 0 + for _, target := range payload.Targets { + if len(target.SamplingClues) > 0 { + sampling++ + } + if len(target.FilteringClues) > 0 { + filtering++ + } + } + return fmt.Sprintf("%d component(s) and %d instrumented target(s) visible; %d target(s) show sampling clues and %d show filtering clues.", len(payload.Components), len(payload.Targets), sampling, filtering) +} diff --git a/internal/render/csv.go b/internal/render/csv.go index 5a6ddcf..8935099 100644 --- a/internal/render/csv.go +++ b/internal/render/csv.go @@ -64,6 +64,51 @@ func persistenceCSVRenderer(payload any) (string, error) { } } +func evasionCSVRenderer(payload any) (string, error) { + switch out := payload.(type) { + case models.EvasionOverviewOutput: + return evasionOverviewCSV(out) + case models.EvasionDCROutput: + return evasionDCRCSV(out) + case models.EvasionDiagnosticSettingsOutput: + return evasionDiagnosticSettingsCSV(out) + case models.EvasionAppInsightsOutput: + return evasionAppInsightsCSV(out) + default: + return "", fmt.Errorf("unexpected payload type for evasion: %T", payload) + } +} + +func resourceHijackingCSVRenderer(payload any) (string, error) { + switch out := payload.(type) { + case models.ResourceHijackingOverviewOutput: + return resourceHijackingOverviewCSV(out) + case models.ResourceHijackingAPIMOutput: + return resourceHijackingAPIMCSV(out) + case models.ResourceHijackingAutomationOutput: + return resourceHijackingAutomationCSV(out) + case models.ResourceHijackingLogicAppsOutput: + return resourceHijackingLogicAppsCSV(out) + default: + return "", fmt.Errorf("unexpected payload type for resourcehijacking: %T", payload) + } +} + +func pathMaskingCSVRenderer(payload any) (string, error) { + switch out := payload.(type) { + case models.PathMaskingOverviewOutput: + return pathMaskingOverviewCSV(out) + case models.PathMaskingAPIMOutput: + return pathMaskingAPIMCSV(out) + case models.PathMaskingLogicAppsOutput: + return pathMaskingLogicAppsCSV(out) + case models.PathMaskingRelayOutput: + return pathMaskingRelayCSV(out) + default: + return "", fmt.Errorf("unexpected payload type for pathmasking: %T", payload) + } +} + func encodeCSV(headers []string, rows [][]string) (string, error) { buffer := &bytes.Buffer{} writer := csv.NewWriter(buffer) @@ -145,6 +190,9 @@ func automationCSV(payload models.AutomationOutput) (string, error) { intPtrString(account.RunbookCount), intPtrString(account.PublishedRunbookCount), jsonStringSlice(account.PublishedRunbookNames), + jsonStringSlice(account.RunbookTypes), + jsonStringSlice(account.RunbookCommandClues), + jsonStringSlice(account.RunbookResourceClues), intPtrString(account.ScheduleCount), jsonStringSlice(account.ScheduleDefinitions), intPtrString(account.JobScheduleCount), @@ -187,6 +235,9 @@ func automationCSV(payload models.AutomationOutput) (string, error) { "runbook_count", "published_runbook_count", "published_runbook_names", + "runbook_types", + "runbook_command_clues", + "runbook_resource_clues", "schedule_count", "schedule_definitions", "job_schedule_count", @@ -215,6 +266,532 @@ func automationCSV(payload models.AutomationOutput) (string, error) { }, rows) } +func dcrCSV(payload models.DCROutput) (string, error) { + rows := make([][]string, 0, len(payload.DCRs)) + for _, dcr := range payload.DCRs { + rows = append(rows, []string{ + dcr.ID, + dcr.Name, + dcr.ResourceGroup, + dcr.Location, + valueOrEmpty(dcr.Kind), + valueOrEmpty(dcr.Description), + valueOrEmpty(dcr.DataCollectionEndpointID), + jsonStringSlice(dcr.DataSourceTypes), + jsonStringSlice(dcr.Streams), + jsonStringSlice(dcr.HighSignalStreams), + jsonStringSlice(dcr.DestinationTypes), + intString(dcr.TransformationCount), + intString(dcr.AssociationCount), + jsonValue(dcr.DataSources), + jsonValue(dcr.DataFlows), + jsonValue(dcr.Destinations), + jsonValue(dcr.Associations), + dcr.Summary, + jsonStringSlice(dcr.RelatedIDs), + }) + } + + return encodeCSV([]string{ + "id", + "name", + "resource_group", + "location", + "kind", + "description", + "data_collection_endpoint_id", + "data_source_types", + "streams", + "high_signal_streams", + "destination_types", + "transformation_count", + "association_count", + "data_sources", + "data_flows", + "destinations", + "associations", + "summary", + "related_ids", + }, rows) +} + +func diagnosticSettingsCSV(payload models.DiagnosticSettingsOutput) (string, error) { + rows := make([][]string, 0, len(payload.Sources)) + for _, source := range payload.Sources { + rows = append(rows, []string{ + source.ID, + source.Name, + source.Type, + source.ResourceGroup, + source.Location, + intString(source.DiagnosticSettingCount), + boolString(source.HasDiagnosticSettings), + boolString(source.HasPartialLogPosture), + boolString(source.HasHighSignalLogPosture), + boolString(source.HasNonWorkspaceDestination), + jsonStringSlice(source.EnabledCategories), + jsonStringSlice(source.DisabledCategories), + jsonStringSlice(source.SupportedCategories), + jsonStringSlice(source.NotExportedSupported), + boolString(source.SupportedCategoryCatalog), + jsonStringSlice(source.CategoryGroups), + jsonStringSlice(source.HighSignalCategories), + jsonStringSlice(source.DestinationTypes), + jsonValue(source.DiagnosticSettings), + source.Summary, + jsonStringSlice(source.RelatedIDs), + }) + } + + return encodeCSV([]string{ + "id", + "name", + "type", + "resource_group", + "location", + "diagnostic_setting_count", + "has_diagnostic_settings", + "has_partial_log_posture", + "has_high_signal_log_posture", + "has_non_workspace_destination", + "enabled_categories", + "disabled_categories", + "supported_categories", + "not_exported_supported_categories", + "supported_category_catalog", + "category_groups", + "high_signal_categories", + "destination_types", + "diagnostic_settings", + "summary", + "related_ids", + }, rows) +} + +func monitoringSinksCSV(payload models.MonitoringSinksOutput) (string, error) { + rows := make([][]string, 0, len(payload.Sinks)) + for _, sink := range payload.Sinks { + rows = append(rows, []string{ + sink.ID, + sink.Name, + sink.Kind, + sink.ResourceType, + sink.ResourceGroup, + sink.Location, + sink.VisibilitySource, + boolPtrString(sink.SentinelEnabled), + intString(sink.ReferenceCount), + jsonValue(sink.References), + sink.Summary, + jsonStringSlice(sink.RelatedIDs), + }) + } + + return encodeCSV([]string{ + "id", + "name", + "kind", + "resource_type", + "resource_group", + "location", + "visibility_source", + "sentinel_enabled", + "reference_count", + "references", + "summary", + "related_ids", + }, rows) +} + +func appInsightsCSV(payload models.AppInsightsOutput) (string, error) { + rows := make([][]string, 0, len(payload.Targets)) + for _, target := range payload.Targets { + rows = append(rows, []string{ + target.ID, + target.Name, + target.Kind, + target.ResourceGroup, + target.Location, + jsonStringSlice(target.InstrumentationClues), + jsonStringSlice(target.SamplingClues), + jsonStringSlice(target.FilteringClues), + jsonStringSlice(target.LoggingLevelClues), + jsonStringSlice(target.VisibleTelemetryTypes), + target.Summary, + jsonStringSlice(target.RelatedIDs), + }) + } + return encodeCSV([]string{ + "id", + "name", + "kind", + "resource_group", + "location", + "instrumentation_clues", + "sampling_clues", + "filtering_clues", + "logging_level_clues", + "visible_telemetry_types", + "summary", + "related_ids", + }, rows) +} + +func relayCSV(payload models.RelayOutput) (string, error) { + rows := make([][]string, 0, len(payload.Namespaces)) + for _, namespace := range payload.Namespaces { + rows = append(rows, []string{ + namespace.ID, + namespace.Name, + namespace.ResourceGroup, + valueOrEmpty(namespace.Location), + valueOrEmpty(namespace.SKUName), + valueOrEmpty(namespace.ProvisioningState), + valueOrEmpty(namespace.ServiceBusEndpoint), + intPtrString(namespace.HybridConnectionCount), + intPtrString(namespace.AuthorizationRuleCount), + relayHybridConnectionNames(namespace.HybridConnections), + relayListenerSummary(namespace), + relayAppServiceAttachmentNames(namespace.HybridConnections), + namespace.Summary, + jsonStringSlice(namespace.RelatedIDs), + }) + } + return encodeCSV([]string{ + "id", + "namespace", + "resource_group", + "location", + "sku_name", + "provisioning_state", + "service_bus_endpoint", + "hybrid_connection_count", + "authorization_rule_count", + "hybrid_connections", + "listeners", + "app_service_attachments", + "summary", + "related_ids", + }, rows) +} + +func relayHybridConnectionNames(connections []models.RelayHybridConnectionAsset) string { + values := make([]string, 0, len(connections)) + for _, connection := range connections { + values = append(values, connection.Name) + } + return jsonStringSlice(values) +} + +func relayAppServiceAttachmentNames(connections []models.RelayHybridConnectionAsset) string { + values := make([]string, 0, len(connections)) + for _, connection := range connections { + for _, app := range connection.AppServiceAttachments { + values = append(values, connection.Name+"->"+app) + } + } + return jsonStringSlice(values) +} + +func evasionOverviewCSV(payload models.EvasionOverviewOutput) (string, error) { + return familyOverviewCSV(payload.Surfaces) +} + +func familyOverviewCSV(surfaces []models.FamilySurfaceDescriptor) (string, error) { + return encodeCSVColumns([]csvColumn[models.FamilySurfaceDescriptor]{ + {header: "surface", value: func(surface models.FamilySurfaceDescriptor) string { return surface.Surface }}, + {header: "state", value: func(surface models.FamilySurfaceDescriptor) string { return surface.State }}, + {header: "summary", value: func(surface models.FamilySurfaceDescriptor) string { return surface.Summary }}, + {header: "operator_question", value: func(surface models.FamilySurfaceDescriptor) string { return surface.OperatorQuestion }}, + {header: "backing_commands", value: func(surface models.FamilySurfaceDescriptor) string { return jsonStringSlice(surface.BackingCommands) }}, + }, surfaces) +} + +func evasionDCRCSV(payload models.EvasionDCROutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.EvasionDCR]{ + {"id", func(dcr models.EvasionDCR) string { return dcr.ID }}, + {"dcr", func(dcr models.EvasionDCR) string { return dcr.Name }}, + {"resource_group", func(dcr models.EvasionDCR) string { return dcr.ResourceGroup }}, + {"location", func(dcr models.EvasionDCR) string { return dcr.Location }}, + {"disruption_rank", func(dcr models.EvasionDCR) string { return intString(dcr.DisruptionRank) }}, + {"disruption_reason", func(dcr models.EvasionDCR) string { return dcr.DisruptionReason }}, + {"capability_steps", func(dcr models.EvasionDCR) string { return jsonValue(dcr.CapabilitySteps) }}, + {"current_identity_summary", func(dcr models.EvasionDCR) string { + return familyRoleSummary(dcr.CurrentIdentityContext) + }}, + {"current_state", func(dcr models.EvasionDCR) string { return jsonValue(dcr.CurrentState) }}, + {"not_collected_by_default", func(dcr models.EvasionDCR) string { + return jsonValue(dcr.NotCollectedByDefault) + }}, + {"summary", func(dcr models.EvasionDCR) string { return dcr.Summary }}, + {"related_ids", func(dcr models.EvasionDCR) string { return jsonStringSlice(dcr.RelatedIDs) }}, + }, payload.DCRs) +} + +func evasionDiagnosticSettingsCSV(payload models.EvasionDiagnosticSettingsOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.EvasionDiagnosticSettingsSource]{ + {"id", func(source models.EvasionDiagnosticSettingsSource) string { return source.ID }}, + {"source", func(source models.EvasionDiagnosticSettingsSource) string { return source.Name }}, + {"resource_group", func(source models.EvasionDiagnosticSettingsSource) string { return source.ResourceGroup }}, + {"location", func(source models.EvasionDiagnosticSettingsSource) string { return source.Location }}, + {"disruption_rank", func(source models.EvasionDiagnosticSettingsSource) string { + return intString(source.DisruptionRank) + }}, + {"disruption_reason", func(source models.EvasionDiagnosticSettingsSource) string { + return source.DisruptionReason + }}, + {"capability_steps", func(source models.EvasionDiagnosticSettingsSource) string { + return jsonValue(source.CapabilitySteps) + }}, + {"current_identity_summary", func(source models.EvasionDiagnosticSettingsSource) string { + return familyRoleSummary(source.CurrentIdentityContext) + }}, + {"current_state", func(source models.EvasionDiagnosticSettingsSource) string { return jsonValue(source.CurrentState) }}, + {"not_collected_by_default", func(source models.EvasionDiagnosticSettingsSource) string { + return jsonValue(source.NotCollectedByDefault) + }}, + {"summary", func(source models.EvasionDiagnosticSettingsSource) string { return source.Summary }}, + {"related_ids", func(source models.EvasionDiagnosticSettingsSource) string { + return jsonStringSlice(source.RelatedIDs) + }}, + }, payload.Sources) +} + +func evasionAppInsightsCSV(payload models.EvasionAppInsightsOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.EvasionAppInsightsTarget]{ + {"id", func(target models.EvasionAppInsightsTarget) string { return target.ID }}, + {"target", func(target models.EvasionAppInsightsTarget) string { return target.Name }}, + {"resource_group", func(target models.EvasionAppInsightsTarget) string { return target.ResourceGroup }}, + {"location", func(target models.EvasionAppInsightsTarget) string { return target.Location }}, + {"disruption_rank", func(target models.EvasionAppInsightsTarget) string { + return intString(target.DisruptionRank) + }}, + {"disruption_reason", func(target models.EvasionAppInsightsTarget) string { return target.DisruptionReason }}, + {"capability_steps", func(target models.EvasionAppInsightsTarget) string { return jsonValue(target.CapabilitySteps) }}, + {"current_identity_summary", func(target models.EvasionAppInsightsTarget) string { + return familyRoleSummary(target.CurrentIdentityContext) + }}, + {"current_state", func(target models.EvasionAppInsightsTarget) string { return jsonValue(target.CurrentState) }}, + {"not_collected_by_default", func(target models.EvasionAppInsightsTarget) string { + return jsonValue(target.NotCollectedByDefault) + }}, + {"summary", func(target models.EvasionAppInsightsTarget) string { return target.Summary }}, + {"related_ids", func(target models.EvasionAppInsightsTarget) string { return jsonStringSlice(target.RelatedIDs) }}, + }, payload.Targets) +} + +func resourceHijackingOverviewCSV(payload models.ResourceHijackingOverviewOutput) (string, error) { + return familyOverviewCSV(payload.Surfaces) +} + +func resourceHijackingAPIMCSV(payload models.ResourceHijackingAPIMOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.ResourceHijackingAPIMTarget]{ + {"id", func(target models.ResourceHijackingAPIMTarget) string { return target.ID }}, + {"api_management_service", func(target models.ResourceHijackingAPIMTarget) string { return target.Name }}, + {"resource_group", func(target models.ResourceHijackingAPIMTarget) string { return target.ResourceGroup }}, + {"location", func(target models.ResourceHijackingAPIMTarget) string { return valueOrEmpty(target.Location) }}, + {"takeover_rank", func(target models.ResourceHijackingAPIMTarget) string { + return fmt.Sprintf("%d", target.TakeoverRank) + }}, + {"takeover_reason", func(target models.ResourceHijackingAPIMTarget) string { return target.TakeoverReason }}, + {"gateway_hostnames", func(target models.ResourceHijackingAPIMTarget) string { + return jsonStringSlice(target.CurrentState.GatewayHostnames) + }}, + {"backend_hostnames", func(target models.ResourceHijackingAPIMTarget) string { + return jsonStringSlice(target.CurrentState.BackendHostnames) + }}, + {"api_count", func(target models.ResourceHijackingAPIMTarget) string { + return intPtrString(target.CurrentState.APICount) + }}, + {"active_subscription_count", func(target models.ResourceHijackingAPIMTarget) string { + return intPtrString(target.CurrentState.ActiveSubscriptionCount) + }}, + {"current_identity", func(target models.ResourceHijackingAPIMTarget) string { + return familyRoleControlLabel(target.CurrentIdentityContext) + }}, + {"summary", func(target models.ResourceHijackingAPIMTarget) string { return target.Summary }}, + {"related_ids", func(target models.ResourceHijackingAPIMTarget) string { return jsonStringSlice(target.RelatedIDs) }}, + }, payload.Targets) +} + +func resourceHijackingAutomationCSV(payload models.ResourceHijackingAutomationOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.ResourceHijackingAutomationTarget]{ + {"id", func(target models.ResourceHijackingAutomationTarget) string { return target.ID }}, + {"automation_account", func(target models.ResourceHijackingAutomationTarget) string { return target.Name }}, + {"resource_group", func(target models.ResourceHijackingAutomationTarget) string { return target.ResourceGroup }}, + {"location", func(target models.ResourceHijackingAutomationTarget) string { return valueOrEmpty(target.Location) }}, + {"takeover_rank", func(target models.ResourceHijackingAutomationTarget) string { + return fmt.Sprintf("%d", target.TakeoverRank) + }}, + {"takeover_reason", func(target models.ResourceHijackingAutomationTarget) string { return target.TakeoverReason }}, + {"published_runbook_count", func(target models.ResourceHijackingAutomationTarget) string { + return intPtrString(target.CurrentState.PublishedRunbookCount) + }}, + {"published_runbook_names", func(target models.ResourceHijackingAutomationTarget) string { + return jsonStringSlice(target.CurrentState.PublishedRunbookNames) + }}, + {"job_schedule_count", func(target models.ResourceHijackingAutomationTarget) string { + return intPtrString(target.CurrentState.JobScheduleCount) + }}, + {"webhook_count", func(target models.ResourceHijackingAutomationTarget) string { + return intPtrString(target.CurrentState.WebhookCount) + }}, + {"hybrid_worker_group_count", func(target models.ResourceHijackingAutomationTarget) string { + return intPtrString(target.CurrentState.HybridWorkerGroupCount) + }}, + {"identity_type", func(target models.ResourceHijackingAutomationTarget) string { + return valueOrEmpty(target.CurrentState.IdentityType) + }}, + {"current_identity", func(target models.ResourceHijackingAutomationTarget) string { + return familyRoleControlLabel(target.CurrentIdentityContext) + }}, + {"summary", func(target models.ResourceHijackingAutomationTarget) string { return target.Summary }}, + {"related_ids", func(target models.ResourceHijackingAutomationTarget) string { + return jsonStringSlice(target.RelatedIDs) + }}, + }, payload.Targets) +} + +func resourceHijackingLogicAppsCSV(payload models.ResourceHijackingLogicAppsOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.ResourceHijackingLogicAppTarget]{ + {"id", func(target models.ResourceHijackingLogicAppTarget) string { return target.ID }}, + {"logic_app", func(target models.ResourceHijackingLogicAppTarget) string { return target.Name }}, + {"resource_group", func(target models.ResourceHijackingLogicAppTarget) string { return target.ResourceGroup }}, + {"location", func(target models.ResourceHijackingLogicAppTarget) string { return valueOrEmpty(target.Location) }}, + {"takeover_rank", func(target models.ResourceHijackingLogicAppTarget) string { + return fmt.Sprintf("%d", target.TakeoverRank) + }}, + {"takeover_reason", func(target models.ResourceHijackingLogicAppTarget) string { return target.TakeoverReason }}, + {"trigger_types", func(target models.ResourceHijackingLogicAppTarget) string { + return jsonStringSlice(target.CurrentState.TriggerTypes) + }}, + {"externally_callable_request_trigger", func(target models.ResourceHijackingLogicAppTarget) string { + return boolString(target.CurrentState.ExternallyCallableRequestTrigger) + }}, + {"recurrence_summary", func(target models.ResourceHijackingLogicAppTarget) string { + return valueOrEmpty(target.CurrentState.RecurrenceSummary) + }}, + {"downstream_action_kinds", func(target models.ResourceHijackingLogicAppTarget) string { + return jsonStringSlice(target.CurrentState.DownstreamActionKinds) + }}, + {"identity_type", func(target models.ResourceHijackingLogicAppTarget) string { + return valueOrEmpty(target.CurrentState.IdentityType) + }}, + {"current_identity", func(target models.ResourceHijackingLogicAppTarget) string { + return familyRoleControlLabel(target.CurrentIdentityContext) + }}, + {"summary", func(target models.ResourceHijackingLogicAppTarget) string { return target.Summary }}, + {"related_ids", func(target models.ResourceHijackingLogicAppTarget) string { + return jsonStringSlice(target.RelatedIDs) + }}, + }, payload.Targets) +} + +func pathMaskingOverviewCSV(payload models.PathMaskingOverviewOutput) (string, error) { + return familyOverviewCSV(payload.Surfaces) +} + +func pathMaskingAPIMCSV(payload models.PathMaskingAPIMOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.PathMaskingAPIMTarget]{ + {"id", func(target models.PathMaskingAPIMTarget) string { return target.ID }}, + {"api_management_service", func(target models.PathMaskingAPIMTarget) string { return target.Name }}, + {"resource_group", func(target models.PathMaskingAPIMTarget) string { return target.ResourceGroup }}, + {"location", func(target models.PathMaskingAPIMTarget) string { return valueOrEmpty(target.Location) }}, + {"masking_rank", func(target models.PathMaskingAPIMTarget) string { + return fmt.Sprintf("%d", target.MaskingRank) + }}, + {"masking_reason", func(target models.PathMaskingAPIMTarget) string { return target.MaskingReason }}, + {"gateway_hostnames", func(target models.PathMaskingAPIMTarget) string { + return jsonStringSlice(target.CurrentState.GatewayHostnames) + }}, + {"backend_hostnames", func(target models.PathMaskingAPIMTarget) string { + return jsonStringSlice(target.CurrentState.BackendHostnames) + }}, + {"api_count", func(target models.PathMaskingAPIMTarget) string { + return intPtrString(target.CurrentState.APICount) + }}, + {"subscription_count", func(target models.PathMaskingAPIMTarget) string { + return intPtrString(target.CurrentState.SubscriptionCount) + }}, + {"current_identity", func(target models.PathMaskingAPIMTarget) string { + return familyRoleControlLabel(target.CurrentIdentityContext) + }}, + {"summary", func(target models.PathMaskingAPIMTarget) string { return target.Summary }}, + {"related_ids", func(target models.PathMaskingAPIMTarget) string { return jsonStringSlice(target.RelatedIDs) }}, + }, payload.Targets) +} + +func pathMaskingLogicAppsCSV(payload models.PathMaskingLogicAppsOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.PathMaskingLogicAppTarget]{ + {"id", func(target models.PathMaskingLogicAppTarget) string { return target.ID }}, + {"logic_app", func(target models.PathMaskingLogicAppTarget) string { return target.Name }}, + {"resource_group", func(target models.PathMaskingLogicAppTarget) string { return target.ResourceGroup }}, + {"location", func(target models.PathMaskingLogicAppTarget) string { return valueOrEmpty(target.Location) }}, + {"masking_rank", func(target models.PathMaskingLogicAppTarget) string { + return fmt.Sprintf("%d", target.MaskingRank) + }}, + {"masking_reason", func(target models.PathMaskingLogicAppTarget) string { return target.MaskingReason }}, + {"trigger_types", func(target models.PathMaskingLogicAppTarget) string { + return jsonStringSlice(target.CurrentState.TriggerTypes) + }}, + {"externally_callable_request_trigger", func(target models.PathMaskingLogicAppTarget) string { + return boolString(target.CurrentState.ExternallyCallableRequestTrigger) + }}, + {"recurrence_summary", func(target models.PathMaskingLogicAppTarget) string { + return valueOrEmpty(target.CurrentState.RecurrenceSummary) + }}, + {"downstream_action_kinds", func(target models.PathMaskingLogicAppTarget) string { + return jsonStringSlice(target.CurrentState.DownstreamActionKinds) + }}, + {"identity_type", func(target models.PathMaskingLogicAppTarget) string { + return valueOrEmpty(target.CurrentState.IdentityType) + }}, + {"current_identity", func(target models.PathMaskingLogicAppTarget) string { + return familyRoleControlLabel(target.CurrentIdentityContext) + }}, + {"summary", func(target models.PathMaskingLogicAppTarget) string { return target.Summary }}, + {"related_ids", func(target models.PathMaskingLogicAppTarget) string { + return jsonStringSlice(target.RelatedIDs) + }}, + }, payload.Targets) +} + +func pathMaskingRelayCSV(payload models.PathMaskingRelayOutput) (string, error) { + return encodeCSVColumns([]csvColumn[models.PathMaskingRelayTarget]{ + {"id", func(target models.PathMaskingRelayTarget) string { return target.ID }}, + {"relay_namespace", func(target models.PathMaskingRelayTarget) string { return target.Name }}, + {"resource_group", func(target models.PathMaskingRelayTarget) string { return target.ResourceGroup }}, + {"location", func(target models.PathMaskingRelayTarget) string { return valueOrEmpty(target.Location) }}, + {"masking_rank", func(target models.PathMaskingRelayTarget) string { + return fmt.Sprintf("%d", target.MaskingRank) + }}, + {"masking_reason", func(target models.PathMaskingRelayTarget) string { return target.MaskingReason }}, + {"service_bus_endpoint", func(target models.PathMaskingRelayTarget) string { + return valueOrEmpty(target.CurrentState.ServiceBusEndpoint) + }}, + {"hybrid_connection_count", func(target models.PathMaskingRelayTarget) string { + return intPtrString(target.CurrentState.HybridConnectionCount) + }}, + {"hybrid_connection_names", func(target models.PathMaskingRelayTarget) string { + return jsonStringSlice(target.CurrentState.HybridConnectionNames) + }}, + {"authorization_rule_count", func(target models.PathMaskingRelayTarget) string { + return intPtrString(target.CurrentState.AuthorizationRuleCount) + }}, + {"listener_summary", func(target models.PathMaskingRelayTarget) string { + return target.CurrentState.ListenerSummary + }}, + {"app_service_attachments", func(target models.PathMaskingRelayTarget) string { + return jsonStringSlice(target.CurrentState.AppServiceAttachments) + }}, + {"current_identity", func(target models.PathMaskingRelayTarget) string { + return familyRoleControlLabel(target.CurrentIdentityContext) + }}, + {"summary", func(target models.PathMaskingRelayTarget) string { return target.Summary }}, + {"related_ids", func(target models.PathMaskingRelayTarget) string { return jsonStringSlice(target.RelatedIDs) }}, + }, payload.Targets) +} + func eventGridCSV(payload models.EventGridOutput) (string, error) { rows := make([][]string, 0, len(payload.Routes)) for _, route := range payload.Routes { @@ -354,10 +931,16 @@ func logicAppsCSV(payload models.LogicAppsOutput) (string, error) { valueOrEmpty(workflow.PrincipalID), valueOrEmpty(workflow.ClientID), jsonStringSlice(workflow.IdentityIDs), + intString(workflow.TriggerCount), + intString(workflow.ActionCount), + intString(workflow.BranchCount), jsonStringSlice(workflow.TriggerTypes), boolString(workflow.ExternallyCallableRequestTrigger), valueOrEmpty(workflow.RecurrenceSummary), jsonStringSlice(workflow.DownstreamActionKinds), + jsonStringSlice(workflow.ConnectorReferences), + jsonStringSlice(workflow.ParameterNames), + jsonStringSlice(workflow.DownstreamResourceReferences), workflow.Summary, jsonStringSlice(workflow.RelatedIDs), }) @@ -379,10 +962,16 @@ func logicAppsCSV(payload models.LogicAppsOutput) (string, error) { "principal_id", "client_id", "identity_ids", + "trigger_count", + "action_count", + "branch_count", "trigger_types", "externally_callable_request_trigger", "recurrence_summary", "downstream_action_kinds", + "connector_references", + "parameter_names", + "downstream_resource_references", "summary", "related_ids", }, rows) diff --git a/internal/render/csv_resources.go b/internal/render/csv_resources.go index e600f74..9a1efcd 100644 --- a/internal/render/csv_resources.go +++ b/internal/render/csv_resources.go @@ -472,6 +472,8 @@ func apiMgmtCSV(payload models.ApiMgmtOutput) (string, error) { intPtrString(service.NamedValueCount), intPtrString(service.NamedValueKeyVaultCount), intPtrString(service.NamedValueSecretCount), + intPtrString(service.PolicyCount), + jsonStringSlice(service.PolicyControlTypes), jsonStringSlice(service.PortalHostnames), valueOrEmpty(service.PublicIPAddressID), jsonStringSlice(service.PrivateIPAddresses), @@ -509,6 +511,8 @@ func apiMgmtCSV(payload models.ApiMgmtOutput) (string, error) { "named_value_count", "named_value_key_vault_count", "named_value_secret_count", + "policy_count", + "policy_control_types", "portal_hostnames", "public_ip_address_id", "private_ip_addresses", diff --git a/internal/render/diagnostic_settings.go b/internal/render/diagnostic_settings.go new file mode 100644 index 0000000..3d73994 --- /dev/null +++ b/internal/render/diagnostic_settings.go @@ -0,0 +1,88 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +func diagnosticSettingsTable(payload models.DiagnosticSettingsOutput) string { + rows := make([][]string, 0, len(payload.Sources)) + for _, source := range payload.Sources { + rows = append(rows, []string{ + source.Name, + source.Type, + diagnosticSettingsExportContext(source), + joinOrNone(source.DestinationTypes), + diagnosticSettingsCategoryContext(source), + }) + } + output := renderListTable( + "ho-azure diagnostic-settings", + []string{"source", "type", "settings", "destinations", "categories"}, + rows, + []string{"no visible resources", "", "", "", ""}, + diagnosticSettingsTakeaway(payload), + ) + output += "\nNot collected by default\n" + output += renderAlignedPipeTable( + []string{"item", "classification", "reason"}, + [][]string{ + {"unsupported category proof", "proof boundary", "When Azure rejects category catalog reads for a source type, the helper reports a collection issue instead of treating omitted categories as unsupported."}, + {"activity-log history", "API/noise", "Change timing and actor proof require history collection, which is not needed for the default posture view."}, + {"sink contents", "proof boundary", "Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture."}, + {"detector wiring", "proof boundary", "The command does not inspect Sentinel or defender rule dependencies, so it cannot claim a detection failed."}, + {"expected SOC destination baseline", "scope/sequencing", "Destination drift needs an expected sink model before the tool can call the current sink wrong."}, + }, + ) + return output +} + +func diagnosticSettingsExportContext(source models.DiagnosticSettingsSource) string { + if !source.HasDiagnosticSettings { + return "none visible" + } + return fmt.Sprintf("%d visible", source.DiagnosticSettingCount) +} + +func diagnosticSettingsCategoryContext(source models.DiagnosticSettingsSource) string { + parts := []string{} + if len(source.EnabledCategories) > 0 { + parts = append(parts, "enabled: "+strings.Join(source.EnabledCategories, ", ")) + } + if len(source.DisabledCategories) > 0 { + parts = append(parts, "not exported by visible setting: "+strings.Join(source.DisabledCategories, ", ")) + } + if len(source.NotExportedSupported) > 0 { + parts = append(parts, "supported not exported: "+strings.Join(source.NotExportedSupported, ", ")) + } + if len(source.HighSignalCategories) > 0 { + parts = append(parts, "high-signal: "+strings.Join(source.HighSignalCategories, ", ")) + } + if len(parts) == 0 { + return "none visible" + } + return strings.Join(parts, "; ") +} + +func diagnosticSettingsTakeaway(payload models.DiagnosticSettingsOutput) string { + if len(payload.Sources) == 0 { + return "no source resources were visible from the current read path." + } + withSettings := 0 + partial := 0 + nonWorkspace := 0 + for _, source := range payload.Sources { + if source.HasDiagnosticSettings { + withSettings++ + } + if source.HasPartialLogPosture { + partial++ + } + if source.HasNonWorkspaceDestination { + nonWorkspace++ + } + } + return fmt.Sprintf("%d source(s) visible; %d have diagnostic settings, %d show partial category posture, and %d route to non-Log Analytics destinations.", len(payload.Sources), withSettings, partial, nonWorkspace) +} diff --git a/internal/render/evasion.go b/internal/render/evasion.go new file mode 100644 index 0000000..92f9c35 --- /dev/null +++ b/internal/render/evasion.go @@ -0,0 +1,231 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +func evasionTableRenderer(payload any) (string, error) { + switch out := payload.(type) { + case models.EvasionOverviewOutput: + return evasionOverviewTable(out), nil + case models.EvasionDCROutput: + return evasionDCRTable(out), nil + case models.EvasionDiagnosticSettingsOutput: + return evasionDiagnosticSettingsTable(out), nil + case models.EvasionAppInsightsOutput: + return evasionAppInsightsTable(out), nil + default: + return "", fmt.Errorf("unexpected payload type for evasion: %T", payload) + } +} + +func evasionAppInsightsTable(payload models.EvasionAppInsightsOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure evasion appinsights", + EmptyHeaders: []string{"target", "status"}, + EmptyRow: []string{"No visible instrumented App Insights targets were confirmed from current scope.", ""}, + EmptyTakeaway: "0 targets visible; no Application Insights evasion surface was confirmed from current scope.", + }) + } + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure evasion appinsights", + CapabilityTitle: "Application Insights evasion capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible Application Insights truth-disruption path. The inventory below lists the other visible targets without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: evasionAppInsightsExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("Application Insights and app telemetry", "Application Insights management-plane and app-setting", "evasion", lead.CurrentIdentityContext), + InventoryTitle: "Visible Targets", + InventoryHeaders: []string{"target", "rank", "kind", "sampling", "filtering", "current identity"}, + InventoryRows: evasionAppInsightsInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func evasionOverviewTable(payload models.EvasionOverviewOutput) string { + rows := make([][]string, 0, len(payload.Surfaces)) + for _, surface := range payload.Surfaces { + rows = append(rows, []string{ + surface.Surface, + surface.Summary, + }) + } + return renderListTable( + "ho-azure evasion", + []string{"surface", "summary"}, + rows, + []string{"no evasion surfaces available", ""}, + evasionOverviewTakeaway(payload), + ) +} + +func evasionDCRTable(payload models.EvasionDCROutput) string { + if len(payload.DCRs) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure evasion dcr", + EmptyHeaders: []string{"dcr", "status"}, + EmptyRow: []string{"No visible DCRs were confirmed from current scope.", ""}, + EmptyTakeaway: "0 DCRs visible; no DCR evasion surface was confirmed from current scope.", + }) + } + lead := payload.DCRs[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure evasion dcr", + CapabilityTitle: "DCR evasion capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible DCR truth-disruption path. The inventory below lists the other visible DCRs without repeating the same narrative.", + TargetCount: len(payload.DCRs), + Explanation: evasionDCRExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("DCR and association", "DCR management-plane", "evasion", lead.CurrentIdentityContext), + InventoryTitle: "Visible DCRs", + InventoryHeaders: []string{"dcr", "rank", "streams", "destinations", "transforms", "current identity"}, + InventoryRows: evasionDCRInventoryRows(payload.DCRs), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func evasionDiagnosticSettingsTable(payload models.EvasionDiagnosticSettingsOutput) string { + if len(payload.Sources) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure evasion diagnostic-settings", + EmptyHeaders: []string{"source", "status"}, + EmptyRow: []string{"No visible diagnostic settings sources were confirmed from current scope.", ""}, + EmptyTakeaway: "0 sources visible; no diagnostic-settings evasion surface was confirmed from current scope.", + }) + } + lead := payload.Sources[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure evasion diagnostic-settings", + CapabilityTitle: "Diagnostic settings evasion capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible diagnostic-settings truth-disruption path. The inventory below lists the other visible sources without repeating the same narrative.", + TargetCount: len(payload.Sources), + Explanation: evasionDiagnosticSettingsExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("diagnostic settings", "diagnostic-settings management-plane", "evasion", lead.CurrentIdentityContext), + InventoryTitle: "Visible Sources", + InventoryHeaders: []string{"source", "rank", "type", "destinations", "not exported", "current identity"}, + InventoryRows: evasionDiagnosticSettingsInventoryRows(payload.Sources), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func evasionDCRExplanation(dcr models.EvasionDCR) string { + lines := []string{ + "", + "Operator read", + dcr.Summary, + "Current identity: " + familyRoleSummary(dcr.CurrentIdentityContext), + "Downstream effect: " + dcr.DisruptionReason, + "First boundary: this is DCR management-plane posture, not log-content proof, runtime agent proof, or downstream detector failure.", + } + if dcr.CurrentState.TransformationPosture != "" { + lines = append(lines, "Transformation posture: "+dcr.CurrentState.TransformationPosture+".") + } + if dcr.CurrentState.DestinationPosture != "" { + lines = append(lines, "Destination posture: "+dcr.CurrentState.DestinationPosture+".") + } + if len(dcr.CurrentState.AssociationTargets) > 0 { + lines = append(lines, "Association scope: "+strings.Join(shortResourceNames(dcr.CurrentState.AssociationTargets), ", ")+".") + } + return strings.Join(lines, "\n") +} + +func evasionDiagnosticSettingsExplanation(source models.EvasionDiagnosticSettingsSource) string { + lines := []string{ + "", + "Operator read", + source.Summary, + "Current identity: " + familyRoleSummary(source.CurrentIdentityContext), + "Downstream effect: " + source.DisruptionReason, + "First boundary: this is diagnostic-settings management-plane posture, not sink-content proof, history proof, or detector-failure proof.", + } + if source.CurrentState.ExportPosture != "" { + lines = append(lines, "Export posture: "+source.CurrentState.ExportPosture+".") + } + if source.CurrentState.DestinationPosture != "" { + lines = append(lines, "Destination posture: "+source.CurrentState.DestinationPosture+".") + } + return strings.Join(lines, "\n") +} + +func evasionAppInsightsExplanation(target models.EvasionAppInsightsTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.DisruptionReason, + "First boundary: this is visible Application Insights and app-setting posture, not code-body proof, runtime proof, or detector-failure proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func evasionDCRInventoryRows(dcrs []models.EvasionDCR) [][]string { + rows := make([][]string, 0, len(dcrs)) + for _, dcr := range dcrs { + rows = append(rows, []string{ + dcr.Name, + fmt.Sprintf("%d/5", dcr.DisruptionRank), + joinOrNone(dcr.CurrentState.Streams), + joinOrNone(dcr.CurrentState.DestinationTypes), + fmt.Sprintf("%d", dcr.CurrentState.TransformationCount), + familyRoleControlLabel(dcr.CurrentIdentityContext), + }) + } + return rows +} + +func evasionDiagnosticSettingsInventoryRows(sources []models.EvasionDiagnosticSettingsSource) [][]string { + rows := make([][]string, 0, len(sources)) + for _, source := range sources { + rows = append(rows, []string{ + source.Name, + fmt.Sprintf("%d/5", source.DisruptionRank), + source.CurrentState.SourceType, + joinOrNone(source.CurrentState.DestinationTypes), + joinOrNone(source.CurrentState.NotExportedCategories), + familyRoleControlLabel(source.CurrentIdentityContext), + }) + } + return rows +} + +func evasionAppInsightsInventoryRows(targets []models.EvasionAppInsightsTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.DisruptionRank), + target.CurrentState.Kind, + joinOrNone(target.CurrentState.SamplingClues), + joinOrNone(target.CurrentState.FilteringClues), + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func evasionOverviewTakeaway(payload models.EvasionOverviewOutput) string { + return fmt.Sprintf("%d evasion surface(s) available; run a surface to rank visible posture by family-specific disruption value.", len(payload.Surfaces)) +} + +func shortResourceNames(ids []string) []string { + values := make([]string, 0, len(ids)) + for _, id := range ids { + values = append(values, resourceNameFromIDForTable(id)) + } + return values +} + +func joinOrNone(values []string) string { + if len(values) == 0 { + return "none visible" + } + return strings.Join(values, ", ") +} diff --git a/internal/render/family.go b/internal/render/family.go new file mode 100644 index 0000000..fa7fbb2 --- /dev/null +++ b/internal/render/family.go @@ -0,0 +1,120 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +type familySurfaceTableConfig struct { + Title string + EmptyHeaders []string + EmptyRow []string + EmptyTakeaway string + CapabilityTitle string + CapabilitySteps []models.FamilyCapabilityStep + MultiTargetNote string + TargetCount int + Explanation string + ReducedVisibility string + InventoryTitle string + InventoryHeaders []string + InventoryRows [][]string + BoundaryNotes []models.FamilyBoundaryNote +} + +func renderFamilySurfaceTable(config familySurfaceTableConfig) string { + if config.TargetCount == 0 { + return renderListTable( + config.Title, + config.EmptyHeaders, + nil, + config.EmptyRow, + config.EmptyTakeaway, + ) + } + + lines := []string{ + config.CapabilityTitle, + "", + renderPersistenceSectionTable( + []string{"action", "api surface", "status"}, + familyCapabilityRows(config.CapabilitySteps), + ), + } + if config.TargetCount > 1 && config.MultiTargetNote != "" { + lines = append(lines, config.MultiTargetNote) + } + explanation := config.Explanation + if !familyHasActionableCapability(config.CapabilitySteps) && config.ReducedVisibility != "" { + explanation = config.ReducedVisibility + } + lines = append(lines, + explanation, + "", + config.InventoryTitle, + renderAlignedPipeTable(config.InventoryHeaders, config.InventoryRows), + "", + "Not collected by default", + familyBoundaryRows(config.BoundaryNotes), + ) + return strings.Join(lines, "\n") +} + +func familyCapabilityRows(steps []models.FamilyCapabilityStep) [][]string { + rows := make([][]string, 0, len(steps)) + for _, step := range steps { + rows = append(rows, []string{step.Action, step.APISurface, step.Status}) + } + return rows +} + +func familyBoundaryRows(notes []models.FamilyBoundaryNote) string { + if len(notes) == 0 { + return "none" + } + rows := make([][]string, 0, len(notes)) + for _, note := range notes { + rows = append(rows, []string{note.Name, note.Classification, note.Reason}) + } + return renderAlignedPipeTable([]string{"item", "classification", "reason"}, rows) +} + +func familyRoleSummary(context *models.FamilyRoleContext) string { + if context == nil { + return "current identity context not visible" + } + return context.Summary +} + +func familyRoleControlLabel(context *models.FamilyRoleContext) string { + if context == nil { + return "not visible" + } + if context.ControlLabel == "" { + return "not proven" + } + return context.ControlLabel +} + +func familyHasActionableCapability(steps []models.FamilyCapabilityStep) bool { + for _, step := range steps { + if step.CanAct { + return true + } + } + return false +} + +func familyReducedVisibilityExplanation(surface string, family string, path string, context *models.FamilyRoleContext) string { + lines := []string{ + "", + "Operator read", + fmt.Sprintf("Current identity can see %s posture, but no change-control path is proven from current evidence.", surface), + fmt.Sprintf("Higher permissions are required to use this as a %s path.", path), + "Current identity: " + familyRoleSummary(context), + fmt.Sprintf("First boundary: this is %s posture only; the walkthrough stops before operator actions that require write/control permissions.", family), + } + return strings.Join(lines, "\n") +} diff --git a/internal/render/family_reduced_visibility_test.go b/internal/render/family_reduced_visibility_test.go new file mode 100644 index 0000000..17cd813 --- /dev/null +++ b/internal/render/family_reduced_visibility_test.go @@ -0,0 +1,144 @@ +package render + +import ( + "strings" + "testing" + + "harrierops-azure/internal/models" +) + +func reducedVisibilityFamilySteps(actions ...string) []models.FamilyCapabilityStep { + steps := make([]models.FamilyCapabilityStep, 0, len(actions)) + for index, action := range actions { + status := "not proven" + if index == 0 { + status = "visible posture only" + } + steps = append(steps, models.FamilyCapabilityStep{ + Action: action, + Status: status, + CanAct: status == "yes" || status == "partial", + }) + } + return steps +} + +func reducedVisibilityRoleContext() *models.FamilyRoleContext { + return &models.FamilyRoleContext{ + ControlLabel: "not proven", + Summary: "Current foothold identity is visible, but write control is not proven here.", + } +} + +func TestFamilyTablesStopOperatorWalkthroughWhenOnlyReadVisibilityIsProven(t *testing.T) { + const marker = "FULL_WALKTHROUGH_MARKER" + tests := []struct { + name string + output string + }{ + { + name: "evasion dcr", + output: evasionDCRTable(models.EvasionDCROutput{DCRs: []models.EvasionDCR{{ + Name: "dcr-prod", + Summary: marker, + DisruptionReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("choose or create DCR", "save or re-associate rule"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "evasion diagnostic-settings", + output: evasionDiagnosticSettingsTable(models.EvasionDiagnosticSettingsOutput{Sources: []models.EvasionDiagnosticSettingsSource{{ + Name: "kv-prod", + Summary: marker, + DisruptionReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("pick source resource", "save or edit setting"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "evasion appinsights", + output: evasionAppInsightsTable(models.EvasionAppInsightsOutput{Targets: []models.EvasionAppInsightsTarget{{ + Name: "app-prod", + Summary: marker, + DisruptionReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("choose telemetry target", "configure sampling"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "resourcehijacking api-mgmt", + output: resourceHijackingAPIMTable(models.ResourceHijackingAPIMOutput{Targets: []models.ResourceHijackingAPIMTarget{{ + Name: "apim-prod", + Summary: marker, + TakeoverReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("select trusted gateway", "change backend or routing policy"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "resourcehijacking automation", + output: resourceHijackingAutomationTable(models.ResourceHijackingAutomationOutput{Targets: []models.ResourceHijackingAutomationTarget{{ + Name: "aa-prod", + Summary: marker, + TakeoverReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("select trusted automation account", "edit published runbook"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "resourcehijacking logic-apps", + output: resourceHijackingLogicAppsTable(models.ResourceHijackingLogicAppsOutput{Targets: []models.ResourceHijackingLogicAppTarget{{ + Name: "wf-prod", + Summary: marker, + TakeoverReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("select trusted workflow", "edit workflow definition"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "pathmasking api-mgmt", + output: pathMaskingAPIMTable(models.PathMaskingAPIMOutput{Targets: []models.PathMaskingAPIMTarget{{ + Name: "apim-prod", + Summary: marker, + MaskingReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("select public gateway", "apply route or transform policy"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "pathmasking logic-apps", + output: pathMaskingLogicAppsTable(models.PathMaskingLogicAppsOutput{Targets: []models.PathMaskingLogicAppTarget{{ + Name: "wf-prod", + Summary: marker, + MaskingReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("select trusted workflow", "change workflow route"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + { + name: "pathmasking relay", + output: pathMaskingRelayTable(models.PathMaskingRelayOutput{Targets: []models.PathMaskingRelayTarget{{ + Name: "relay-prod", + Summary: marker, + MaskingReason: marker, + CapabilitySteps: reducedVisibilityFamilySteps("select Relay namespace", "change namespace or connection posture"), + CurrentIdentityContext: reducedVisibilityRoleContext(), + }}}), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if !strings.Contains(test.output, "Higher permissions are required") { + t.Fatalf("expected reduced-visibility stop line, got:\n%s", test.output) + } + if strings.Contains(test.output, marker) { + t.Fatalf("expected read-only output to suppress full operator walkthrough details, got:\n%s", test.output) + } + if strings.Contains(test.output, "Downstream effect:") { + t.Fatalf("expected read-only output to stop before downstream-effect narration, got:\n%s", test.output) + } + }) + } +} diff --git a/internal/render/monitoring_sinks.go b/internal/render/monitoring_sinks.go new file mode 100644 index 0000000..e9ad9b9 --- /dev/null +++ b/internal/render/monitoring_sinks.go @@ -0,0 +1,73 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +func monitoringSinksTable(payload models.MonitoringSinksOutput) string { + rows := make([][]string, 0, len(payload.Sinks)) + for _, sink := range payload.Sinks { + rows = append(rows, []string{ + sink.Name, + sink.Kind, + sink.VisibilitySource, + fmt.Sprintf("%d", sink.ReferenceCount), + monitoringSinkSentinelText(sink), + }) + } + output := renderListTable( + "ho-azure monitoring-sinks", + []string{"sink", "kind", "visible from", "routes", "sentinel"}, + rows, + []string{"no visible monitoring sinks", "", "", "", ""}, + monitoringSinksTakeaway(payload), + ) + output += "\nNot collected by default\n" + output += renderAlignedPipeTable( + []string{"item", "classification", "reason"}, + [][]string{ + {"expected SOC baseline", "proof boundary", "Visible sinks and declared telemetry routes do not prove which sink defenders expect."}, + {"sink contents", "proof boundary", "The helper does not query Log Analytics, Storage, Event Hub, or partner sink contents."}, + {"detector wiring", "proof boundary", "Sentinel enablement is posture only; rule dependencies and alert behavior are not inspected."}, + }, + ) + return output +} + +func monitoringSinkSentinelText(sink models.MonitoringSinkAsset) string { + if sink.SentinelEnabled == nil { + return "unknown" + } + if *sink.SentinelEnabled { + return "visible" + } + return "not visible" +} + +func monitoringSinksTakeaway(payload models.MonitoringSinksOutput) string { + referenced := 0 + kinds := []string{} + for _, sink := range payload.Sinks { + if sink.ReferenceCount > 0 { + referenced++ + } + kinds = append(kinds, sink.Kind) + } + return fmt.Sprintf("%d visible or declared monitoring sink(s); %d referenced by DCR or diagnostic-settings routes; kinds: %s.", len(payload.Sinks), referenced, strings.Join(dedupeStrings(kinds), ", ")) +} + +func dedupeStrings(values []string) []string { + seen := map[string]bool{} + result := []string{} + for _, value := range values { + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} diff --git a/internal/render/path_masking.go b/internal/render/path_masking.go new file mode 100644 index 0000000..1d90114 --- /dev/null +++ b/internal/render/path_masking.go @@ -0,0 +1,203 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +func pathMaskingTableRenderer(payload any) (string, error) { + switch out := payload.(type) { + case models.PathMaskingOverviewOutput: + return pathMaskingOverviewTable(out), nil + case models.PathMaskingAPIMOutput: + return pathMaskingAPIMTable(out), nil + case models.PathMaskingLogicAppsOutput: + return pathMaskingLogicAppsTable(out), nil + case models.PathMaskingRelayOutput: + return pathMaskingRelayTable(out), nil + default: + return "", fmt.Errorf("unexpected payload type for pathmasking: %T", payload) + } +} + +func pathMaskingOverviewTable(payload models.PathMaskingOverviewOutput) string { + rows := make([][]string, 0, len(payload.Surfaces)) + for _, surface := range payload.Surfaces { + rows = append(rows, []string{surface.Surface, surface.Summary}) + } + return renderListTable( + "ho-azure pathmasking", + []string{"surface", "summary"}, + rows, + []string{"no pathmasking surfaces available", ""}, + pathMaskingOverviewTakeaway(payload), + ) +} + +func pathMaskingAPIMTable(payload models.PathMaskingAPIMOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure pathmasking api-mgmt", + EmptyHeaders: []string{"api management service", "status"}, + EmptyRow: []string{"No visible API Management services were confirmed from current scope.", ""}, + EmptyTakeaway: "0 APIM services visible; no APIM pathmasking surface was confirmed from current scope.", + }) + } + + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure pathmasking api-mgmt", + CapabilityTitle: "APIM pathmasking capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible APIM pathmasking posture. The inventory below lists the other visible services without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: pathMaskingAPIMExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("APIM gateway and backend-indirection", "APIM management-plane", "pathmasking", lead.CurrentIdentityContext), + InventoryTitle: "Visible APIM Services", + InventoryHeaders: []string{"service", "rank", "gateways", "backends", "subscriptions", "current identity"}, + InventoryRows: pathMaskingAPIMInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func pathMaskingLogicAppsTable(payload models.PathMaskingLogicAppsOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure pathmasking logic-apps", + EmptyHeaders: []string{"logic app", "status"}, + EmptyRow: []string{"No visible Logic Apps were confirmed from current scope.", ""}, + EmptyTakeaway: "0 Logic Apps visible; no Logic Apps pathmasking surface was confirmed from current scope.", + }) + } + + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure pathmasking logic-apps", + CapabilityTitle: "Logic Apps pathmasking capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible Logic App relay path. The inventory below lists the other visible workflows without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: pathMaskingLogicAppsExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("Logic App path-shaping", "Logic Apps management-plane", "pathmasking", lead.CurrentIdentityContext), + InventoryTitle: "Visible Logic Apps", + InventoryHeaders: []string{"workflow", "rank", "triggers", "actions", "identity", "current identity"}, + InventoryRows: pathMaskingLogicAppsInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func pathMaskingRelayTable(payload models.PathMaskingRelayOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure pathmasking relay", + EmptyHeaders: []string{"relay namespace", "status"}, + EmptyRow: []string{"No visible Relay namespaces were confirmed from current scope.", ""}, + EmptyTakeaway: "0 Relay namespaces visible; no Relay pathmasking surface was confirmed from current scope.", + }) + } + + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure pathmasking relay", + CapabilityTitle: "Relay pathmasking capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible Relay private-path posture. The inventory below lists the other visible namespaces without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: pathMaskingRelayExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("Relay namespace and Hybrid Connection", "Relay management-plane", "pathmasking", lead.CurrentIdentityContext), + InventoryTitle: "Visible Relay Namespaces", + InventoryHeaders: []string{"namespace", "rank", "hybrid connections", "auth rules", "listeners", "current identity"}, + InventoryRows: pathMaskingRelayInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func pathMaskingAPIMExplanation(target models.PathMaskingAPIMTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.MaskingReason, + "First boundary: this is APIM management-plane posture, not policy-body proof, live request proof, or backend ownership proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func pathMaskingLogicAppsExplanation(target models.PathMaskingLogicAppTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.MaskingReason, + "First boundary: this is Logic App management-plane posture, not run-history proof, connector payload proof, or credential-material proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func pathMaskingRelayExplanation(target models.PathMaskingRelayTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.MaskingReason, + "First boundary: this is Relay management-plane posture, not listener-runtime proof, backend process proof, or traffic-content proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func pathMaskingAPIMInventoryRows(targets []models.PathMaskingAPIMTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.MaskingRank), + joinOrNone(target.CurrentState.GatewayHostnames), + joinOrNone(target.CurrentState.BackendHostnames), + intPtrString(target.CurrentState.SubscriptionCount), + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func pathMaskingLogicAppsInventoryRows(targets []models.PathMaskingLogicAppTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.MaskingRank), + joinOrNone(target.CurrentState.TriggerTypes), + joinOrNone(target.CurrentState.DownstreamActionKinds), + valueOrEmpty(target.CurrentState.IdentityType), + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func pathMaskingRelayInventoryRows(targets []models.PathMaskingRelayTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.MaskingRank), + intPtrString(target.CurrentState.HybridConnectionCount), + intPtrString(target.CurrentState.AuthorizationRuleCount), + target.CurrentState.ListenerSummary, + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func pathMaskingOverviewTakeaway(payload models.PathMaskingOverviewOutput) string { + return fmt.Sprintf("%d pathmasking surface(s) available; run a surface to rank visible posture by path ambiguity and attribution-blur value.", len(payload.Surfaces)) +} diff --git a/internal/render/path_masking_table_test.go b/internal/render/path_masking_table_test.go new file mode 100644 index 0000000..73f18bb --- /dev/null +++ b/internal/render/path_masking_table_test.go @@ -0,0 +1,21 @@ +package render + +import ( + "strings" + "testing" + + "harrierops-azure/internal/models" +) + +func TestPathMaskingRelayTableUsesSurfaceNarration(t *testing.T) { + output, err := Table("pathmasking", models.PathMaskingRelayOutput{}, models.RenderContext{}) + if err != nil { + t.Fatalf("Table(pathmasking relay) returned error: %v", err) + } + if !strings.Contains(output, "Relay path masking means Azure exposes the cloud rendezvous point") { + t.Fatalf("expected Relay-specific pathmasking narration, got:\n%s", output) + } + if strings.Contains(output, "Walking the current identity through Azure-native relay, proxy, and workflow surfaces") { + t.Fatalf("expected surface narration instead of generic pathmasking narration, got:\n%s", output) + } +} diff --git a/internal/render/payload_sections.go b/internal/render/payload_sections.go index 05b2039..966bc60 100644 --- a/internal/render/payload_sections.go +++ b/internal/render/payload_sections.go @@ -10,6 +10,8 @@ func payloadFindings(payload any) []models.Finding { return cloneFindings(out.Findings) case models.ApiMgmtOutput: return cloneFindings(out.Findings) + case models.AppInsightsOutput: + return cloneFindings(out.Findings) case models.AppServicesOutput: return cloneFindings(out.Findings) case models.ApplicationGatewayOutput: @@ -30,6 +32,8 @@ func payloadFindings(payload any) []models.Finding { return cloneFindings(out.Findings) case models.DatabasesOutput: return cloneFindings(out.Findings) + case models.DiagnosticSettingsOutput: + return cloneFindings(out.Findings) case models.DevopsOutput: return cloneFindings(out.Findings) case models.DnsOutput: @@ -56,6 +60,8 @@ func payloadFindings(payload any) []models.Finding { return cloneFindings(out.Findings) case models.ResourceTrustsOutput: return resourceTrustFindings(out.Findings) + case models.RelayOutput: + return cloneFindings(out.Findings) case models.SnapshotsDisksOutput: return cloneFindings(out.Findings) case models.StorageOutput: @@ -83,6 +89,8 @@ func payloadIssues(payload any) []models.Issue { return cloneIssues(out.Issues) case models.ApiMgmtOutput: return cloneIssues(out.Issues) + case models.AppInsightsOutput: + return cloneIssues(out.Issues) case models.AppCredentialsOutput: return cloneIssues(out.Issues) case models.AppServicesOutput: @@ -107,8 +115,18 @@ func payloadIssues(payload any) []models.Issue { return cloneIssues(out.Issues) case models.CrossTenantOutput: return cloneIssues(out.Issues) + case models.EvasionDCROutput: + return cloneIssues(out.Issues) + case models.EvasionDiagnosticSettingsOutput: + return cloneIssues(out.Issues) + case models.EvasionAppInsightsOutput: + return cloneIssues(out.Issues) + case models.EvasionOverviewOutput: + return cloneIssues(out.Issues) case models.DatabasesOutput: return cloneIssues(out.Issues) + case models.DiagnosticSettingsOutput: + return cloneIssues(out.Issues) case models.DevopsOutput: return cloneIssues(out.Issues) case models.DnsOutput: @@ -155,14 +173,32 @@ func payloadIssues(payload any) []models.Issue { return cloneIssues(out.Issues) case models.PersistenceOverviewOutput: return cloneIssues(out.Issues) + case models.PathMaskingAPIMOutput: + return cloneIssues(out.Issues) + case models.PathMaskingLogicAppsOutput: + return cloneIssues(out.Issues) + case models.PathMaskingRelayOutput: + return cloneIssues(out.Issues) + case models.PathMaskingOverviewOutput: + return cloneIssues(out.Issues) case models.PrincipalsOutput: return cloneIssues(out.Issues) case models.PrivescOutput: return cloneIssues(out.Issues) case models.RbacOutput: return cloneIssues(out.Issues) + case models.ResourceHijackingAPIMOutput: + return cloneIssues(out.Issues) + case models.ResourceHijackingAutomationOutput: + return cloneIssues(out.Issues) + case models.ResourceHijackingLogicAppsOutput: + return cloneIssues(out.Issues) + case models.ResourceHijackingOverviewOutput: + return cloneIssues(out.Issues) case models.ResourceTrustsOutput: return cloneIssues(out.Issues) + case models.RelayOutput: + return cloneIssues(out.Issues) case models.RoleTrustsOutput: return cloneIssues(out.Issues) case models.SnapshotsDisksOutput: diff --git a/internal/render/persistence.go b/internal/render/persistence.go index 91e4870..18283cd 100644 --- a/internal/render/persistence.go +++ b/internal/render/persistence.go @@ -429,7 +429,7 @@ func renderAlignedPipeTable(headers []string, rows [][]string) string { } rendered = append(rendered, strings.Join(parts, " | ")) } - return strings.Join(rendered, "\n") + return trimTrailingLineSpaces(strings.Join(rendered, "\n")) } var builder strings.Builder @@ -461,21 +461,25 @@ func persistenceAutomationExplanation(account models.PersistenceAutomationAccoun if persistenceCapabilityStatus(account.CapabilitySteps, "create or modify account") != "yes" { return persistenceTruncatedWalkthrough(lines, []string{" " + persistenceAutomationVisibilityLine(account)}, account.CurrentState.NearbyThematicNames) } + lines = append(lines, " The Automation account is the Azure-side container for runbooks, schedules, webhooks, identity, and secure assets; no VM or host login is required to keep this path in Azure.") lines = append(lines, "- "+persistenceAutomationRunbookBullet(account)) if persistenceCapabilityStatus(account.CapabilitySteps, "add or edit runbook") != "yes" { return persistenceTruncatedWalkthrough(lines, []string{" " + persistenceAutomationVisibilityLine(account)}, account.CurrentState.NearbyThematicNames) } + lines = append(lines, " A runbook is the stored container first; it becomes useful execution only after content is added and a published version exists.") lines = append(lines, "- "+persistenceAutomationCodeBullet(account)) if persistenceCapabilityStatus(account.CapabilitySteps, "upload or replace code") != "yes" { return persistenceTruncatedWalkthrough(lines, []string{" " + persistenceAutomationVisibilityLine(account)}, account.CurrentState.NearbyThematicNames) } + lines = append(lines, " This is the runnable content layer: PowerShell or Python runbook content can call Azure APIs, reach storage or Key Vault, make outbound calls, or drive host actions through control-plane paths.") lines = append(lines, "- "+persistenceAutomationPublishBullet(account)) if persistenceCapabilityStatus(account.CapabilitySteps, "publish runbook") != "yes" { return persistenceTruncatedWalkthrough(lines, []string{" " + persistenceAutomationVisibilityLine(account)}, account.CurrentState.NearbyThematicNames) } + lines = append(lines, " Automation keeps draft and published runbook versions; publishing is the step that makes the stored content runnable in Azure.") lines = append(lines, "- "+persistenceAutomationExecutionContextBullet(account)) if persistenceCapabilityStatus(account.CapabilitySteps, "attach or reuse exec ctx") != "yes" { @@ -498,7 +502,9 @@ func persistenceAutomationExplanation(account models.PersistenceAutomationAccoun if len(account.CurrentState.ScheduleDefinitions) > 0 { lines = append(lines, " Visible schedule definitions here include "+persistenceAutomationScheduleDefinitionSummary(account.CurrentState.ScheduleDefinitions)+".") } + lines = append(lines, " Schedules, job schedules, webhooks, or upstream services such as Logic Apps and Functions are the durable rerun anchors; a runbook without one is stored code but not a complete persistence path.") lines = append(lines, "- "+persistenceAutomationRepurposeBullet(account)) + lines = append(lines, " When triggered, Azure spins up a worker, loads the published runbook, executes under the selected identity or credential context, and then stops; persistence is the code, identity, and trigger remaining configured.") if nearby := persistenceAutomationNearbyNamesLine(account.CurrentState.NearbyThematicNames); nearby != "" { lines = append(lines, " "+nearby) } @@ -617,31 +623,97 @@ func persistenceCapabilityStatus(steps []models.PersistenceCapabilityStep, actio } func persistenceLogicAppExplanation(workflow models.PersistenceLogicAppWorkflow) string { - lines := []string{ - "- " + persistenceLogicAppWorkflowBullet(workflow), - "- " + persistenceLogicAppDefinitionBullet(workflow), - "- " + persistenceLogicAppExecutionContextBullet(workflow), - " Managed identity or connector-backed actions may provide that execution context.", - " In Logic Apps, the payload is the stored workflow definition and action chain Azure will execute later.", + visibilityLines := persistenceLogicAppVisibilityLines(workflow) + lines := []string{"- " + persistenceLogicAppWorkflowBullet(workflow)} + if persistenceCapabilityStatus(workflow.CapabilitySteps, "create or modify workflow") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workflow.CurrentState.NearbyThematicNames) + } + lines = append(lines, " A Logic App is a workflow resource stored in Azure: the trigger starts it, and the actions decide what it does next.") + + lines = append(lines, "- "+persistenceLogicAppDefinitionBullet(workflow)) + if persistenceCapabilityStatus(workflow.CapabilitySteps, "edit workflow definition") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workflow.CurrentState.NearbyThematicNames) + } + lines = append(lines, " In Logic Apps, the payload is the stored workflow definition and action chain Azure will execute later.") + lines = append(lines, " Consumption-style workflows are managed directly from the workflow definition; Standard Logic Apps behave more like a host with workflows, app settings, and package or deployment paths inside it.") + + lines = append(lines, "- "+persistenceLogicAppExecutionContextBullet(workflow)) + if persistenceCapabilityStatus(workflow.CapabilitySteps, "attach or reuse exec ctx") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workflow.CurrentState.NearbyThematicNames) } + lines = append(lines, " Managed identity or connector-backed actions may provide that execution context.") + lines = append(lines, " That identity or connection is the power layer: it determines which Azure services, secrets, storage paths, external endpoints, or other automation the workflow can reach.") if workflow.CurrentIdentityContext != nil && strings.TrimSpace(workflow.CurrentIdentityContext.Summary) != "" { lines = append(lines, " "+workflow.CurrentIdentityContext.Summary) } if ctx := workflow.CurrentState.StrongestVisibleExecutionContext; ctx != nil && strings.TrimSpace(ctx.Summary) != "" { lines = append(lines, " "+ctx.Summary) } - lines = append(lines, - "- "+persistenceLogicAppTriggerBullet(workflow), - "- "+persistenceLogicAppEnableBullet(workflow), - "- "+persistenceLogicAppActionBullet(workflow), - "- "+persistenceLogicAppRepurposeBullet(workflow), - ) + + lines = append(lines, "- "+persistenceLogicAppTriggerBullet(workflow)) + if persistenceCapabilityStatus(workflow.CapabilitySteps, "define or modify trigger") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workflow.CurrentState.NearbyThematicNames) + } + lines = append(lines, persistenceLogicAppTriggerWalkthrough(workflow)...) + + lines = append(lines, "- "+persistenceLogicAppEnableBullet(workflow)) + if persistenceCapabilityStatus(workflow.CapabilitySteps, "enable workflow") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workflow.CurrentState.NearbyThematicNames) + } + lines = append(lines, " Once saved and enabled, Azure listens for the trigger and starts the workflow when the trigger fires; no user needs to stay logged in.") + + lines = append(lines, "- "+persistenceLogicAppActionBullet(workflow)) + if persistenceCapabilityStatus(workflow.CapabilitySteps, "add or repurpose downstream actions") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workflow.CurrentState.NearbyThematicNames) + } + lines = append(lines, persistenceLogicAppActionWalkthrough(workflow)...) + + lines = append(lines, "- "+persistenceLogicAppRepurposeBullet(workflow)) + lines = append(lines, " Persistence here is the stored workflow, reachable trigger, and valid identity or connector context remaining in Azure so the path can be reused later.") if nearby := persistenceAutomationNearbyNamesLine(workflow.CurrentState.NearbyThematicNames); nearby != "" { lines = append(lines, " "+nearby) } return renderPersistenceWalkthrough(lines) } +func persistenceLogicAppTriggerWalkthrough(workflow models.PersistenceLogicAppWorkflow) []string { + lines := []string{} + if len(workflow.CurrentState.TriggerTypes) > 0 { + lines = append(lines, " Visible trigger types here include "+persistenceJoinedOrNone(workflow.CurrentState.TriggerTypes)+".") + } + if workflow.CurrentState.ExternallyCallableRequestTrigger { + lines = append(lines, " The visible request trigger makes this workflow externally callable if the callback URL or caller path is usable; this command does not print trigger secret material.") + } + if recurrence := strings.TrimSpace(valueOrEmpty(workflow.CurrentState.RecurrenceSummary)); recurrence != "" { + lines = append(lines, " Visible recurrence posture here is "+recurrence+".") + } + if len(lines) == 0 { + lines = append(lines, " Trigger posture is the re-entry anchor: HTTP request, schedule, connector, or event triggers decide how this workflow can run again later.") + } + return lines +} + +func persistenceLogicAppActionWalkthrough(workflow models.PersistenceLogicAppWorkflow) []string { + lines := []string{ + " Logic Apps do not need a traditional script to be useful; the action graph is the execution logic.", + } + if len(workflow.CurrentState.DownstreamActionKinds) > 0 { + lines = append(lines, " Visible downstream action kinds here include "+persistenceJoinedOrNone(workflow.CurrentState.DownstreamActionKinds)+".") + } + lines = append(lines, " Actions can call Azure APIs, send HTTP requests, read or write storage, invoke other automation, or branch through connector-backed workflows when those mechanics are present.") + return lines +} + +func persistenceLogicAppVisibilityLines(workflow models.PersistenceLogicAppWorkflow) []string { + return persistenceVisibilityFallbackLines( + strings.TrimSpace(persistenceLogicAppInventoryState(workflow)), + strings.TrimSpace(persistenceLogicAppInventoryExecutionContext(workflow)), + "this workflow already has trigger posture, downstream action shape, or reuse value if stronger control is obtained later.", + "this workflow is worth revisiting if stronger control is obtained later.", + " Visibility still confirms this Logic App exists, even though the current identity does not yet have a proven write path here.", + ) +} + func persistenceFunctionsExplanation(app models.PersistenceFunctionApp) string { lines := []string{"- " + persistenceFunctionsHostBullet(app)} if persistenceCapabilityStatus(app.CapabilitySteps, "create or modify function app") != "yes" { @@ -786,6 +858,7 @@ func persistenceTruncatedWalkthrough(lines []string, visibilityLines []string, n } lines = append(lines, line) } + lines = append(lines, " Higher permissions are required to complete the remaining persistence steps for this path.") if nearby := persistenceAutomationNearbyNamesLine(nearbyNames); nearby != "" { lines = append(lines, " "+nearby) } @@ -875,6 +948,7 @@ func persistenceFunctionsCodeWalkthrough(app models.PersistenceFunctionApp) []st lines := []string{ " Because the current identity already controls this Function App, zip deploy, publish, or package replacement are part of the defended Functions persistence path here.", " Common deploy paths here include ZIP package deployment, pipeline deployment, run-from-package, or local project publish.", + " The Function App can exist without meaningful deployed logic; the package or project is the runnable payload Azure loads when a trigger fires.", } if deployment := strings.TrimSpace(valueOrEmpty(app.CurrentState.Deployment)); deployment != "" { for _, item := range strings.Split(deployment, ";") { @@ -945,7 +1019,7 @@ func persistenceFunctionsTriggerWalkthrough(app models.PersistenceFunctionApp) [ } } lines = append(lines, " The remaining gap is data-plane and runtime-side validation the current management-plane collector does not perform.") - lines = append(lines, " That includes function keys or caller auth actually in hand, upstream Service Bus or storage access, and any runtime-side restriction beyond the visible trigger metadata.") + lines = append(lines, " That includes function keys or caller auth actually in hand, upstream Service Bus, queue, storage, or binding access, and any runtime-side restriction beyond the visible trigger metadata.") return lines } @@ -2854,44 +2928,48 @@ func persistenceFunctionsBoundarySections(apps []models.PersistenceFunctionApp) } func persistenceAzureMLExplanation(workspace models.PersistenceAzureMLWorkspace) string { - lines := []string{} - add := func(bullet string, detail []string) bool { - if bullet == "" { - return false - } - lines = append(lines, bullet) - lines = append(lines, detail...) - return true + visibilityLines := []string{" " + persistenceAzureMLVisibilityLine(workspace)} + lines := []string{persistenceAzureMLWorkspaceBullet(workspace)} + if persistenceCapabilityStatus(workspace.CapabilitySteps, "create or modify workspace") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } + lines = append(lines, persistenceAzureMLWorkspaceWalkthrough(workspace)...) - if !add(persistenceAzureMLWorkspaceBullet(workspace), persistenceAzureMLWorkspaceWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) - } - if !add(persistenceAzureMLComputeBullet(workspace), persistenceAzureMLComputeWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) + lines = append(lines, persistenceAzureMLComputeBullet(workspace)) + if persistenceCapabilityStatus(workspace.CapabilitySteps, "attach or reuse compute") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } - if !add(persistenceAzureMLCodeBullet(workspace), persistenceAzureMLCodeWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) + lines = append(lines, persistenceAzureMLComputeWalkthrough(workspace)...) + + lines = append(lines, persistenceAzureMLCodeBullet(workspace)) + if persistenceCapabilityStatus(workspace.CapabilitySteps, "add or modify jobs or pipelines") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } - if !add(persistenceAzureMLExecutionContextBullet(workspace), persistenceAzureMLExecutionContextWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) + lines = append(lines, persistenceAzureMLCodeWalkthrough(workspace)...) + + lines = append(lines, persistenceAzureMLExecutionContextBullet(workspace)) + if persistenceCapabilityStatus(workspace.CapabilitySteps, "attach or reuse exec ctx") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } - if !add(persistenceAzureMLScheduleBullet(workspace), persistenceAzureMLScheduleWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) + lines = append(lines, persistenceAzureMLExecutionContextWalkthrough(workspace)...) + + lines = append(lines, persistenceAzureMLScheduleBullet(workspace)) + if persistenceCapabilityStatus(workspace.CapabilitySteps, "create or modify schedule") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } - if !add(persistenceAzureMLEndpointBullet(workspace), persistenceAzureMLEndpointWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) + lines = append(lines, persistenceAzureMLScheduleWalkthrough(workspace)...) + + lines = append(lines, persistenceAzureMLEndpointBullet(workspace)) + if persistenceCapabilityStatus(workspace.CapabilitySteps, "expose or reuse endpoint") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } - if !add(persistenceAzureMLRepurposeBullet(workspace), persistenceAzureMLRepurposeWalkthrough(workspace)) { - lines = append(lines, persistenceAzureMLVisibilityLine(workspace)) - return renderPersistenceWalkthrough(lines) + lines = append(lines, persistenceAzureMLEndpointWalkthrough(workspace)...) + + lines = append(lines, persistenceAzureMLRepurposeBullet(workspace)) + if persistenceCapabilityStatus(workspace.CapabilitySteps, "create or modify workspace") != "yes" { + return persistenceTruncatedWalkthrough(lines, visibilityLines, workspace.CurrentState.NearbyThematicNames) } + lines = append(lines, persistenceAzureMLRepurposeWalkthrough(workspace)...) if nearby := persistenceAutomationNearbyNamesLine(workspace.CurrentState.NearbyThematicNames); nearby != "" { lines = append(lines, " "+nearby) } @@ -2948,6 +3026,7 @@ func persistenceAzureMLCodeBullet(workspace models.PersistenceAzureMLWorkspace) func persistenceAzureMLCodeWalkthrough(workspace models.PersistenceAzureMLWorkspace) []string { detail := []string{ " In Azure ML, persistence can live in saved notebooks, jobs, pipelines, scheduled jobs, and environment definitions.", + " Notebooks are interactive code surfaces, while jobs and pipelines are the scheduled or triggered execution surfaces.", " Those are the stored execution surfaces that can remain in the workspace even when no host is persistently compromised.", } return detail diff --git a/internal/render/persistence_table_test.go b/internal/render/persistence_table_test.go index 11dc19b..74c6235 100644 --- a/internal/render/persistence_table_test.go +++ b/internal/render/persistence_table_test.go @@ -60,6 +60,26 @@ var ( "attach or reuse exec ctx", "expose or reuse endpoint", } + containerAppsJobStepActions = []string{ + "create or reuse job in environment", + "point job at image or command", + "choose trigger mode", + "set execution shape and access posture", + "deploy or update stored job definition", + "start or rely on later executions", + "preserve or reuse execution path", + } + vmExtensionStepActions = []string{ + "modify VM extension configuration", + "reuse VM or VMSS target", + "add or modify extension attachment", + "provide script or command source", + "configure extension execution", + "deliver config to VM agent", + "hand off extension execution to VM agent", + "update extension later", + "preserve control-plane execution path", + } ) func capabilitySteps(actions []string, defaultStatus string, overrides map[string]string) []models.PersistenceCapabilityStep { @@ -104,6 +124,21 @@ func TestPersistenceAutomationTableUsesSingleWalkthroughForMultipleAccounts(t *t if !strings.Contains(output, "aa-one") || !strings.Contains(output, "aa-two") { t.Fatalf("expected both Automation accounts in compact inventory, got:\n%s", output) } + if !strings.Contains(output, "The Automation account is the Azure-side container for runbooks, schedules, webhooks, identity, and secure assets; no VM or host login is required to keep this path in Azure.") { + t.Fatalf("expected Automation walkthrough to carry account-container framing, got:\n%s", output) + } + if !strings.Contains(output, "A runbook is the stored container first; it becomes useful execution only after content is added and a published version exists.") { + t.Fatalf("expected Automation walkthrough to carry runbook stored-object framing, got:\n%s", output) + } + if !strings.Contains(output, "Automation keeps draft and published runbook versions; publishing is the step that makes the stored content runnable in Azure.") { + t.Fatalf("expected Automation walkthrough to carry draft/published boundary, got:\n%s", output) + } + if !strings.Contains(output, "Schedules, job schedules, webhooks, or upstream services such as Logic Apps and Functions are the durable rerun anchors; a runbook without one is stored code but not a complete persistence path.") { + t.Fatalf("expected Automation walkthrough to carry durable trigger framing, got:\n%s", output) + } + if !strings.Contains(output, "When triggered, Azure spins up a worker, loads the published runbook, executes under the selected identity or credential context, and then stops; persistence is the code, identity, and trigger remaining configured.") { + t.Fatalf("expected Automation walkthrough to carry runbook execution lifecycle, got:\n%s", output) + } } func TestPersistenceAppServiceTableUsesSingleWalkthroughForMultipleApps(t *testing.T) { @@ -314,6 +349,24 @@ func TestPersistenceLogicAppsTableUsesSingleWalkthroughForMultipleWorkflows(t *t if !strings.Contains(output, "Nearby maintenance- or schedule-themed names visible from the current environment include `nightly-sync` and `maintenance-router`.") { t.Fatalf("expected nearby thematic Logic App names line, got:\n%s", output) } + if !strings.Contains(output, "A Logic App is a workflow resource stored in Azure: the trigger starts it, and the actions decide what it does next.") { + t.Fatalf("expected Logic Apps walkthrough to carry workflow-resource framing, got:\n%s", output) + } + if !strings.Contains(output, "Consumption-style workflows are managed directly from the workflow definition; Standard Logic Apps behave more like a host with workflows, app settings, and package or deployment paths inside it.") { + t.Fatalf("expected Logic Apps walkthrough to carry Consumption vs Standard boundary, got:\n%s", output) + } + if !strings.Contains(output, "That identity or connection is the power layer: it determines which Azure services, secrets, storage paths, external endpoints, or other automation the workflow can reach.") { + t.Fatalf("expected Logic Apps walkthrough to carry identity/connector power framing, got:\n%s", output) + } + if !strings.Contains(output, "The visible request trigger makes this workflow externally callable if the callback URL or caller path is usable; this command does not print trigger secret material.") { + t.Fatalf("expected Logic Apps walkthrough to carry request-trigger boundary, got:\n%s", output) + } + if !strings.Contains(output, "Logic Apps do not need a traditional script to be useful; the action graph is the execution logic.") { + t.Fatalf("expected Logic Apps walkthrough to carry action-graph execution framing, got:\n%s", output) + } + if !strings.Contains(output, "Persistence here is the stored workflow, reachable trigger, and valid identity or connector context remaining in Azure so the path can be reused later.") { + t.Fatalf("expected Logic Apps walkthrough to carry persistence closeout framing, got:\n%s", output) + } } func TestPersistenceAutomationTableCarriesVisibilityWhenControlNotProven(t *testing.T) { @@ -415,6 +468,9 @@ func TestPersistenceFunctionsTableStopsWalkthroughAtFirstBrokenStep(t *testing.T if !strings.Contains(output, "Because the current identity already controls this Function App, zip deploy, publish, or package replacement are part of the defended Functions persistence path here.") { t.Fatalf("expected Functions walkthrough to explain the defended deploy path on its own line, got:\n%s", output) } + if !strings.Contains(output, "The Function App can exist without meaningful deployed logic; the package or project is the runnable payload Azure loads when a trigger fires.") { + t.Fatalf("expected Functions walkthrough to explain package/project payload boundary, got:\n%s", output) + } if !strings.Contains(output, "Visible deployment posture includes storage=plain-text.") { t.Fatalf("expected Functions walkthrough to split deployment posture into follow-on lines, got:\n%s", output) } @@ -439,6 +495,9 @@ func TestPersistenceFunctionsTableStopsWalkthroughAtFirstBrokenStep(t *testing.T if !strings.Contains(output, "The remaining gap is data-plane and runtime-side validation the current management-plane collector does not perform.") { t.Fatalf("expected Functions walkthrough to explain the management-plane boundary, got:\n%s", output) } + if !strings.Contains(output, "That includes function keys or caller auth actually in hand, upstream Service Bus, queue, storage, or binding access, and any runtime-side restriction beyond the visible trigger metadata.") { + t.Fatalf("expected Functions walkthrough to carry binding-access runtime boundary, got:\n%s", output) + } if !strings.Contains(output, "Current identity does not yet have a proven path to attach or reuse execution context for this Function App.") { t.Fatalf("expected Functions walkthrough to show the first broken step, got:\n%s", output) } @@ -481,6 +540,142 @@ func TestPersistenceAutomationTableStopsWalkthroughAtFirstBrokenStep(t *testing. } } +func TestPersistenceLogicAppsTableStopsWhenOnlyReadVisibilityIsProven(t *testing.T) { + output := persistenceLogicAppsTable(models.PersistenceLogicAppsOutput{ + Workflows: []models.PersistenceLogicAppWorkflow{ + { + Name: "wf-prod", + ResourceGroup: "rg-prod", + CapabilitySteps: capabilitySteps(logicAppStepActions, "not proven", nil), + CurrentState: models.PersistenceLogicAppWorkflowState{ + Classification: "request-triggered", + TriggerTypes: []string{"Request"}, + }, + }, + }, + }) + + if !strings.Contains(output, "Current identity does not yet have a proven path to create a new Logic App or modify this existing workflow.") { + t.Fatalf("expected Logic Apps walkthrough to show the first unproven capability, got:\n%s", output) + } + if !strings.Contains(output, "Higher permissions are required to complete the remaining persistence steps for this path.") { + t.Fatalf("expected Logic Apps walkthrough to include the reduced-visibility stop line, got:\n%s", output) + } + if strings.Contains(output, "Current identity can change the stored workflow definition Azure will execute here.") { + t.Fatalf("expected Logic Apps walkthrough to stop before later write actions, got:\n%s", output) + } + if strings.Contains(output, "Current identity can define or modify request, recurrence, or event trigger posture for this Logic App.") { + t.Fatalf("expected Logic Apps walkthrough to stop before trigger actions, got:\n%s", output) + } +} + +func TestPersistenceAzureMLTableStopsWhenOnlyReadVisibilityIsProven(t *testing.T) { + output := persistenceAzureMLTable(models.PersistenceAzureMLOutput{ + Workspaces: []models.PersistenceAzureMLWorkspace{ + { + Name: "ml-prod", + ResourceGroup: "rg-ml", + CapabilitySteps: capabilitySteps(azureMLStepActions, "not proven", nil), + ExecutionContextOptions: []string{"managed identity"}, + CurrentState: models.PersistenceAzureMLWorkspaceState{ + Classification: "execution-capable", + ComputeCount: intPtr(1), + }, + }, + }, + }) + + if !strings.Contains(output, "Current identity does not have a proven path to create or modify this Azure ML workspace from current RBAC evidence.") { + t.Fatalf("expected Azure ML walkthrough to show the first unproven capability, got:\n%s", output) + } + if !strings.Contains(output, "Higher permissions are required to complete the remaining persistence steps for this path.") { + t.Fatalf("expected Azure ML walkthrough to include the reduced-visibility stop line, got:\n%s", output) + } + if strings.Contains(output, "Current identity can attach or reuse Azure ML compute for this workspace") { + t.Fatalf("expected Azure ML walkthrough to stop before compute actions, got:\n%s", output) + } + if strings.Contains(output, "In Azure ML, persistence can live in saved notebooks") { + t.Fatalf("expected Azure ML walkthrough to stop before stored-code details, got:\n%s", output) + } +} + +func TestPersistenceWebJobsTableStopsWhenOnlyReadVisibilityIsProven(t *testing.T) { + output := persistenceWebJobsTable(models.PersistenceWebJobsOutput{ + WebJobs: []models.PersistenceWebJob{ + { + Name: "nightly-sync", + ResourceGroup: "rg-app", + CapabilitySteps: capabilitySteps(webJobStepActions, "not proven", nil), + CurrentState: models.PersistenceWebJobState{ + Mode: "continuous", + ParentAppName: "app-prod", + }, + }, + }, + }) + + if !strings.Contains(output, "Current identity does not yet have a proven path to create or reuse the parent App Service host that carries this WebJob.") { + t.Fatalf("expected WebJobs walkthrough to show the first unproven capability, got:\n%s", output) + } + if !strings.Contains(output, "Higher permissions are required to complete the remaining persistence steps for this path.") { + t.Fatalf("expected WebJobs walkthrough to include the reduced-visibility stop line, got:\n%s", output) + } + if strings.Contains(output, "Current identity can add or replace the WebJob package that the App Service will run.") { + t.Fatalf("expected WebJobs walkthrough to stop before package actions, got:\n%s", output) + } +} + +func TestPersistenceContainerAppsJobsTableStopsWhenOnlyReadVisibilityIsProven(t *testing.T) { + output := persistenceContainerAppsJobsTable(models.PersistenceContainerAppsJobsOutput{ + ContainerAppsJobs: []models.PersistenceContainerAppsJob{ + { + Name: "job-sync", + ResourceGroup: "rg-app", + CapabilitySteps: capabilitySteps(containerAppsJobStepActions, "not proven", nil), + CurrentState: models.PersistenceContainerAppsJobState{ + TriggerType: models.StringPtr("Schedule"), + }, + }, + }, + }) + + if !strings.Contains(output, "Current identity does not yet have a proven path to create a new Container Apps job or reuse this existing job definition in its environment.") { + t.Fatalf("expected Container Apps Jobs walkthrough to show the first unproven capability, got:\n%s", output) + } + if !strings.Contains(output, "Higher permissions are required to complete the remaining persistence steps for this path.") { + t.Fatalf("expected Container Apps Jobs walkthrough to include the reduced-visibility stop line, got:\n%s", output) + } + if strings.Contains(output, "Current identity can point this Container Apps Job at an image or command Azure will execute later.") { + t.Fatalf("expected Container Apps Jobs walkthrough to stop before image or command actions, got:\n%s", output) + } +} + +func TestPersistenceVMExtensionsTableStopsWhenOnlyReadVisibilityIsProven(t *testing.T) { + output := persistenceVMExtensionsTable(models.PersistenceVMExtensionsOutput{ + VMExtensions: []models.PersistenceVMExtension{ + { + Name: "CustomScriptExtension", + ResourceGroup: "rg-vm", + CapabilitySteps: capabilitySteps(vmExtensionStepActions, "not proven", nil), + CurrentState: models.PersistenceVMExtensionState{ + TargetKind: "vm", + TargetName: "vm-prod", + }, + }, + }, + }) + + if !strings.Contains(output, "Current identity does not yet have a proven path to modify VM extension configuration on this VM or VMSS.") { + t.Fatalf("expected VM Extensions walkthrough to show the first unproven capability, got:\n%s", output) + } + if !strings.Contains(output, "Higher permissions are required to complete the remaining persistence steps for this path.") { + t.Fatalf("expected VM Extensions walkthrough to include the reduced-visibility stop line, got:\n%s", output) + } + if strings.Contains(output, "Current identity can reuse this VM or VMSS target as the execution host for the extension.") { + t.Fatalf("expected VM Extensions walkthrough to stop before target reuse actions, got:\n%s", output) + } +} + func TestPersistenceAzureMLTableUsesComputeAndResolvedIdentityTruth(t *testing.T) { output := persistenceAzureMLTable(models.PersistenceAzureMLOutput{ Workspaces: []models.PersistenceAzureMLWorkspace{ @@ -518,6 +713,9 @@ func TestPersistenceAzureMLTableUsesComputeAndResolvedIdentityTruth(t *testing.T if !strings.Contains(output, "In Azure ML, persistence can live in saved notebooks, jobs, pipelines, scheduled jobs, and environment definitions.") { t.Fatalf("expected Azure ML walkthrough to mention stored execution logic locations, got:\n%s", output) } + if !strings.Contains(output, "Notebooks are interactive code surfaces, while jobs and pipelines are the scheduled or triggered execution surfaces.") { + t.Fatalf("expected Azure ML walkthrough to distinguish interactive and scheduled execution surfaces, got:\n%s", output) + } if !strings.Contains(output, "When a notebook, job, or pipeline runs later, it executes with the attached identity plus the linked workspace resources Azure ML will use at runtime.") { t.Fatalf("expected Azure ML walkthrough to explain re-triggered execution flow, got:\n%s", output) } diff --git a/internal/render/registry.go b/internal/render/registry.go index b0aaa4f..484f34c 100644 --- a/internal/render/registry.go +++ b/internal/render/registry.go @@ -27,101 +27,67 @@ func wrapCSVRenderer[T any](command string, render func(T) (string, error)) func } } +var renderRegistry = map[string]rendererEntry{ + "acr": {table: wrapTableRenderer("acr", acrTable), csv: wrapCSVRenderer("acr", acrCSV)}, + "aks": {table: wrapTableRenderer("aks", aksTable), csv: wrapCSVRenderer("aks", aksCSV)}, + "api-mgmt": {table: wrapTableRenderer("api-mgmt", apiMgmtTable), csv: wrapCSVRenderer("api-mgmt", apiMgmtCSV)}, + "app-credentials": {table: wrapTableRenderer("app-credentials", appCredentialsTable), csv: wrapCSVRenderer("app-credentials", appCredentialsCSV)}, + "app-services": {table: wrapTableRenderer("app-services", appServicesTable), csv: wrapCSVRenderer("app-services", appServicesCSV)}, + "appinsights": {table: wrapTableRenderer("appinsights", appInsightsTable), csv: wrapCSVRenderer("appinsights", appInsightsCSV)}, + "application-gateway": {table: wrapTableRenderer("application-gateway", applicationGatewayTable), csv: wrapCSVRenderer("application-gateway", applicationGatewayCSV)}, + "arm-deployments": {table: wrapTableRenderer("arm-deployments", armDeploymentsTable), csv: wrapCSVRenderer("arm-deployments", armDeploymentsCSV)}, + "auth-policies": {table: wrapTableRenderer("auth-policies", authPoliciesTable), csv: wrapCSVRenderer("auth-policies", authPoliciesCSV)}, + "automation": {table: wrapTableRenderer("automation", automationTable), csv: wrapCSVRenderer("automation", automationCSV)}, + "azure-ml": {table: wrapTableRenderer("azure-ml", azureMLTable), csv: wrapCSVRenderer("azure-ml", azureMLCSV)}, + "chains": {table: chainsTableRenderer, csv: chainsCSVRenderer}, + "container-apps": {table: wrapTableRenderer("container-apps", containerAppsTable), csv: wrapCSVRenderer("container-apps", containerAppsCSV)}, + "container-apps-jobs": {table: wrapTableRenderer("container-apps-jobs", containerAppsJobsTable), csv: wrapCSVRenderer("container-apps-jobs", containerAppsJobsCSV)}, + "container-instances": {table: wrapTableRenderer("container-instances", containerInstancesTable), csv: wrapCSVRenderer("container-instances", containerInstancesCSV)}, + "cross-tenant": {table: wrapTableRenderer("cross-tenant", crossTenantTable), csv: wrapCSVRenderer("cross-tenant", crossTenantCSV)}, + "databases": {table: wrapTableRenderer("databases", databasesTable), csv: wrapCSVRenderer("databases", databasesCSV)}, + "dcr": {table: wrapTableRenderer("dcr", dcrTable), csv: wrapCSVRenderer("dcr", dcrCSV)}, + "devops": {table: wrapTableRenderer("devops", devopsTable), csv: wrapCSVRenderer("devops", devopsCSV)}, + "diagnostic-settings": {table: wrapTableRenderer("diagnostic-settings", diagnosticSettingsTable), csv: wrapCSVRenderer("diagnostic-settings", diagnosticSettingsCSV)}, + "dns": {table: wrapTableRenderer("dns", dnsTable), csv: wrapCSVRenderer("dns", dnsCSV)}, + "endpoints": {table: wrapTableRenderer("endpoints", endpointsTable), csv: wrapCSVRenderer("endpoints", endpointsCSV)}, + "env-vars": {table: wrapTableRenderer("env-vars", envVarsTable), csv: wrapCSVRenderer("env-vars", envVarsCSV)}, + "event-grid": {table: wrapTableRenderer("event-grid", eventGridTable), csv: wrapCSVRenderer("event-grid", eventGridCSV)}, + "evasion": {table: evasionTableRenderer, csv: evasionCSVRenderer}, + "functions": {table: wrapTableRenderer("functions", functionsTable), csv: wrapCSVRenderer("functions", functionsCSV)}, + "inventory": {table: wrapTableRenderer("inventory", inventoryTable), csv: wrapCSVRenderer("inventory", inventoryCSV)}, + "keyvault": {table: wrapTableRenderer("keyvault", keyVaultTable), csv: wrapCSVRenderer("keyvault", keyVaultCSV)}, + "lighthouse": {table: wrapTableRenderer("lighthouse", lighthouseTable), csv: wrapCSVRenderer("lighthouse", lighthouseCSV)}, + "logic-apps": {table: wrapTableRenderer("logic-apps", logicAppsTable), csv: wrapCSVRenderer("logic-apps", logicAppsCSV)}, + "managed-identities": {table: wrapTableRenderer("managed-identities", managedIdentitiesTable), csv: wrapCSVRenderer("managed-identities", managedIdentitiesCSV)}, + "monitoring-sinks": {table: wrapTableRenderer("monitoring-sinks", monitoringSinksTable), csv: wrapCSVRenderer("monitoring-sinks", monitoringSinksCSV)}, + "network-effective": {table: wrapTableRenderer("network-effective", networkEffectiveTable), csv: wrapCSVRenderer("network-effective", networkEffectiveCSV)}, + "network-ports": {table: wrapTableRenderer("network-ports", networkPortsTable), csv: wrapCSVRenderer("network-ports", networkPortsCSV)}, + "nics": {table: wrapTableRenderer("nics", nicsTable), csv: wrapCSVRenderer("nics", nicsCSV)}, + "pathmasking": {table: pathMaskingTableRenderer, csv: pathMaskingCSVRenderer}, + "permissions": {table: wrapTableRenderer("permissions", permissionsTable), csv: wrapCSVRenderer("permissions", permissionsCSV)}, + "persistence": {table: persistenceTableRenderer, csv: persistenceCSVRenderer}, + "principals": {table: wrapTableRenderer("principals", principalsTable), csv: wrapCSVRenderer("principals", principalsCSV)}, + "privesc": {table: wrapTableRenderer("privesc", privescTable), csv: wrapCSVRenderer("privesc", privescCSV)}, + "rbac": {table: wrapTableRenderer("rbac", rbacTable), csv: wrapCSVRenderer("rbac", rbacCSV)}, + "relay": {table: wrapTableRenderer("relay", relayTable), csv: wrapCSVRenderer("relay", relayCSV)}, + "resource-trusts": {table: wrapTableRenderer("resource-trusts", resourceTrustsTable), csv: wrapCSVRenderer("resource-trusts", resourceTrustsCSV)}, + "resourcehijacking": {table: resourceHijackingTableRenderer, csv: resourceHijackingCSVRenderer}, + "role-trusts": {table: wrapTableRenderer("role-trusts", roleTrustsTable), csv: wrapCSVRenderer("role-trusts", roleTrustsCSV)}, + "snapshots-disks": {table: wrapTableRenderer("snapshots-disks", snapshotsDisksTable), csv: wrapCSVRenderer("snapshots-disks", snapshotsDisksCSV)}, + "storage": {table: wrapTableRenderer("storage", storageTable), csv: wrapCSVRenderer("storage", storageCSV)}, + "tokens-credentials": {table: wrapTableRenderer("tokens-credentials", tokensCredentialsTable), csv: wrapCSVRenderer("tokens-credentials", tokensCredentialsCSV)}, + "vm-extensions": {table: wrapTableRenderer("vm-extensions", vmExtensionsTable), csv: wrapCSVRenderer("vm-extensions", vmExtensionsCSV)}, + "vms": {table: wrapTableRenderer("vms", vmsTable), csv: wrapCSVRenderer("vms", vmsCSV)}, + "vmss": {table: wrapTableRenderer("vmss", vmssTable), csv: wrapCSVRenderer("vmss", vmssCSV)}, + "webjobs": {table: wrapTableRenderer("webjobs", webJobsTable), csv: wrapCSVRenderer("webjobs", webJobsCSV)}, + "whoami": {table: wrapTableRenderer("whoami", whoAmITable), csv: wrapCSVRenderer("whoami", whoAmICSV)}, + "workloads": {table: wrapTableRenderer("workloads", workloadsTable), csv: wrapCSVRenderer("workloads", workloadsCSV)}, +} + func renderRegistryEntry(command string) (rendererEntry, error) { - switch command { - case "automation": - return rendererEntry{table: wrapTableRenderer("automation", automationTable), csv: wrapCSVRenderer("automation", automationCSV)}, nil - case "devops": - return rendererEntry{table: wrapTableRenderer("devops", devopsTable), csv: wrapCSVRenderer("devops", devopsCSV)}, nil - case "acr": - return rendererEntry{table: wrapTableRenderer("acr", acrTable), csv: wrapCSVRenderer("acr", acrCSV)}, nil - case "databases": - return rendererEntry{table: wrapTableRenderer("databases", databasesTable), csv: wrapCSVRenderer("databases", databasesCSV)}, nil - case "storage": - return rendererEntry{table: wrapTableRenderer("storage", storageTable), csv: wrapCSVRenderer("storage", storageCSV)}, nil - case "snapshots-disks": - return rendererEntry{table: wrapTableRenderer("snapshots-disks", snapshotsDisksTable), csv: wrapCSVRenderer("snapshots-disks", snapshotsDisksCSV)}, nil - case "keyvault": - return rendererEntry{table: wrapTableRenderer("keyvault", keyVaultTable), csv: wrapCSVRenderer("keyvault", keyVaultCSV)}, nil - case "application-gateway": - return rendererEntry{table: wrapTableRenderer("application-gateway", applicationGatewayTable), csv: wrapCSVRenderer("application-gateway", applicationGatewayCSV)}, nil - case "dns": - return rendererEntry{table: wrapTableRenderer("dns", dnsTable), csv: wrapCSVRenderer("dns", dnsCSV)}, nil - case "aks": - return rendererEntry{table: wrapTableRenderer("aks", aksTable), csv: wrapCSVRenderer("aks", aksCSV)}, nil - case "api-mgmt": - return rendererEntry{table: wrapTableRenderer("api-mgmt", apiMgmtTable), csv: wrapCSVRenderer("api-mgmt", apiMgmtCSV)}, nil - case "app-credentials": - return rendererEntry{table: wrapTableRenderer("app-credentials", appCredentialsTable), csv: wrapCSVRenderer("app-credentials", appCredentialsCSV)}, nil - case "app-services": - return rendererEntry{table: wrapTableRenderer("app-services", appServicesTable), csv: wrapCSVRenderer("app-services", appServicesCSV)}, nil - case "functions": - return rendererEntry{table: wrapTableRenderer("functions", functionsTable), csv: wrapCSVRenderer("functions", functionsCSV)}, nil - case "webjobs": - return rendererEntry{table: wrapTableRenderer("webjobs", webJobsTable), csv: wrapCSVRenderer("webjobs", webJobsCSV)}, nil - case "azure-ml": - return rendererEntry{table: wrapTableRenderer("azure-ml", azureMLTable), csv: wrapCSVRenderer("azure-ml", azureMLCSV)}, nil - case "event-grid": - return rendererEntry{table: wrapTableRenderer("event-grid", eventGridTable), csv: wrapCSVRenderer("event-grid", eventGridCSV)}, nil - case "logic-apps": - return rendererEntry{table: wrapTableRenderer("logic-apps", logicAppsTable), csv: wrapCSVRenderer("logic-apps", logicAppsCSV)}, nil - case "container-apps": - return rendererEntry{table: wrapTableRenderer("container-apps", containerAppsTable), csv: wrapCSVRenderer("container-apps", containerAppsCSV)}, nil - case "container-apps-jobs": - return rendererEntry{table: wrapTableRenderer("container-apps-jobs", containerAppsJobsTable), csv: wrapCSVRenderer("container-apps-jobs", containerAppsJobsCSV)}, nil - case "container-instances": - return rendererEntry{table: wrapTableRenderer("container-instances", containerInstancesTable), csv: wrapCSVRenderer("container-instances", containerInstancesCSV)}, nil - case "arm-deployments": - return rendererEntry{table: wrapTableRenderer("arm-deployments", armDeploymentsTable), csv: wrapCSVRenderer("arm-deployments", armDeploymentsCSV)}, nil - case "endpoints": - return rendererEntry{table: wrapTableRenderer("endpoints", endpointsTable), csv: wrapCSVRenderer("endpoints", endpointsCSV)}, nil - case "network-ports": - return rendererEntry{table: wrapTableRenderer("network-ports", networkPortsTable), csv: wrapCSVRenderer("network-ports", networkPortsCSV)}, nil - case "network-effective": - return rendererEntry{table: wrapTableRenderer("network-effective", networkEffectiveTable), csv: wrapCSVRenderer("network-effective", networkEffectiveCSV)}, nil - case "nics": - return rendererEntry{table: wrapTableRenderer("nics", nicsTable), csv: wrapCSVRenderer("nics", nicsCSV)}, nil - case "vms": - return rendererEntry{table: wrapTableRenderer("vms", vmsTable), csv: wrapCSVRenderer("vms", vmsCSV)}, nil - case "vm-extensions": - return rendererEntry{table: wrapTableRenderer("vm-extensions", vmExtensionsTable), csv: wrapCSVRenderer("vm-extensions", vmExtensionsCSV)}, nil - case "vmss": - return rendererEntry{table: wrapTableRenderer("vmss", vmssTable), csv: wrapCSVRenderer("vmss", vmssCSV)}, nil - case "workloads": - return rendererEntry{table: wrapTableRenderer("workloads", workloadsTable), csv: wrapCSVRenderer("workloads", workloadsCSV)}, nil - case "principals": - return rendererEntry{table: wrapTableRenderer("principals", principalsTable), csv: wrapCSVRenderer("principals", principalsCSV)}, nil - case "permissions": - return rendererEntry{table: wrapTableRenderer("permissions", permissionsTable), csv: wrapCSVRenderer("permissions", permissionsCSV)}, nil - case "privesc": - return rendererEntry{table: wrapTableRenderer("privesc", privescTable), csv: wrapCSVRenderer("privesc", privescCSV)}, nil - case "lighthouse": - return rendererEntry{table: wrapTableRenderer("lighthouse", lighthouseTable), csv: wrapCSVRenderer("lighthouse", lighthouseCSV)}, nil - case "cross-tenant": - return rendererEntry{table: wrapTableRenderer("cross-tenant", crossTenantTable), csv: wrapCSVRenderer("cross-tenant", crossTenantCSV)}, nil - case "auth-policies": - return rendererEntry{table: wrapTableRenderer("auth-policies", authPoliciesTable), csv: wrapCSVRenderer("auth-policies", authPoliciesCSV)}, nil - case "resource-trusts": - return rendererEntry{table: wrapTableRenderer("resource-trusts", resourceTrustsTable), csv: wrapCSVRenderer("resource-trusts", resourceTrustsCSV)}, nil - case "rbac": - return rendererEntry{table: wrapTableRenderer("rbac", rbacTable), csv: wrapCSVRenderer("rbac", rbacCSV)}, nil - case "managed-identities": - return rendererEntry{table: wrapTableRenderer("managed-identities", managedIdentitiesTable), csv: wrapCSVRenderer("managed-identities", managedIdentitiesCSV)}, nil - case "env-vars": - return rendererEntry{table: wrapTableRenderer("env-vars", envVarsTable), csv: wrapCSVRenderer("env-vars", envVarsCSV)}, nil - case "tokens-credentials": - return rendererEntry{table: wrapTableRenderer("tokens-credentials", tokensCredentialsTable), csv: wrapCSVRenderer("tokens-credentials", tokensCredentialsCSV)}, nil - case "chains": - return rendererEntry{table: chainsTableRenderer, csv: chainsCSVRenderer}, nil - case "persistence": - return rendererEntry{table: persistenceTableRenderer, csv: persistenceCSVRenderer}, nil - case "role-trusts": - return rendererEntry{table: wrapTableRenderer("role-trusts", roleTrustsTable), csv: wrapCSVRenderer("role-trusts", roleTrustsCSV)}, nil - case "inventory": - return rendererEntry{table: wrapTableRenderer("inventory", inventoryTable), csv: wrapCSVRenderer("inventory", inventoryCSV)}, nil - case "whoami": - return rendererEntry{table: wrapTableRenderer("whoami", whoAmITable), csv: wrapCSVRenderer("whoami", whoAmICSV)}, nil - default: + entry, ok := renderRegistry[command] + if !ok { return rendererEntry{}, fmt.Errorf("rendering is not implemented for command %q", command) } + return entry, nil } diff --git a/internal/render/relay.go b/internal/render/relay.go new file mode 100644 index 0000000..e445b8b --- /dev/null +++ b/internal/render/relay.go @@ -0,0 +1,71 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +func relayTable(payload models.RelayOutput) string { + rows := make([][]string, 0, len(payload.Namespaces)) + for _, namespace := range payload.Namespaces { + rows = append(rows, []string{ + namespace.Name, + namespace.ResourceGroup, + intPtrString(namespace.HybridConnectionCount), + intPtrString(namespace.AuthorizationRuleCount), + relayListenerSummary(namespace), + relayAppServiceAttachmentSummary(namespace), + valueOrEmpty(namespace.ServiceBusEndpoint), + }) + } + output := renderListTable( + "ho-azure relay", + []string{"namespace", "resource group", "hybrid connections", "auth rules", "listeners", "app attachments", "endpoint"}, + rows, + []string{"No Relay namespaces were visible from current scope.", "", "", "", "", "", ""}, + relayTakeaway(payload), + ) + output += "\nNot collected by default:\n" + output += "- authorization keys: recon safety; authorization rules are counted, but key material is not retrieved or printed\n" + output += "- listener runtime state: proof boundary; listener counts do not prove a current listener process, host, or session\n" + output += "- backend process and traffic contents: proof boundary; Relay posture does not identify the private backend process or inspect traffic payloads\n" + return output +} + +func relayListenerSummary(namespace models.RelayNamespaceAsset) string { + total := 0 + known := false + for _, connection := range namespace.HybridConnections { + if connection.ListenerCount == nil { + continue + } + known = true + total += *connection.ListenerCount + } + if !known { + return "unknown" + } + return fmt.Sprintf("%d", total) +} + +func relayAppServiceAttachmentSummary(namespace models.RelayNamespaceAsset) string { + values := []string{} + for _, connection := range namespace.HybridConnections { + for _, app := range connection.AppServiceAttachments { + values = append(values, connection.Name+"->"+app) + } + } + if len(values) == 0 { + return "none visible" + } + return strings.Join(values, "; ") +} + +func relayTakeaway(payload models.RelayOutput) string { + if len(payload.Namespaces) == 0 { + return "0 Relay namespaces visible; no Azure Relay pathmasking helper surface was confirmed from current scope." + } + return fmt.Sprintf("%d Relay namespace(s) visible; review Hybrid Connections for cloud rendezvous and private-path masking posture.", len(payload.Namespaces)) +} diff --git a/internal/render/resource_hijacking.go b/internal/render/resource_hijacking.go new file mode 100644 index 0000000..09943f6 --- /dev/null +++ b/internal/render/resource_hijacking.go @@ -0,0 +1,203 @@ +package render + +import ( + "fmt" + "strings" + + "harrierops-azure/internal/models" +) + +func resourceHijackingTableRenderer(payload any) (string, error) { + switch out := payload.(type) { + case models.ResourceHijackingOverviewOutput: + return resourceHijackingOverviewTable(out), nil + case models.ResourceHijackingAPIMOutput: + return resourceHijackingAPIMTable(out), nil + case models.ResourceHijackingAutomationOutput: + return resourceHijackingAutomationTable(out), nil + case models.ResourceHijackingLogicAppsOutput: + return resourceHijackingLogicAppsTable(out), nil + default: + return "", fmt.Errorf("unexpected payload type for resourcehijacking: %T", payload) + } +} + +func resourceHijackingAutomationTable(payload models.ResourceHijackingAutomationOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure resourcehijacking automation", + EmptyHeaders: []string{"automation account", "status"}, + EmptyRow: []string{"No visible Automation accounts were confirmed from current scope.", ""}, + EmptyTakeaway: "0 Automation accounts visible; no Automation resourcehijacking surface was confirmed from current scope.", + }) + } + + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure resourcehijacking automation", + CapabilityTitle: "Automation resourcehijacking capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible Automation takeover path. The inventory below lists the other visible accounts without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: resourceHijackingAutomationExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("Automation account", "Automation management-plane", "resourcehijacking", lead.CurrentIdentityContext), + InventoryTitle: "Visible Automation Accounts", + InventoryHeaders: []string{"account", "rank", "runbooks", "job schedules", "webhooks", "current identity"}, + InventoryRows: resourceHijackingAutomationInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func resourceHijackingLogicAppsTable(payload models.ResourceHijackingLogicAppsOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure resourcehijacking logic-apps", + EmptyHeaders: []string{"logic app", "status"}, + EmptyRow: []string{"No visible Logic Apps were confirmed from current scope.", ""}, + EmptyTakeaway: "0 Logic Apps visible; no Logic Apps resourcehijacking surface was confirmed from current scope.", + }) + } + + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure resourcehijacking logic-apps", + CapabilityTitle: "Logic Apps resourcehijacking capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible Logic App takeover path. The inventory below lists the other visible workflows without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: resourceHijackingLogicAppsExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("Logic App workflow", "Logic Apps management-plane", "resourcehijacking", lead.CurrentIdentityContext), + InventoryTitle: "Visible Logic Apps", + InventoryHeaders: []string{"workflow", "rank", "triggers", "actions", "identity", "current identity"}, + InventoryRows: resourceHijackingLogicAppsInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func resourceHijackingOverviewTable(payload models.ResourceHijackingOverviewOutput) string { + rows := make([][]string, 0, len(payload.Surfaces)) + for _, surface := range payload.Surfaces { + rows = append(rows, []string{surface.Surface, surface.Summary}) + } + return renderListTable( + "ho-azure resourcehijacking", + []string{"surface", "summary"}, + rows, + []string{"no resourcehijacking surfaces available", ""}, + resourceHijackingOverviewTakeaway(payload), + ) +} + +func resourceHijackingAPIMTable(payload models.ResourceHijackingAPIMOutput) string { + if len(payload.Targets) == 0 { + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure resourcehijacking api-mgmt", + EmptyHeaders: []string{"api management service", "status"}, + EmptyRow: []string{"No visible API Management services were confirmed from current scope.", ""}, + EmptyTakeaway: "0 APIM services visible; no APIM resourcehijacking surface was confirmed from current scope.", + }) + } + + lead := payload.Targets[0] + return renderFamilySurfaceTable(familySurfaceTableConfig{ + Title: "ho-azure resourcehijacking api-mgmt", + CapabilityTitle: "APIM resourcehijacking capability", + CapabilitySteps: lead.CapabilitySteps, + MultiTargetNote: "This walkthrough shows the strongest currently visible APIM takeover path. The inventory below lists the other visible services without repeating the same narrative.", + TargetCount: len(payload.Targets), + Explanation: resourceHijackingAPIMExplanation(lead), + ReducedVisibility: familyReducedVisibilityExplanation("APIM service", "APIM management-plane", "resourcehijacking", lead.CurrentIdentityContext), + InventoryTitle: "Visible APIM Services", + InventoryHeaders: []string{"service", "rank", "gateways", "backends", "active subscriptions", "current identity"}, + InventoryRows: resourceHijackingAPIMInventoryRows(payload.Targets), + BoundaryNotes: lead.NotCollectedByDefault, + }) +} + +func resourceHijackingAPIMExplanation(target models.ResourceHijackingAPIMTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.TakeoverReason, + "First boundary: this is APIM management-plane posture, not policy-body proof, live traffic proof, or backend ownership proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func resourceHijackingAutomationExplanation(target models.ResourceHijackingAutomationTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.TakeoverReason, + "First boundary: this is Automation management-plane posture, not runbook script proof, job-output proof, or hybrid worker host proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func resourceHijackingLogicAppsExplanation(target models.ResourceHijackingLogicAppTarget) string { + lines := []string{ + "", + "Operator read", + target.Summary, + "Current identity: " + familyRoleSummary(target.CurrentIdentityContext), + "Downstream effect: " + target.TakeoverReason, + "First boundary: this is Logic App management-plane posture, not run-history proof, connector data proof, or secret-material proof.", + "Posture: " + target.CurrentState.Posture + ".", + } + return strings.Join(lines, "\n") +} + +func resourceHijackingAPIMInventoryRows(targets []models.ResourceHijackingAPIMTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.TakeoverRank), + joinOrNone(target.CurrentState.GatewayHostnames), + joinOrNone(target.CurrentState.BackendHostnames), + intPtrString(target.CurrentState.ActiveSubscriptionCount), + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func resourceHijackingAutomationInventoryRows(targets []models.ResourceHijackingAutomationTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.TakeoverRank), + intPtrString(target.CurrentState.PublishedRunbookCount), + intPtrString(target.CurrentState.JobScheduleCount), + intPtrString(target.CurrentState.WebhookCount), + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func resourceHijackingLogicAppsInventoryRows(targets []models.ResourceHijackingLogicAppTarget) [][]string { + rows := make([][]string, 0, len(targets)) + for _, target := range targets { + rows = append(rows, []string{ + target.Name, + fmt.Sprintf("%d/5", target.TakeoverRank), + joinOrNone(target.CurrentState.TriggerTypes), + joinOrNone(target.CurrentState.DownstreamActionKinds), + valueOrEmpty(target.CurrentState.IdentityType), + familyRoleControlLabel(target.CurrentIdentityContext), + }) + } + return rows +} + +func resourceHijackingOverviewTakeaway(payload models.ResourceHijackingOverviewOutput) string { + return fmt.Sprintf("%d resourcehijacking surface(s) available; run a surface to rank visible posture by takeover value.", len(payload.Surfaces)) +} diff --git a/internal/render/table.go b/internal/render/table.go index 3c50e3d..8838fda 100644 --- a/internal/render/table.go +++ b/internal/render/table.go @@ -13,72 +13,6 @@ import ( const findingNoteWrapWidth = 100 -var commandNarration = map[string]string{ - "whoami": "Checking caller context and active subscription scope.", - "inventory": "Scoping the visible Azure resource footprint.", - "automation": "Reviewing Azure Automation accounts for identity, execution, webhook, worker, and secure-asset posture.", - "app-credentials": "Reviewing application and service-principal authentication material, federated trust, and visible current-identity control paths.", - "devops": "Reviewing Azure DevOps build definitions for trusted source inputs, visible injection surfaces, and Azure-facing change paths.", - "app-services": "Reviewing App Service runtime, hostname, identity, deployment, and config cues that change follow-on paths.", - "acr": "Reviewing Azure Container Registry login, auth, network, and registry automation/trust cues.", - "databases": "Reviewing relational database server posture across Azure SQL, PostgreSQL Flexible, and MySQL Flexible.", - "dns": "Reviewing public and private DNS zone inventory and namespace boundaries.", - "aks": "Reviewing AKS control-plane endpoint, identity, auth posture, and Azure-side federation and addon cues.", - "api-mgmt": "Reviewing API Management gateway hostnames, identity, subscription, backend, and secret posture.", - "functions": "Reviewing Function App runtime, trigger, storage binding, identity, and deployment posture.", - "webjobs": "Reviewing App Service WebJobs for background execution mode, rerun posture, and inherited app context.", - "azure-ml": "Reviewing Azure ML runtime, scheduling, endpoint, identity, and storage-linked workspace posture.", - "event-grid": "Reviewing Event Grid trigger routes, destination types, and visible execution-capable follow-on paths.", - "logic-apps": "Reviewing Logic Apps trigger posture, identity context, and safe downstream action relationships.", - "container-apps-jobs": "Reviewing Container Apps Jobs trigger, execution, image, identity, secret, and registry posture.", - "arm-deployments": "Reviewing ARM deployment history for config exposure and linked content.", - "endpoints": "Mapping reachable IP and hostname surfaces from compute and web workloads.", - "network-effective": "Prioritizing likely public-IP reachability by combining visible endpoint and NSG evidence.", - "env-vars": "Reviewing App Service and Function App settings for exposed config paths and likely credential or secret follow-on.", - "network-ports": "Tracing likely inbound port exposure from visible NIC and subnet NSG rules.", - "tokens-credentials": "Correlating token-minting workloads, credential-bearing metadata paths, and the next likely follow-on.", - "rbac": "Collecting raw RBAC assignments across the current subscription.", - "principals": "Mapping visible principals, identity footholds, and follow-on candidates.", - "permissions": "Ranking principals by high-impact RBAC exposure and the next likely follow-on.", - "privesc": "Triage likely privilege-escalation and workload identity abuse paths.", - "role-trusts": "Reviewing high-signal identity trust edges and the clearest next review without implying delegated or admin consent.", - "cross-tenant": "Reviewing outside-tenant trust, delegated management, and tenant policy cues that most change control or pivot paths.", - "lighthouse": "Reviewing Azure Lighthouse delegations for cross-tenant management scope and high-impact access cues.", - "auth-policies": "Reviewing tenant auth controls that widen guest, consent, app-creation, or sign-in abuse paths.", - "managed-identities": "Mapping workload-linked managed identities and their visible privilege cues.", - "keyvault": "Reviewing Key Vault exposure, access-model weakness, and destructive leverage cues.", - "resource-trusts": "Correlating resource trust surfaces across public network and private-link paths.", - "storage": "Checking storage exposure and network posture for likely data targets.", - "snapshots-disks": "Reviewing managed disks and snapshots for offline-copy, sharing/export, and encryption posture with highest-value targets first.", - "nics": "Enumerating NIC attachments, IP context, and network boundary references.", - "workloads": "Joining workload assets with identity context and visible ingress paths.", - "vms": "Summarizing reachable compute assets and identity-bearing workloads.", - "vm-extensions": "Reviewing VM Extensions handler, source, protected-settings, and rerun posture.", - "vmss": "Reviewing Virtual Machine Scale Sets (VMSS) for fleet posture, identity, and frontend network cues.", - "chains": "Correlating grouped chain evidence with conservative cross-command joins.", - "persistence": "Walking the current identity through Azure-native persistence surfaces one service at a time.", -} - -var commandCompactIntroHint = map[string]string{ - "aks": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "acr": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "api-mgmt": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "env-vars": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "functions": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "azure-ml": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "event-grid": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "logic-apps": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "tokens-credentials": "table view is compact by design; the JSON artifact keeps the fuller visible field set", - "vm-extensions": "table view is compact by design; the JSON artifact keeps the fuller visible field set", -} - -var chainsFamilyTableRenderers = map[string]func(models.ChainsOutput) string{ - "compute-control": chainsComputeControlTable, - "credential-path": chainsCredentialPathTable, - "deployment-path": chainsDeploymentPathTable, - "escalation-path": chainsEscalationPathTable, -} - func chainsTableRenderer(payload any) (string, error) { switch out := payload.(type) { case models.ChainsOverviewOutput: @@ -137,13 +71,21 @@ func renderStructuredTableWithTitle(title string, headers []string, rows [][]str return cellStyle }) - body := strings.TrimRight(table.String(), "\n") + "\n" + body := trimTrailingLineSpaces(strings.TrimRight(table.String(), "\n")) + "\n" if !includeTitle { return body } return titleStyle.Render(title) + "\n\n" + body } +func trimTrailingLineSpaces(value string) string { + lines := strings.Split(value, "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " \t") + } + return strings.Join(lines, "\n") +} + func renderListTable(title string, headers []string, rows [][]string, emptyRow []string, takeaway string) string { if len(rows) == 0 { rows = append(rows, emptyRow) @@ -187,10 +129,49 @@ func renderTablePrelude(command string, context models.RenderContext, payload an } func commandNarrationForPayload(command string, payload any) string { - if command != "persistence" { + if command != "persistence" && command != "evasion" && command != "resourcehijacking" && command != "pathmasking" { return commandNarration[command] } + if command == "evasion" { + switch payload.(type) { + case models.EvasionAppInsightsOutput: + return "Application Insights evasion means visible instrumentation, sampling, filtering, and logging-level posture can reduce retained telemetry while the app still emits health signals." + case models.EvasionDCROutput: + return "DCR evasion means Azure Monitor collection, data flow, destination, association, and transformation posture can quietly reshape what defender telemetry says without proving log-content loss by default." + case models.EvasionDiagnosticSettingsOutput: + return "Diagnostic settings evasion means selected Azure resource categories, metrics, and destinations can change what telemetry is exported and where defenders must look, without proving sink contents by default." + default: + return commandNarration[command] + } + } + + if command == "resourcehijacking" { + switch payload.(type) { + case models.ResourceHijackingAPIMOutput: + return "APIM resource hijacking means the trusted API gateway can keep answering while backend or routing posture changes behind it." + case models.ResourceHijackingAutomationOutput: + return "Automation resource hijacking means trusted runbooks, schedules, webhooks, identities, or worker context can be repurposed as ordinary operations automation." + case models.ResourceHijackingLogicAppsOutput: + return "Logic Apps resource hijacking means a trusted workflow, trigger, connector path, or identity context can be repurposed while the automation resource remains familiar." + default: + return commandNarration[command] + } + } + + if command == "pathmasking" { + switch payload.(type) { + case models.PathMaskingAPIMOutput: + return "APIM path masking means the API gateway can preserve a public contract while backend, route, or policy indirection hides the true downstream path." + case models.PathMaskingLogicAppsOutput: + return "Logic Apps path masking means a trusted workflow can act as the visible relay while downstream actions, connectors, or identities carry the real path." + case models.PathMaskingRelayOutput: + return "Relay path masking means Azure exposes the cloud rendezvous point while the private listener, backend host, and traffic contents stay beyond management-plane proof." + default: + return commandNarration[command] + } + } + switch payload.(type) { case models.PersistenceAutomationOutput: return "Azure Automation persistence means Azure stores code plus execution context plus a trigger that can invoke it again later, not a backdoor listening on a port." @@ -424,6 +405,135 @@ func armDeploymentsTable(payload models.ArmDeploymentsOutput) string { return output + "\nTakeaway: " + armDeploymentsTakeaway(payload) + "\n" } +func dcrTable(payload models.DCROutput) string { + rows := make([][]string, 0, len(payload.DCRs)) + notes := []string{} + for _, dcr := range payload.DCRs { + rows = append(rows, []string{ + dcr.Name, + dcrScopeContext(dcr), + wrapTableNote(dcrStreamContext(dcr), 34), + wrapTableNote(dcrDestinationContext(dcr), 34), + dcrTransformationContext(dcr), + dcrAssociationContext(dcr), + }) + notes = append(notes, dcrOperatorNote(dcr)) + } + output := renderListTable("ho-azure dcr", []string{ + "dcr", "scope", "streams", "destinations", "transforms", "associations", + }, rows, []string{"no Data Collection Rules visible", "", "", "", "", ""}, dcrTakeaway(payload)) + if len(notes) > 0 { + output += "\n" + renderWrappedDetailTableWithWidth("operator notes", strings.Join(notes, "\n"), 96) + } + output += "\nNot collected by default:\n" + output += "- log arrival/filtering proof: proof boundary; querying workspace or sink contents can be noisy and would change this from management-plane posture review into evidence validation\n" + output += "- agent applied-state proof: proof boundary; this command shows configured DCR associations, not whether every target agent applied the rule\n" + output += "- activity-log history: API/noise; actor, timing, quick-revert, and maintenance-window proof belongs in an explicit history mode\n" + return output +} + +func dcrScopeContext(dcr models.DCRAsset) string { + parts := []string{} + if dcr.ResourceGroup != "" { + parts = append(parts, "rg="+dcr.ResourceGroup) + } + if dcr.Location != "" { + parts = append(parts, "location="+dcr.Location) + } + if stringPtrValue(dcr.Kind) != "" { + parts = append(parts, "kind="+stringPtrValue(dcr.Kind)) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, "; ") +} + +func dcrStreamContext(dcr models.DCRAsset) string { + if len(dcr.Streams) == 0 { + return "no streams visible" + } + parts := []string{fmt.Sprintf("%d stream(s)", len(dcr.Streams))} + if len(dcr.HighSignalStreams) > 0 { + parts = append(parts, "high-signal: "+strings.Join(dcr.HighSignalStreams, ", ")) + } + if len(dcr.DataSourceTypes) > 0 { + parts = append(parts, "sources: "+strings.Join(dcr.DataSourceTypes, ", ")) + } + return strings.Join(parts, "; ") +} + +func dcrDestinationContext(dcr models.DCRAsset) string { + if len(dcr.Destinations) == 0 { + return "no destinations visible" + } + parts := []string{} + for _, destination := range dcr.Destinations { + label := destination.Name + " (" + destination.Type + ")" + if stringPtrValue(destination.ResourceID) != "" { + label += " -> " + resourceNameFromIDForTable(stringPtrValue(destination.ResourceID)) + } + parts = append(parts, label) + } + return strings.Join(parts, "; ") +} + +func dcrTransformationContext(dcr models.DCRAsset) string { + if dcr.TransformationCount == 0 { + return "none visible" + } + return fmt.Sprintf("%d present", dcr.TransformationCount) +} + +func dcrAssociationContext(dcr models.DCRAsset) string { + if dcr.AssociationCount == 0 { + return "none visible" + } + if dcr.AssociationCount == 1 && len(dcr.Associations) == 1 { + return resourceNameFromIDForTable(dcr.Associations[0].TargetID) + } + return fmt.Sprintf("%d target(s)", dcr.AssociationCount) +} + +func dcrOperatorNote(dcr models.DCRAsset) string { + parts := []string{dcr.Name + ": " + dcr.Summary} + if dcr.TransformationCount > 0 && len(dcr.HighSignalStreams) > 0 { + parts = append(parts, "Evasion read: transformation posture is visible on high-signal streams, so logs may still arrive while selected records or fields are filtered or reshaped before storage. The command does not print transformKql or claim malicious filtering.") + } else if dcr.TransformationCount > 0 { + parts = append(parts, "Evasion read: transformation posture is visible, but the command needs stream/context evidence before calling it high-impact.") + } else if len(dcr.HighSignalStreams) > 0 { + parts = append(parts, "Evasion read: high-signal streams are visible; removing, narrowing, or rerouting those streams would change defender truth, but current output only shows configured posture.") + } + if len(dcr.Destinations) > 0 { + parts = append(parts, "Destination read: current destinations are named, but this command does not claim they are wrong without an expected workspace baseline.") + } + if dcr.AssociationCount > 0 { + parts = append(parts, "Association read: the visible association scope shows where the DCR is intended to apply; runtime agent applied-state is not proven.") + } + return strings.Join(parts, " ") +} + +func dcrTakeaway(payload models.DCROutput) string { + if len(payload.DCRs) == 0 { + return "no Data Collection Rules were visible from the current read path." + } + transformed := 0 + highSignal := 0 + associated := 0 + for _, dcr := range payload.DCRs { + if dcr.TransformationCount > 0 { + transformed++ + } + if len(dcr.HighSignalStreams) > 0 { + highSignal++ + } + if dcr.AssociationCount > 0 { + associated++ + } + } + return fmt.Sprintf("%d DCR(s) visible; %d show transformation posture, %d carry high-signal streams, and %d have visible associations.", len(payload.DCRs), transformed, highSignal, associated) +} + func appServicesTakeaway(payload models.AppServicesOutput) string { httpsOnly := 0 publicNetwork := 0 @@ -2003,6 +2113,9 @@ func apiMgmtInventoryContext(service models.ApiMgmtServiceAsset) string { if service.NamedValueCount != nil { parts = append(parts, "named-values="+intText(*service.NamedValueCount)) } + if service.PolicyCount != nil { + parts = append(parts, "policies="+intText(*service.PolicyCount)) + } if len(parts) == 0 { return "-" } @@ -2056,6 +2169,9 @@ func apiMgmtPostureContext(service models.ApiMgmtServiceAsset) string { if service.NamedValueKeyVaultCount != nil { parts = append(parts, "kv-backed="+intText(*service.NamedValueKeyVaultCount)) } + if len(service.PolicyControlTypes) > 0 { + parts = append(parts, "policy-controls="+strings.Join(service.PolicyControlTypes, ",")) + } if len(parts) == 0 { return "-" } @@ -2148,6 +2264,21 @@ func valueOrFallback(value *string, fallback string) string { return *value } +func stringPtrValue(value *string) string { + if value == nil { + return "" + } + return *value +} + +func resourceNameFromIDForTable(resourceID string) string { + parts := strings.Split(strings.Trim(resourceID, "/"), "/") + if len(parts) == 0 { + return resourceID + } + return parts[len(parts)-1] +} + func stringOrFallback(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback @@ -2195,8 +2326,19 @@ func automationIdentityContext(item models.AutomationAccountAsset) string { } func automationExecutionContext(item models.AutomationAccountAsset) string { - return "published=" + intOrUnknown(item.PublishedRunbookCount) + "/" + intOrUnknown(item.RunbookCount) + - "; job-schedules=" + intOrUnknown(item.JobScheduleCount) + parts := []string{"published=" + intOrUnknown(item.PublishedRunbookCount) + "/" + intOrUnknown(item.RunbookCount) + + "; job-schedules=" + intOrUnknown(item.JobScheduleCount), + } + if len(item.RunbookTypes) > 0 { + parts = append(parts, "types="+strings.Join(item.RunbookTypes, ", ")) + } + if len(item.RunbookCommandClues) > 0 { + parts = append(parts, "command-clues="+strings.Join(item.RunbookCommandClues, ", ")) + } + if len(item.RunbookResourceClues) > 0 { + parts = append(parts, "resource-clues="+strings.Join(item.RunbookResourceClues, ", ")) + } + return strings.Join(parts, "; ") } func automationTriggerContext(item models.AutomationAccountAsset) string { diff --git a/internal/render/table_metadata.go b/internal/render/table_metadata.go new file mode 100644 index 0000000..0f268f7 --- /dev/null +++ b/internal/render/table_metadata.go @@ -0,0 +1,82 @@ +package render + +import "harrierops-azure/internal/models" + +var commandNarration = map[string]string{ + "whoami": "Checking caller context and active subscription scope.", + "inventory": "Scoping the visible Azure resource footprint.", + "automation": "Reviewing Azure Automation accounts for identity, execution, webhook, worker, and secure-asset posture.", + "app-credentials": "Reviewing application and service-principal authentication material, federated trust, and visible current-identity control paths.", + "devops": "Reviewing Azure DevOps build definitions for trusted source inputs, visible injection surfaces, and Azure-facing change paths.", + "app-services": "Reviewing App Service runtime, hostname, identity, deployment, and config cues that change follow-on paths.", + "acr": "Reviewing Azure Container Registry login, auth, network, and registry automation/trust cues.", + "databases": "Reviewing relational database server posture across Azure SQL, PostgreSQL Flexible, and MySQL Flexible.", + "dcr": "Reviewing Data Collection Rules for collection, stream, destination, association, and transformation posture.", + "diagnostic-settings": "Reviewing resource diagnostic settings for selected categories, metrics, destinations, and visible telemetry export posture.", + "monitoring-sinks": "Reviewing visible and declared monitoring destinations that DCRs and diagnostic settings can route telemetry toward.", + "dns": "Reviewing public and private DNS zone inventory and namespace boundaries.", + "aks": "Reviewing AKS control-plane endpoint, identity, auth posture, and Azure-side federation and addon cues.", + "api-mgmt": "Reviewing API Management gateway hostnames, identity, subscription, backend, and secret posture.", + "appinsights": "Reviewing Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture.", + "functions": "Reviewing Function App runtime, trigger, storage binding, identity, and deployment posture.", + "webjobs": "Reviewing App Service WebJobs for background execution mode, rerun posture, and inherited app context.", + "azure-ml": "Reviewing Azure ML runtime, scheduling, endpoint, identity, and storage-linked workspace posture.", + "event-grid": "Reviewing Event Grid trigger routes, destination types, and visible execution-capable follow-on paths.", + "logic-apps": "Reviewing Logic Apps trigger posture, identity context, and safe downstream action relationships.", + "container-apps-jobs": "Reviewing Container Apps Jobs trigger, execution, image, identity, secret, and registry posture.", + "arm-deployments": "Reviewing ARM deployment history for config exposure and linked content.", + "endpoints": "Mapping reachable IP and hostname surfaces from compute and web workloads.", + "network-effective": "Prioritizing likely public-IP reachability by combining visible endpoint and NSG evidence.", + "env-vars": "Reviewing App Service and Function App settings for exposed config paths and likely credential or secret follow-on.", + "network-ports": "Tracing likely inbound port exposure from visible NIC and subnet NSG rules.", + "tokens-credentials": "Correlating token-minting workloads, credential-bearing metadata paths, and the next likely follow-on.", + "rbac": "Collecting raw RBAC assignments across the current subscription.", + "principals": "Mapping visible principals, identity footholds, and follow-on candidates.", + "permissions": "Ranking principals by high-impact RBAC exposure and the next likely follow-on.", + "privesc": "Triage likely privilege-escalation and workload identity abuse paths.", + "role-trusts": "Reviewing high-signal identity trust edges and the clearest next review without implying delegated or admin consent.", + "cross-tenant": "Reviewing outside-tenant trust, delegated management, and tenant policy cues that most change control or pivot paths.", + "lighthouse": "Reviewing Azure Lighthouse delegations for cross-tenant management scope and high-impact access cues.", + "auth-policies": "Reviewing tenant auth controls that widen guest, consent, app-creation, or sign-in abuse paths.", + "managed-identities": "Mapping workload-linked managed identities and their visible privilege cues.", + "keyvault": "Reviewing Key Vault exposure, access-model weakness, and destructive leverage cues.", + "resource-trusts": "Correlating resource trust surfaces across public network and private-link paths.", + "relay": "Reviewing Azure Relay namespaces and Hybrid Connections for private-path rendezvous posture.", + "storage": "Checking storage exposure and network posture for likely data targets.", + "snapshots-disks": "Reviewing managed disks and snapshots for offline-copy, sharing/export, and encryption posture with highest-value targets first.", + "nics": "Enumerating NIC attachments, IP context, and network boundary references.", + "workloads": "Joining workload assets with identity context and visible ingress paths.", + "vms": "Summarizing reachable compute assets and identity-bearing workloads.", + "vm-extensions": "Reviewing VM Extensions handler, source, protected-settings, and rerun posture.", + "vmss": "Reviewing Virtual Machine Scale Sets (VMSS) for fleet posture, identity, and frontend network cues.", + "chains": "Correlating grouped chain evidence with conservative cross-command joins.", + "persistence": "Walking the current identity through Azure-native persistence surfaces one service at a time.", + "evasion": "Walking the current identity through Azure-native evasion surfaces by visible truth-disruption posture.", + "resourcehijacking": "Walking the current identity through existing Azure resources that can be commandeered, redirected, replaced, or repurposed.", + "pathmasking": "Walking the current identity through Azure-native relay, proxy, and workflow surfaces that can blur caller-to-target paths.", +} + +var commandCompactIntroHint = map[string]string{ + "aks": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "acr": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "api-mgmt": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "appinsights": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "dcr": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "diagnostic-settings": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "monitoring-sinks": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "env-vars": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "functions": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "azure-ml": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "event-grid": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "logic-apps": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "relay": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "tokens-credentials": "table view is compact by design; the JSON artifact keeps the fuller visible field set", + "vm-extensions": "table view is compact by design; the JSON artifact keeps the fuller visible field set", +} + +var chainsFamilyTableRenderers = map[string]func(models.ChainsOutput) string{ + "compute-control": chainsComputeControlTable, + "credential-path": chainsCredentialPathTable, + "deployment-path": chainsDeploymentPathTable, + "escalation-path": chainsEscalationPathTable, +} diff --git a/packaging/container/Dockerfile.release-linux b/packaging/container/Dockerfile.release-linux index ea2100c..78ec1ec 100644 --- a/packaging/container/Dockerfile.release-linux +++ b/packaging/container/Dockerfile.release-linux @@ -5,6 +5,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY ho-azure /usr/local/bin/ho-azure +ARG TARGETARCH +COPY --chmod=755 ho-azure-${TARGETARCH} /usr/local/bin/ho-azure ENTRYPOINT ["/usr/local/bin/ho-azure"] diff --git a/scripts/release_smoke_unix.sh b/scripts/release_smoke_unix.sh new file mode 100755 index 0000000..435ed61 --- /dev/null +++ b/scripts/release_smoke_unix.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env sh +set -eu + +binary="${1:?usage: release_smoke_unix.sh /path/to/ho-azure}" + +"$binary" help >/dev/null +AZUREFOX_PROVIDER=static "$binary" whoami --output json >/dev/null +AZUREFOX_PROVIDER=static "$binary" chains credential-path --output json >/dev/null +AZUREFOX_PROVIDER=static "$binary" persistence automation --output json >/dev/null +AZUREFOX_PROVIDER=static "$binary" evasion dcr --output json >/dev/null +AZUREFOX_PROVIDER=static "$binary" resourcehijacking api-mgmt --output json >/dev/null +AZUREFOX_PROVIDER=static "$binary" pathmasking relay --output json >/dev/null diff --git a/testdata/api-mgmt.golden.csv b/testdata/api-mgmt.golden.csv index 4923991..5823992 100644 --- a/testdata/api-mgmt.golden.csv +++ b/testdata/api-mgmt.golden.csv @@ -1,2 +1,2 @@ -active_subscription_count,api_count,api_subscription_required_count,backend_hostnames,backend_count,developer_portal_status,gateway_enabled,gateway_hostnames,id,legacy_portal_status,location,management_hostnames,name,named_value_count,named_value_key_vault_count,named_value_secret_count,portal_hostnames,public_ip_address_id,private_ip_addresses,public_ip_addresses,public_network_access,related_ids,resource_group,sku_capacity,sku_name,state,subscription_count,summary,virtual_network_type,workload_client_id,workload_identity_ids,workload_identity_type,workload_principal_id -2,2,1,"[""orders-internal.contoso.local""]",1,Enabled,true,"[""apim-edge-01.azure-api.net"",""api.contoso.com""]",/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01,Disabled,eastus,"[""apim-edge-01.management.azure-api.net""]",apim-edge-01,2,1,1,"[""portal.apim-edge-01.contoso.com""]",/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01,[],"[""52.170.20.30""]",Enabled,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01"",""99990000-0000-0000-0000-000000000001"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01""]",rg-apps,1,Developer,Succeeded,3,"API Management service 'apim-edge-01' publishes gateway hostnames apim-edge-01.azure-api.net, api.contoso.com; management hostnames apim-edge-01.management.azure-api.net; portal hostnames portal.apim-edge-01.contoso.com and uses managed identity (SystemAssigned). Visible inventory: 2 APIs, 1 require subscriptions, 3 subscriptions (2 active), 1 backends, 2 named values. Depth cues: 1 named values marked secret, 1 Key Vault-backed named values, backend hosts orders-internal.contoso.local. Visible posture: public network access Enabled, virtual network type External, SKU Developer, gateway enabled, developer portal Enabled.",External,99990000-0000-0000-0000-000000000002,[],SystemAssigned,99990000-0000-0000-0000-000000000001 +active_subscription_count,api_count,api_subscription_required_count,backend_hostnames,backend_count,developer_portal_status,gateway_enabled,gateway_hostnames,id,legacy_portal_status,location,management_hostnames,name,named_value_count,named_value_key_vault_count,named_value_secret_count,policy_count,policy_control_types,portal_hostnames,public_ip_address_id,private_ip_addresses,public_ip_addresses,public_network_access,related_ids,resource_group,sku_capacity,sku_name,state,subscription_count,summary,virtual_network_type,workload_client_id,workload_identity_ids,workload_identity_type,workload_principal_id +2,2,1,"[""orders-internal.contoso.local""]",1,Enabled,true,"[""apim-edge-01.azure-api.net"",""api.contoso.com""]",/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01,Disabled,eastus,"[""apim-edge-01.management.azure-api.net""]",apim-edge-01,2,1,1,2,"[""backend-routing"",""conditional-routing"",""header-auth"",""request-rewrite""]","[""portal.apim-edge-01.contoso.com""]",/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01,[],"[""52.170.20.30""]",Enabled,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01"",""99990000-0000-0000-0000-000000000001"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01""]",rg-apps,1,Developer,Succeeded,3,"API Management service 'apim-edge-01' publishes gateway hostnames apim-edge-01.azure-api.net, api.contoso.com; management hostnames apim-edge-01.management.azure-api.net; portal hostnames portal.apim-edge-01.contoso.com and uses managed identity (SystemAssigned). Visible inventory: 2 APIs, 1 require subscriptions, 3 subscriptions (2 active), 1 backends, 2 policy scope(s), 2 named values. Depth cues: 1 named values marked secret, 1 Key Vault-backed named values, backend hosts orders-internal.contoso.local, policy controls backend-routing, conditional-routing, header-auth, request-rewrite. Visible posture: public network access Enabled, virtual network type External, SKU Developer, gateway enabled, developer portal Enabled.",External,99990000-0000-0000-0000-000000000002,[],SystemAssigned,99990000-0000-0000-0000-000000000001 diff --git a/testdata/api-mgmt.golden.json b/testdata/api-mgmt.golden.json index bd27cc9..c5a4722 100644 --- a/testdata/api-mgmt.golden.json +++ b/testdata/api-mgmt.golden.json @@ -40,10 +40,17 @@ "backend_hostnames": [ "orders-internal.contoso.local" ], + "policy_count": 2, + "policy_control_types": [ + "backend-routing", + "conditional-routing", + "header-auth", + "request-rewrite" + ], "named_value_count": 2, "named_value_secret_count": 1, "named_value_key_vault_count": 1, - "summary": "API Management service 'apim-edge-01' publishes gateway hostnames apim-edge-01.azure-api.net, api.contoso.com; management hostnames apim-edge-01.management.azure-api.net; portal hostnames portal.apim-edge-01.contoso.com and uses managed identity (SystemAssigned). Visible inventory: 2 APIs, 1 require subscriptions, 3 subscriptions (2 active), 1 backends, 2 named values. Depth cues: 1 named values marked secret, 1 Key Vault-backed named values, backend hosts orders-internal.contoso.local. Visible posture: public network access Enabled, virtual network type External, SKU Developer, gateway enabled, developer portal Enabled.", + "summary": "API Management service 'apim-edge-01' publishes gateway hostnames apim-edge-01.azure-api.net, api.contoso.com; management hostnames apim-edge-01.management.azure-api.net; portal hostnames portal.apim-edge-01.contoso.com and uses managed identity (SystemAssigned). Visible inventory: 2 APIs, 1 require subscriptions, 3 subscriptions (2 active), 1 backends, 2 policy scope(s), 2 named values. Depth cues: 1 named values marked secret, 1 Key Vault-backed named values, backend hosts orders-internal.contoso.local, policy controls backend-routing, conditional-routing, header-auth, request-rewrite. Visible posture: public network access Enabled, virtual network type External, SKU Developer, gateway enabled, developer portal Enabled.", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01", "99990000-0000-0000-0000-000000000001", diff --git a/testdata/api-mgmt.golden.table.txt b/testdata/api-mgmt.golden.table.txt index f02c3b8..97a2405 100644 --- a/testdata/api-mgmt.golden.table.txt +++ b/testdata/api-mgmt.golden.table.txt @@ -11,12 +11,12 @@ table view is compact by design; the JSON artifact keeps the fuller visible fiel │ │ management: apim-edge-01.management.azure-api.net │ │ │ │ │ portal: portal.apim-edge-01.contoso.com │ │ │ ╰──────────────┴───────────────────────────────────────────────────┴────────────────┴────────────────────────────────────────────────────────────────╯ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ operator signal │ -├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ -│ apis=2; sub-required=1/2; subs=3; active-subs=2; backends=1; backend-hosts=1; named-values=2; sku=Developer; vnet=External; gateway=yes; │ -│ devportal=Enabled; named-secrets=1; kv-backed=1 │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ operator signal │ +├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ apis=2; sub-required=1/2; subs=3; active-subs=2; backends=1; backend-hosts=1; named-values=2; policies=2; sku=Developer; vnet=External; gateway=yes; │ +│ devportal=Enabled; named-secrets=1; kv-backed=1; policy-controls=backend-routing,conditional-routing,header-auth,request-rewrite │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ note │ ├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ diff --git a/testdata/appinsights.golden.csv b/testdata/appinsights.golden.csv new file mode 100644 index 0000000..fa0fe35 --- /dev/null +++ b/testdata/appinsights.golden.csv @@ -0,0 +1,3 @@ +id,name,kind,resource_group,location,instrumentation_clues,sampling_clues,filtering_clues,logging_level_clues,visible_telemetry_types,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api,app-public-api,AppService,rg-apps,eastus,"[""APPLICATIONINSIGHTS_CONNECTION_STRING""]","[""ApplicationInsights__Sampling__Percentage=25""]","[""ApplicationInsights__TelemetryProcessor__HealthCheckFilter""]","[""Logging__ApplicationInsights__LogLevel__Default=Warning""]","[""traces""]","AppService ""app-public-api"" has 1 instrumentation clue(s), 1 sampling clue(s), 1 filtering clue(s), 1 logging-level clue(s) visible from app settings.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders,func-orders,FunctionApp,rg-apps,eastus,"[""APPINSIGHTS_INSTRUMENTATIONKEY""]","[""AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true""]",[],[],[],"FunctionApp ""func-orders"" has 1 instrumentation clue(s), 1 sampling clue(s) visible from app settings.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]" diff --git a/testdata/appinsights.golden.json b/testdata/appinsights.golden.json new file mode 100644 index 0000000..bab428e --- /dev/null +++ b/testdata/appinsights.golden.json @@ -0,0 +1,76 @@ +{ + "components": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/components/ai-public-api", + "name": "ai-public-api", + "resource_group": "rg-monitor", + "location": "eastus", + "kind": "web", + "application_type": "web", + "workspace_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "ingestion_mode": "LogAnalytics", + "summary": "Application Insights component \"ai-public-api\" is visible in eastus.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/components/ai-public-api" + ] + } + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api", + "name": "app-public-api", + "kind": "AppService", + "resource_group": "rg-apps", + "location": "eastus", + "instrumentation_clues": [ + "APPLICATIONINSIGHTS_CONNECTION_STRING" + ], + "sampling_clues": [ + "ApplicationInsights__Sampling__Percentage=25" + ], + "filtering_clues": [ + "ApplicationInsights__TelemetryProcessor__HealthCheckFilter" + ], + "logging_level_clues": [ + "Logging__ApplicationInsights__LogLevel__Default=Warning" + ], + "visible_telemetry_types": [ + "traces" + ], + "summary": "AppService \"app-public-api\" has 1 instrumentation clue(s), 1 sampling clue(s), 1 filtering clue(s), 1 logging-level clue(s) visible from app settings.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders", + "name": "func-orders", + "kind": "FunctionApp", + "resource_group": "rg-apps", + "location": "eastus", + "instrumentation_clues": [ + "APPINSIGHTS_INSTRUMENTATIONKEY" + ], + "sampling_clues": [ + "AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true" + ], + "filtering_clues": [], + "logging_level_clues": [], + "visible_telemetry_types": [], + "summary": "FunctionApp \"func-orders\" has 1 instrumentation clue(s), 1 sampling clue(s) visible from app settings.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ] + } + ], + "findings": [], + "issues": [], + "metadata": { + "command": "appinsights", + "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 + } +} diff --git a/testdata/appinsights.golden.table.txt b/testdata/appinsights.golden.table.txt new file mode 100644 index 0000000..5a090ef --- /dev/null +++ b/testdata/appinsights.golden.table.txt @@ -0,0 +1,29 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[appinsights] Reviewing Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture. +table view is compact by design; the JSON artifact keeps the fuller visible field set +ho-azure appinsights + +╭────────────────┬─────────────┬───────────────────────────────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────╮ +│ target │ kind │ sampling │ filtering │ logging │ +├────────────────┼─────────────┼───────────────────────────────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤ +│ app-public-api │ AppService │ ApplicationInsights__Sampling__Percentage=25 │ ApplicationInsights__TelemetryProcessor__HealthCheckFilter │ Logging__ApplicationInsights__LogLevel__Default=Warning │ +│ func-orders │ FunctionApp │ AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true │ none visible │ none visible │ +╰────────────────┴─────────────┴───────────────────────────────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────╯ + +Takeaway: 1 component(s) and 2 instrumented target(s) visible; 2 target(s) show sampling clues and 1 show filtering clues. + +Components +component | resource group | ingestion | workspace + +ai-public-api | rg-monitor | LogAnalytics | law-soc-prod + +Not collected by default +item | classification | reason + +setting values | recon safety | Default output uses setting names as posture clues and does not print instrumentation keys or connection strings. +code-level processors | proof boundary | Telemetry processor bodies usually live in source code or binaries, outside management-plane posture. +true unsampled count | proof boundary | Current posture cannot prove how many events were dropped or retained. +host.json body | collector issue | Function sampling can live in host.json; this helper only uses visible app setting names by default. +detector failure | proof boundary | The command does not inspect detections, so it cannot claim a rule missed activity. diff --git a/testdata/automation.golden.csv b/testdata/automation.golden.csv index c3fc10a..b4038bc 100644 --- a/testdata/automation.golden.csv +++ b/testdata/automation.golden.csv @@ -1,3 +1,3 @@ -id,name,resource_group,location,state,sku_name,identity_type,principal_id,client_id,identity_ids,runbook_count,published_runbook_count,published_runbook_names,schedule_count,schedule_definitions,job_schedule_count,webhook_count,hybrid_worker_group_count,credential_count,certificate_count,connection_count,variable_count,encrypted_variable_count,start_modes,primary_start_mode,primary_runbook_name,schedule_runbook_names,webhook_runbook_names,hybrid_worker_group_ids,trigger_join_ids,identity_join_ids,secret_support_types,secret_dependency_ids,consequence_types,missing_execution_path,missing_target_mapping,summary,related_ids -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod,aa-hybrid-prod,rg-ops,eastus,Ok,Basic,SystemAssigned,12121212-1212-1212-1212-121212121212,34343434-3434-3434-3434-343434343434,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system""]",7,6,"[""Baseline-Config"",""Nightly-Reconcile"",""Redeploy-App"",""Reapply-Agent"",""Sync-Secrets"",""Rotate-Certs""]",4,"[""baseline-nightly: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T01:00:00Z; enabled=true"",""cert-rotation-weekly: frequency=Week; interval=1; timezone=UTC; start=2026-04-13T02:00:00Z; enabled=true; weekdays=Sunday"",""nightly-reconcile: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T04:00:00Z; enabled=true"",""sync-secrets-hourly: frequency=Hour; interval=6; timezone=UTC; start=2026-04-13T00:00:00Z; enabled=true""]",5,2,1,2,1,2,5,4,"[""schedule"",""job-schedule"",""webhook"",""hybrid-worker""]",webhook,Redeploy-App,"[""Baseline-Config"",""Nightly-Reconcile""]","[""Redeploy-App"",""Reapply-Agent""]","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/hybridRunbookWorkerGroups/prod-workers""]","[""automation-job-schedule:baseline-nightly"",""automation-webhook:redeploy-api"",""automation-hybrid-worker:/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/hybridRunbookWorkerGroups/prod-workers""]","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system"",""12121212-1212-1212-1212-121212121212"",""34343434-3434-3434-3434-343434343434""]","[""credentials"",""certificates"",""connections"",""encrypted-variables""]","[""automation-credential:prod-admin"",""automation-certificate:prod-signing-cert"",""automation-connection:prod-arm"",""automation-variable:prod-config-secret""]","[""run-recurring-execution"",""reintroduce-config"",""consume-secret-backed-deployment-material""]",false,true,"Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted).","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system""]" -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet,aa-lab-quiet,rg-lab,centralus,Ok,Basic,,,,[],2,1,"[""Lab-Maintenance""]",1,"[""lab-maintenance-daily: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T03:00:00Z; enabled=true""]",1,0,0,0,0,1,2,1,"[""schedule"",""job-schedule""]",schedule,Lab-Maintenance,"[""Lab-Maintenance""]",[],[],"[""automation-job-schedule:lab-maintenance""]",[],"[""connections"",""encrypted-variables""]","[""automation-connection:lab-ops"",""automation-variable:lab-secret-ref""]","[""run-recurring-execution"",""reintroduce-config"",""consume-secret-backed-deployment-material""]",false,true,"Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted).","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet""]" +id,name,resource_group,location,state,sku_name,identity_type,principal_id,client_id,identity_ids,runbook_count,published_runbook_count,published_runbook_names,runbook_types,runbook_command_clues,runbook_resource_clues,schedule_count,schedule_definitions,job_schedule_count,webhook_count,hybrid_worker_group_count,credential_count,certificate_count,connection_count,variable_count,encrypted_variable_count,start_modes,primary_start_mode,primary_runbook_name,schedule_runbook_names,webhook_runbook_names,hybrid_worker_group_ids,trigger_join_ids,identity_join_ids,secret_support_types,secret_dependency_ids,consequence_types,missing_execution_path,missing_target_mapping,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod,aa-hybrid-prod,rg-ops,eastus,Ok,Basic,SystemAssigned,12121212-1212-1212-1212-121212121212,34343434-3434-3434-3434-343434343434,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system""]",7,6,"[""Baseline-Config"",""Nightly-Reconcile"",""Redeploy-App"",""Reapply-Agent"",""Sync-Secrets"",""Rotate-Certs""]","[""PowerShell"",""Python3""]",[],[],4,"[""baseline-nightly: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T01:00:00Z; enabled=true"",""cert-rotation-weekly: frequency=Week; interval=1; timezone=UTC; start=2026-04-13T02:00:00Z; enabled=true; weekdays=Sunday"",""nightly-reconcile: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T04:00:00Z; enabled=true"",""sync-secrets-hourly: frequency=Hour; interval=6; timezone=UTC; start=2026-04-13T00:00:00Z; enabled=true""]",5,2,1,2,1,2,5,4,"[""schedule"",""job-schedule"",""webhook"",""hybrid-worker""]",webhook,Redeploy-App,"[""Baseline-Config"",""Nightly-Reconcile""]","[""Redeploy-App"",""Reapply-Agent""]","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/hybridRunbookWorkerGroups/prod-workers""]","[""automation-job-schedule:baseline-nightly"",""automation-webhook:redeploy-api"",""automation-hybrid-worker:/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/hybridRunbookWorkerGroups/prod-workers""]","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system"",""12121212-1212-1212-1212-121212121212"",""34343434-3434-3434-3434-343434343434""]","[""credentials"",""certificates"",""connections"",""encrypted-variables""]","[""automation-credential:prod-admin"",""automation-certificate:prod-signing-cert"",""automation-connection:prod-arm"",""automation-variable:prod-config-secret""]","[""run-recurring-execution"",""reintroduce-config"",""consume-secret-backed-deployment-material""]",false,true,"Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); types PowerShell, Python3; runbook content clues not collected by default; 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted).","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet,aa-lab-quiet,rg-lab,centralus,Ok,Basic,,,,[],2,1,"[""Lab-Maintenance""]","[""PowerShell""]",[],[],1,"[""lab-maintenance-daily: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T03:00:00Z; enabled=true""]",1,0,0,0,0,1,2,1,"[""schedule"",""job-schedule""]",schedule,Lab-Maintenance,"[""Lab-Maintenance""]",[],[],"[""automation-job-schedule:lab-maintenance""]",[],"[""connections"",""encrypted-variables""]","[""automation-connection:lab-ops"",""automation-variable:lab-secret-ref""]","[""run-recurring-execution"",""reintroduce-config"",""consume-secret-backed-deployment-material""]",false,true,"Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); types PowerShell; runbook content clues not collected by default; 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted).","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet""]" diff --git a/testdata/automation.golden.json b/testdata/automation.golden.json index e950247..6040fa4 100644 --- a/testdata/automation.golden.json +++ b/testdata/automation.golden.json @@ -31,6 +31,12 @@ "Sync-Secrets", "Rotate-Certs" ], + "runbook_types": [ + "PowerShell", + "Python3" + ], + "runbook_command_clues": [], + "runbook_resource_clues": [], "schedule_count": 4, "schedule_definitions": [ "baseline-nightly: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T01:00:00Z; enabled=true", @@ -94,7 +100,7 @@ ], "missing_execution_path": false, "missing_target_mapping": true, - "summary": "Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted).", + "summary": "Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); types PowerShell, Python3; runbook content clues not collected by default; 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted).", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod", "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system" @@ -116,6 +122,11 @@ "published_runbook_names": [ "Lab-Maintenance" ], + "runbook_types": [ + "PowerShell" + ], + "runbook_command_clues": [], + "runbook_resource_clues": [], "schedule_count": 1, "schedule_definitions": [ "lab-maintenance-daily: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T03:00:00Z; enabled=true" @@ -158,7 +169,7 @@ ], "missing_execution_path": false, "missing_target_mapping": true, - "summary": "Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted).", + "summary": "Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); types PowerShell; runbook content clues not collected by default; 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted).", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet" ] diff --git a/testdata/automation.golden.table.txt b/testdata/automation.golden.table.txt index 222762a..d0b7075 100644 --- a/testdata/automation.golden.table.txt +++ b/testdata/automation.golden.table.txt @@ -4,11 +4,11 @@ context :: tenant=auto subscription=auto output=table [automation] Reviewing Azure Automation accounts for identity, execution, webhook, worker, and secure-asset posture. ho-azure automation -╭────────────────────┬────────────────┬────────────────────────────────┬─────────────────────────┬──────────┬────────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ automation account │ identity │ execution │ triggers │ workers │ assets │ why it matters │ -├────────────────────┼────────────────┼────────────────────────────────┼─────────────────────────┼──────────┼────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ -│ aa-hybrid-prod │ SystemAssigned │ published=6/7; job-schedules=5 │ schedules=4; webhooks=2 │ groups=1 │ cred=2; cert=1; conn=2; vars=5 (4 enc) │ Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted). │ -│ aa-lab-quiet │ none │ published=1/2; job-schedules=1 │ schedules=1; webhooks=0 │ groups=0 │ cred=0; cert=0; conn=1; vars=2 (1 enc) │ Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted). │ -╰────────────────────┴────────────────┴────────────────────────────────┴─────────────────────────┴──────────┴────────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭────────────────────┬────────────────┬───────────────────────────────────────────────────────────┬─────────────────────────┬──────────┬────────────────────────────────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ automation account │ identity │ execution │ triggers │ workers │ assets │ why it matters │ +├────────────────────┼────────────────┼───────────────────────────────────────────────────────────┼─────────────────────────┼──────────┼────────────────────────────────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ aa-hybrid-prod │ SystemAssigned │ published=6/7; job-schedules=5; types=PowerShell, Python3 │ schedules=4; webhooks=2 │ groups=1 │ cred=2; cert=1; conn=2; vars=5 (4 enc) │ Automation account 'aa-hybrid-prod' uses managed identity (SystemAssigned). Visible execution shape: 6/7 published runbook(s); types PowerShell, Python3; runbook content clues not collected by default; 4 schedule(s), 5 job schedule(s), 2 webhook(s); 1 Hybrid Runbook Worker group(s). Secure asset posture: credentials 2, certificates 1, connections 2, variables 5 (4 encrypted). │ +│ aa-lab-quiet │ none │ published=1/2; job-schedules=1; types=PowerShell │ schedules=1; webhooks=0 │ groups=0 │ cred=0; cert=0; conn=1; vars=2 (1 enc) │ Automation account 'aa-lab-quiet' has no managed identity visible from the current read path. Visible execution shape: 1/2 published runbook(s); types PowerShell; runbook content clues not collected by default; 1 schedule(s), 1 job schedule(s), 0 webhook(s); no Hybrid Runbook Worker groups visible. Secure asset posture: credentials 0, certificates 0, connections 1, variables 2 (1 encrypted). │ +╰────────────────────┴────────────────┴───────────────────────────────────────────────────────────┴─────────────────────────┴──────────┴────────────────────────────────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ Takeaway: 2 Automation account(s) visible; 1 carry managed identity context, 1 expose webhook start paths, 1 show Hybrid Runbook Worker reach, and 7 published runbooks are visible. diff --git a/testdata/dcr.golden.csv b/testdata/dcr.golden.csv new file mode 100644 index 0000000..0ff3e19 --- /dev/null +++ b/testdata/dcr.golden.csv @@ -0,0 +1,3 @@ +id,name,resource_group,location,kind,description,data_collection_endpoint_id,data_source_types,streams,high_signal_streams,destination_types,transformation_count,association_count,data_sources,data_flows,destinations,associations,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host,dcr-prod-host,rg-monitor,eastus,,Production host collection rule with cost-control transform,,"[""syslog"",""windowsEventLogs""]","[""Microsoft-Syslog"",""Microsoft-WindowsEvent""]","[""Microsoft-WindowsEvent"",""Microsoft-Syslog""]","[""logAnalytics""]",1,1,"[{""name"":""windows-security-events"",""type"":""windowsEventLogs"",""streams"":[""Microsoft-WindowsEvent""],""transform_kql_present"":false},{""name"":""linux-syslog"",""type"":""syslog"",""streams"":[""Microsoft-Syslog""],""transform_kql_present"":false}]","[{""streams"":[""Microsoft-WindowsEvent""],""destinations"":[""soc-workspace""],""transform_kql_present"":true,""transform_kql_fingerprint"":""31c5a1b7dd8e"",""transform_kql_length"":84},{""streams"":[""Microsoft-Syslog""],""destinations"":[""soc-workspace""],""transform_kql_present"":false}]","[{""name"":""soc-workspace"",""type"":""logAnalytics"",""resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod"",""detail"":""soc-workspace""}]","[{""id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association"",""name"":""prod-host-association"",""target_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01"",""data_collection_rule_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host"",""description"":""Production host association""}]","DCR ""dcr-prod-host"" has 2 data source(s), 2 data flow(s), 1 destination(s), and 1 association(s); 1 transformation clue(s) present; high-signal streams: Microsoft-WindowsEvent, Microsoft-Syslog; destinations: logAnalytics.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration,dcr-ama-migration,rg-monitor,eastus,,AMA migration routing for batch fleet,,"[""logFiles"",""performanceCounters""]","[""Custom-AppText_CL"",""Microsoft-Perf""]",null,"[""eventHubs""]",0,1,"[{""name"":""perf-default"",""type"":""performanceCounters"",""streams"":[""Microsoft-Perf""],""transform_kql_present"":false},{""name"":""custom-text"",""type"":""logFiles"",""streams"":[""Custom-AppText_CL""],""transform_kql_present"":false}]","[{""streams"":[""Microsoft-Perf"",""Custom-AppText_CL""],""destinations"":[""migration-eventhub""],""transform_kql_present"":false}]","[{""name"":""migration-eventhub"",""type"":""eventHubs"",""resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""detail"":""monitoring-migration""}]","[{""id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association"",""name"":""batch-migration-association"",""target_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch"",""data_collection_rule_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration"",""description"":""Batch fleet AMA migration""}]","DCR ""dcr-ama-migration"" has 2 data source(s), 1 data flow(s), 1 destination(s), and 1 association(s); destinations: eventHubs.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch""]" diff --git a/testdata/dcr.golden.json b/testdata/dcr.golden.json new file mode 100644 index 0000000..b2c5660 --- /dev/null +++ b/testdata/dcr.golden.json @@ -0,0 +1,177 @@ +{ + "dcrs": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "name": "dcr-prod-host", + "resource_group": "rg-monitor", + "location": "eastus", + "description": "Production host collection rule with cost-control transform", + "data_sources": [ + { + "name": "windows-security-events", + "type": "windowsEventLogs", + "streams": [ + "Microsoft-WindowsEvent" + ], + "transform_kql_present": false + }, + { + "name": "linux-syslog", + "type": "syslog", + "streams": [ + "Microsoft-Syslog" + ], + "transform_kql_present": false + } + ], + "data_flows": [ + { + "streams": [ + "Microsoft-WindowsEvent" + ], + "destinations": [ + "soc-workspace" + ], + "transform_kql_present": true, + "transform_kql_fingerprint": "31c5a1b7dd8e", + "transform_kql_length": 84 + }, + { + "streams": [ + "Microsoft-Syslog" + ], + "destinations": [ + "soc-workspace" + ], + "transform_kql_present": false + } + ], + "destinations": [ + { + "name": "soc-workspace", + "type": "logAnalytics", + "resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "detail": "soc-workspace" + } + ], + "associations": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association", + "name": "prod-host-association", + "target_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01", + "data_collection_rule_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "description": "Production host association" + } + ], + "data_source_types": [ + "syslog", + "windowsEventLogs" + ], + "streams": [ + "Microsoft-Syslog", + "Microsoft-WindowsEvent" + ], + "high_signal_streams": [ + "Microsoft-WindowsEvent", + "Microsoft-Syslog" + ], + "destination_types": [ + "logAnalytics" + ], + "transformation_count": 1, + "association_count": 1, + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + ], + "summary": "DCR \"dcr-prod-host\" has 2 data source(s), 2 data flow(s), 1 destination(s), and 1 association(s); 1 transformation clue(s) present; high-signal streams: Microsoft-WindowsEvent, Microsoft-Syslog; destinations: logAnalytics." + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "name": "dcr-ama-migration", + "resource_group": "rg-monitor", + "location": "eastus", + "description": "AMA migration routing for batch fleet", + "data_sources": [ + { + "name": "perf-default", + "type": "performanceCounters", + "streams": [ + "Microsoft-Perf" + ], + "transform_kql_present": false + }, + { + "name": "custom-text", + "type": "logFiles", + "streams": [ + "Custom-AppText_CL" + ], + "transform_kql_present": false + } + ], + "data_flows": [ + { + "streams": [ + "Microsoft-Perf", + "Custom-AppText_CL" + ], + "destinations": [ + "migration-eventhub" + ], + "transform_kql_present": false + } + ], + "destinations": [ + { + "name": "migration-eventhub", + "type": "eventHubs", + "resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "detail": "monitoring-migration" + } + ], + "associations": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association", + "name": "batch-migration-association", + "target_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch", + "data_collection_rule_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "description": "Batch fleet AMA migration" + } + ], + "data_source_types": [ + "logFiles", + "performanceCounters" + ], + "streams": [ + "Custom-AppText_CL", + "Microsoft-Perf" + ], + "high_signal_streams": null, + "destination_types": [ + "eventHubs" + ], + "transformation_count": 0, + "association_count": 1, + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch" + ], + "summary": "DCR \"dcr-ama-migration\" has 2 data source(s), 1 data flow(s), 1 destination(s), and 1 association(s); destinations: eventHubs." + } + ], + "findings": [], + "issues": [], + "metadata": { + "command": "dcr", + "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 + } +} diff --git a/testdata/dcr.golden.table.txt b/testdata/dcr.golden.table.txt new file mode 100644 index 0000000..3f320ba --- /dev/null +++ b/testdata/dcr.golden.table.txt @@ -0,0 +1,45 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[dcr] Reviewing Data Collection Rules for collection, stream, destination, association, and transformation posture. +table view is compact by design; the JSON artifact keeps the fuller visible field set +ho-azure dcr + +╭───────────────────┬────────────────────────────────┬────────────────────────────────────┬───────────────────────────────────┬──────────────┬──────────────╮ +│ dcr │ scope │ streams │ destinations │ transforms │ associations │ +├───────────────────┼────────────────────────────────┼────────────────────────────────────┼───────────────────────────────────┼──────────────┼──────────────┤ +│ dcr-prod-host │ rg=rg-monitor; location=eastus │ 2 stream(s); high-signal: │ soc-workspace (logAnalytics) -> │ 1 present │ vm-web-01 │ +│ │ │ Microsoft-WindowsEvent, │ law-soc-prod │ │ │ +│ │ │ Microsoft-Syslog; sources: syslog, │ │ │ │ +│ │ │ windowsEventLogs │ │ │ │ +│ dcr-ama-migration │ rg=rg-monitor; location=eastus │ 2 stream(s); sources: logFiles, │ migration-eventhub (eventHubs) -> │ none visible │ vmss-batch │ +│ │ │ performanceCounters │ send │ │ │ +╰───────────────────┴────────────────────────────────┴────────────────────────────────────┴───────────────────────────────────┴──────────────┴──────────────╯ + +Takeaway: 2 DCR(s) visible; 1 show transformation posture, 1 carry high-signal streams, and 2 have visible associations. + +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ operator notes │ +├──────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ dcr-prod-host: DCR "dcr-prod-host" has 2 data source(s), 2 data flow(s), 1 destination(s), and 1 │ +│ association(s); 1 transformation clue(s) present; high-signal streams: Microsoft-WindowsEvent, │ +│ Microsoft-Syslog; destinations: logAnalytics. │ +│ Evasion read: transformation posture is visible on high-signal streams, so logs may still arrive │ +│ while selected records or fields are filtered or reshaped before storage. │ +│ The command does not print transformKql or claim malicious filtering. │ +│ Destination read: current destinations are named, but this command does not claim they are wrong │ +│ without an expected workspace baseline. │ +│ Association read: the visible association scope shows where the DCR is intended to apply; │ +│ runtime agent applied-state is not proven. │ +│ dcr-ama-migration: DCR "dcr-ama-migration" has 2 data source(s), 1 data flow(s), 1 │ +│ destination(s), and 1 association(s); destinations: eventHubs. │ +│ Destination read: current destinations are named, but this command does not claim they are wrong │ +│ without an expected workspace baseline. │ +│ Association read: the visible association scope shows where the DCR is intended to apply; │ +│ runtime agent applied-state is not proven. │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Not collected by default: +- log arrival/filtering proof: proof boundary; querying workspace or sink contents can be noisy and would change this from management-plane posture review into evidence validation +- agent applied-state proof: proof boundary; this command shows configured DCR associations, not whether every target agent applied the rule +- activity-log history: API/noise; actor, timing, quick-revert, and maintenance-window proof belongs in an explicit history mode diff --git a/testdata/diagnostic-settings.golden.csv b/testdata/diagnostic-settings.golden.csv new file mode 100644 index 0000000..42f232b --- /dev/null +++ b/testdata/diagnostic-settings.golden.csv @@ -0,0 +1,4 @@ +id,name,type,resource_group,location,diagnostic_setting_count,has_diagnostic_settings,has_partial_log_posture,has_high_signal_log_posture,has_non_workspace_destination,enabled_categories,disabled_categories,supported_categories,not_exported_supported_categories,supported_category_catalog,category_groups,high_signal_categories,destination_types,diagnostic_settings,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod,kv-prod,Microsoft.KeyVault/vaults,rg-sec,eastus,1,true,true,true,false,"[""AllMetrics""]","[""AuditEvent""]","[""AllMetrics"",""AuditEvent""]","[""AuditEvent""]",true,"[""AuditEvent""]","[""AuditEvent""]","[""logAnalytics""]","[{""id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit"",""name"":""send-audit"",""source_resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod"",""destinations"":[{""type"":""logAnalytics"",""resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod"",""detail"":""Dedicated""}],""logs"":[{""name"":""AuditEvent"",""type"":""log"",""enabled"":false}],""metrics"":[{""name"":""AllMetrics"",""type"":""metric"",""enabled"":true}],""enabled_categories"":[""AllMetrics""],""disabled_categories"":[""AuditEvent""],""category_groups"":[""AuditEvent""],""high_signal_categories"":[""AuditEvent""],""destination_types"":[""logAnalytics""],""related_ids"":[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod""],""summary"":""diagnostic setting \""send-audit\"" exports 1 enabled categor(ies), has 1 disabled categor(ies), and routes to logAnalytics.""}]","Microsoft.KeyVault/vaults ""kv-prod"" has 1 diagnostic setting(s), 1 enabled categor(ies), 1 disabled categor(ies), and destinations: logAnalytics.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod,stdataprod,Microsoft.Storage/storageAccounts,rg-data,eastus,1,true,true,true,true,"[""StorageRead"",""StorageWrite""]",null,"[""StorageDelete"",""StorageRead"",""StorageWrite""]","[""StorageDelete""]",true,null,null,"[""eventHubs""]","[{""id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage"",""name"":""archive-storage"",""source_resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"",""destinations"":[{""type"":""eventHubs"",""resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""detail"":""monitoring-migration""}],""logs"":[{""name"":""StorageRead"",""type"":""log"",""enabled"":true},{""name"":""StorageWrite"",""type"":""log"",""enabled"":true}],""metrics"":[],""enabled_categories"":[""StorageRead"",""StorageWrite""],""disabled_categories"":null,""category_groups"":null,""high_signal_categories"":[],""destination_types"":[""eventHubs""],""related_ids"":[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage""],""summary"":""diagnostic setting \""archive-storage\"" exports 2 enabled categor(ies), has 0 disabled categor(ies), and routes to eventHubs.""}]","Microsoft.Storage/storageAccounts ""stdataprod"" has 1 diagnostic setting(s), 2 enabled categor(ies), 0 disabled categor(ies), and destinations: eventHubs.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod,app-prod,Microsoft.Web/sites,rg-app,eastus,0,false,false,true,false,null,null,"[""AppServiceHTTPLogs"",""AppServiceConsoleLogs""]","[""AppServiceConsoleLogs"",""AppServiceHTTPLogs""]",true,null,null,null,[],"Microsoft.Web/sites ""app-prod"" has no visible diagnostic settings.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod""]" diff --git a/testdata/diagnostic-settings.golden.json b/testdata/diagnostic-settings.golden.json new file mode 100644 index 0000000..5dd09e1 --- /dev/null +++ b/testdata/diagnostic-settings.golden.json @@ -0,0 +1,215 @@ +{ + "sources": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "name": "kv-prod", + "type": "Microsoft.KeyVault/vaults", + "resource_group": "rg-sec", + "location": "eastus", + "diagnostic_settings": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit", + "name": "send-audit", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "destinations": [ + { + "type": "logAnalytics", + "resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "detail": "Dedicated" + } + ], + "logs": [ + { + "name": "AuditEvent", + "type": "log", + "enabled": false + } + ], + "metrics": [ + { + "name": "AllMetrics", + "type": "metric", + "enabled": true + } + ], + "enabled_categories": [ + "AllMetrics" + ], + "disabled_categories": [ + "AuditEvent" + ], + "category_groups": [ + "AuditEvent" + ], + "high_signal_categories": [ + "AuditEvent" + ], + "destination_types": [ + "logAnalytics" + ], + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + ], + "summary": "diagnostic setting \"send-audit\" exports 1 enabled categor(ies), has 1 disabled categor(ies), and routes to logAnalytics." + } + ], + "diagnostic_setting_count": 1, + "enabled_categories": [ + "AllMetrics" + ], + "disabled_categories": [ + "AuditEvent" + ], + "supported_categories": [ + "AllMetrics", + "AuditEvent" + ], + "not_exported_supported_categories": [ + "AuditEvent" + ], + "supported_category_catalog": true, + "category_groups": [ + "AuditEvent" + ], + "high_signal_categories": [ + "AuditEvent" + ], + "destination_types": [ + "logAnalytics" + ], + "has_diagnostic_settings": true, + "has_partial_log_posture": true, + "has_high_signal_log_posture": true, + "has_non_workspace_destination": false, + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + ], + "summary": "Microsoft.KeyVault/vaults \"kv-prod\" has 1 diagnostic setting(s), 1 enabled categor(ies), 1 disabled categor(ies), and destinations: logAnalytics." + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "name": "stdataprod", + "type": "Microsoft.Storage/storageAccounts", + "resource_group": "rg-data", + "location": "eastus", + "diagnostic_settings": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage", + "name": "archive-storage", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "destinations": [ + { + "type": "eventHubs", + "resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "detail": "monitoring-migration" + } + ], + "logs": [ + { + "name": "StorageRead", + "type": "log", + "enabled": true + }, + { + "name": "StorageWrite", + "type": "log", + "enabled": true + } + ], + "metrics": [], + "enabled_categories": [ + "StorageRead", + "StorageWrite" + ], + "disabled_categories": null, + "category_groups": null, + "high_signal_categories": [], + "destination_types": [ + "eventHubs" + ], + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage" + ], + "summary": "diagnostic setting \"archive-storage\" exports 2 enabled categor(ies), has 0 disabled categor(ies), and routes to eventHubs." + } + ], + "diagnostic_setting_count": 1, + "enabled_categories": [ + "StorageRead", + "StorageWrite" + ], + "disabled_categories": null, + "supported_categories": [ + "StorageDelete", + "StorageRead", + "StorageWrite" + ], + "not_exported_supported_categories": [ + "StorageDelete" + ], + "supported_category_catalog": true, + "category_groups": null, + "high_signal_categories": null, + "destination_types": [ + "eventHubs" + ], + "has_diagnostic_settings": true, + "has_partial_log_posture": true, + "has_high_signal_log_posture": true, + "has_non_workspace_destination": true, + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage" + ], + "summary": "Microsoft.Storage/storageAccounts \"stdataprod\" has 1 diagnostic setting(s), 2 enabled categor(ies), 0 disabled categor(ies), and destinations: eventHubs." + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod", + "name": "app-prod", + "type": "Microsoft.Web/sites", + "resource_group": "rg-app", + "location": "eastus", + "diagnostic_settings": [], + "diagnostic_setting_count": 0, + "enabled_categories": null, + "disabled_categories": null, + "supported_categories": [ + "AppServiceHTTPLogs", + "AppServiceConsoleLogs" + ], + "not_exported_supported_categories": [ + "AppServiceConsoleLogs", + "AppServiceHTTPLogs" + ], + "supported_category_catalog": true, + "category_groups": null, + "high_signal_categories": null, + "destination_types": null, + "has_diagnostic_settings": false, + "has_partial_log_posture": false, + "has_high_signal_log_posture": true, + "has_non_workspace_destination": false, + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod" + ], + "summary": "Microsoft.Web/sites \"app-prod\" has no visible diagnostic settings." + } + ], + "findings": [], + "issues": [], + "metadata": { + "command": "diagnostic-settings", + "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 + } +} diff --git a/testdata/diagnostic-settings.golden.table.txt b/testdata/diagnostic-settings.golden.table.txt new file mode 100644 index 0000000..d80ca9d --- /dev/null +++ b/testdata/diagnostic-settings.golden.table.txt @@ -0,0 +1,25 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[diagnostic-settings] Reviewing resource diagnostic settings for selected categories, metrics, destinations, and visible telemetry export posture. +table view is compact by design; the JSON artifact keeps the fuller visible field set +ho-azure diagnostic-settings + +╭────────────┬───────────────────────────────────┬──────────────┬──────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ source │ type │ settings │ destinations │ categories │ +├────────────┼───────────────────────────────────┼──────────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ kv-prod │ Microsoft.KeyVault/vaults │ 1 visible │ logAnalytics │ enabled: AllMetrics; not exported by visible setting: AuditEvent; supported not exported: AuditEvent; high-signal: AuditEvent │ +│ stdataprod │ Microsoft.Storage/storageAccounts │ 1 visible │ eventHubs │ enabled: StorageRead, StorageWrite; supported not exported: StorageDelete │ +│ app-prod │ Microsoft.Web/sites │ none visible │ none visible │ supported not exported: AppServiceConsoleLogs, AppServiceHTTPLogs │ +╰────────────┴───────────────────────────────────┴──────────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Takeaway: 3 source(s) visible; 2 have diagnostic settings, 2 show partial category posture, and 1 route to non-Log Analytics destinations. + +Not collected by default +item | classification | reason + +unsupported category proof | proof boundary | When Azure rejects category catalog reads for a source type, the helper reports a collection issue instead of treating omitted categories as unsupported. +activity-log history | API/noise | Change timing and actor proof require history collection, which is not needed for the default posture view. +sink contents | proof boundary | Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture. +detector wiring | proof boundary | The command does not inspect Sentinel or defender rule dependencies, so it cannot claim a detection failed. +expected SOC destination baseline | scope/sequencing | Destination drift needs an expected sink model before the tool can call the current sink wrong. diff --git a/testdata/evasion-appinsights.golden.csv b/testdata/evasion-appinsights.golden.csv new file mode 100644 index 0000000..034e3a5 --- /dev/null +++ b/testdata/evasion-appinsights.golden.csv @@ -0,0 +1,3 @@ +id,target,resource_group,location,disruption_rank,disruption_reason,capability_steps,current_identity_summary,current_state,not_collected_by_default,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api,app-public-api,rg-apps,eastus,5,filtering and sampling posture clues are both visible; current identity has visible app configuration write control,"[{""action"":""change instrumentation posture"",""api_surface"":""Microsoft.Web/sites/config/write"",""status"":""yes"",""downstream_effect"":""Changes app settings that can control Application Insights instrumentation behavior."",""boundary"":""Does not read code-level instrumentation bodies.""},{""action"":""choose telemetry target"",""api_surface"":""Application Insights component and app settings"",""status"":""yes"",""downstream_effect"":""Selects the instrumented app or function where telemetry is shaped."",""boundary"":""A visible setting name is a posture clue, not proof of emitted telemetry.""},{""action"":""configure sampling"",""api_surface"":""sampling app settings or SDK/OpenTelemetry config clues"",""status"":""yes"",""downstream_effect"":""Can reduce retained request, dependency, trace, or exception examples while dashboards remain alive."",""boundary"":""Does not prove true unsampled event count.""},{""action"":""configure filtering or logging level"",""api_surface"":""filter, processor, or logging-level setting clues"",""status"":""yes"",""downstream_effect"":""Can narrow selected telemetry types before investigators query Application Insights."",""boundary"":""Does not prove filtered events occurred.""},{""action"":""preserve app-side config"",""api_surface"":""stored app settings"",""status"":""yes"",""downstream_effect"":""The instrumentation posture remains as normal application configuration until changed."",""boundary"":""Change timing and author require history.""},{""action"":""blend as observability tuning"",""api_surface"":""app setting names and component posture"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include cost control, health-check filtering, privacy, and performance tuning."",""boundary"":""Cover story is not an intent claim.""}]",Current foothold `azurefox-lab-sp` has visible app configuration write control.,"{""kind"":""AppService"",""instrumentation_clues"":[""APPLICATIONINSIGHTS_CONNECTION_STRING""],""sampling_clues"":[""ApplicationInsights__Sampling__Percentage=25""],""filtering_clues"":[""ApplicationInsights__TelemetryProcessor__HealthCheckFilter""],""logging_level_clues"":[""Logging__ApplicationInsights__LogLevel__Default=Warning""],""visible_telemetry_types"":[""traces""],""posture"":""filtering clue(s) visible; sampling clue(s) visible; logging-level clue(s) visible""}","[{""name"":""setting values"",""classification"":""recon safety"",""reason"":""Default output uses setting names as posture clues and does not print instrumentation keys or connection strings.""},{""name"":""code-level processors"",""classification"":""proof boundary"",""reason"":""Telemetry processor bodies usually live in source code or binaries, outside management-plane posture.""},{""name"":""host.json body"",""classification"":""collector issue"",""reason"":""Function sampling can live in host.json; this helper only uses visible app setting names by default.""},{""name"":""true unsampled count"",""classification"":""proof boundary"",""reason"":""Current posture cannot prove how many events were dropped or retained.""},{""name"":""detector failure"",""classification"":""proof boundary"",""reason"":""The command does not inspect detections, so it cannot claim a rule missed activity.""}]","target ""app-public-api"" ranks 5/5 for Application Insights truth-disruption posture; 1 filtering clue(s); 1 sampling clue(s); current identity can modify app configuration from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders,func-orders,rg-apps,eastus,3,sampling posture clues can reduce retained event examples; current identity has visible app configuration write control,"[{""action"":""change instrumentation posture"",""api_surface"":""Microsoft.Web/sites/config/write"",""status"":""yes"",""downstream_effect"":""Changes app settings that can control Application Insights instrumentation behavior."",""boundary"":""Does not read code-level instrumentation bodies.""},{""action"":""choose telemetry target"",""api_surface"":""Application Insights component and app settings"",""status"":""yes"",""downstream_effect"":""Selects the instrumented app or function where telemetry is shaped."",""boundary"":""A visible setting name is a posture clue, not proof of emitted telemetry.""},{""action"":""configure sampling"",""api_surface"":""sampling app settings or SDK/OpenTelemetry config clues"",""status"":""yes"",""downstream_effect"":""Can reduce retained request, dependency, trace, or exception examples while dashboards remain alive."",""boundary"":""Does not prove true unsampled event count.""},{""action"":""configure filtering or logging level"",""api_surface"":""filter, processor, or logging-level setting clues"",""status"":""yes"",""downstream_effect"":""Can narrow selected telemetry types before investigators query Application Insights."",""boundary"":""Does not prove filtered events occurred.""},{""action"":""preserve app-side config"",""api_surface"":""stored app settings"",""status"":""yes"",""downstream_effect"":""The instrumentation posture remains as normal application configuration until changed."",""boundary"":""Change timing and author require history.""},{""action"":""blend as observability tuning"",""api_surface"":""app setting names and component posture"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include cost control, health-check filtering, privacy, and performance tuning."",""boundary"":""Cover story is not an intent claim.""}]",Current foothold `azurefox-lab-sp` has visible app configuration write control.,"{""kind"":""FunctionApp"",""instrumentation_clues"":[""APPINSIGHTS_INSTRUMENTATIONKEY""],""sampling_clues"":[""AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true""],""filtering_clues"":[],""logging_level_clues"":[],""visible_telemetry_types"":[],""posture"":""sampling clue(s) visible""}","[{""name"":""setting values"",""classification"":""recon safety"",""reason"":""Default output uses setting names as posture clues and does not print instrumentation keys or connection strings.""},{""name"":""code-level processors"",""classification"":""proof boundary"",""reason"":""Telemetry processor bodies usually live in source code or binaries, outside management-plane posture.""},{""name"":""host.json body"",""classification"":""collector issue"",""reason"":""Function sampling can live in host.json; this helper only uses visible app setting names by default.""},{""name"":""true unsampled count"",""classification"":""proof boundary"",""reason"":""Current posture cannot prove how many events were dropped or retained.""},{""name"":""detector failure"",""classification"":""proof boundary"",""reason"":""The command does not inspect detections, so it cannot claim a rule missed activity.""}]","target ""func-orders"" ranks 3/5 for Application Insights truth-disruption posture; 1 sampling clue(s); current identity can modify app configuration from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]" diff --git a/testdata/evasion-appinsights.golden.json b/testdata/evasion-appinsights.golden.json new file mode 100644 index 0000000..92e665f --- /dev/null +++ b/testdata/evasion-appinsights.golden.json @@ -0,0 +1,265 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "evasion", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "evasion", + "surface": "appinsights", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture clues.", + "backing_commands": [ + "appinsights", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api", + "target": "app-public-api", + "resource_group": "rg-apps", + "location": "eastus", + "disruption_rank": 5, + "disruption_reason": "filtering and sampling posture clues are both visible; current identity has visible app configuration write control", + "capability_steps": [ + { + "action": "change instrumentation posture", + "api_surface": "Microsoft.Web/sites/config/write", + "status": "yes", + "downstream_effect": "Changes app settings that can control Application Insights instrumentation behavior.", + "boundary": "Does not read code-level instrumentation bodies." + }, + { + "action": "choose telemetry target", + "api_surface": "Application Insights component and app settings", + "status": "yes", + "downstream_effect": "Selects the instrumented app or function where telemetry is shaped.", + "boundary": "A visible setting name is a posture clue, not proof of emitted telemetry." + }, + { + "action": "configure sampling", + "api_surface": "sampling app settings or SDK/OpenTelemetry config clues", + "status": "yes", + "downstream_effect": "Can reduce retained request, dependency, trace, or exception examples while dashboards remain alive.", + "boundary": "Does not prove true unsampled event count." + }, + { + "action": "configure filtering or logging level", + "api_surface": "filter, processor, or logging-level setting clues", + "status": "yes", + "downstream_effect": "Can narrow selected telemetry types before investigators query Application Insights.", + "boundary": "Does not prove filtered events occurred." + }, + { + "action": "preserve app-side config", + "api_surface": "stored app settings", + "status": "yes", + "downstream_effect": "The instrumentation posture remains as normal application configuration until changed.", + "boundary": "Change timing and author require history." + }, + { + "action": "blend as observability tuning", + "api_surface": "app setting names and component posture", + "status": "visible posture only", + "downstream_effect": "Common cover stories include cost control, health-check filtering, privacy, and performance tuning.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "app config write", + "summary": "Current foothold `azurefox-lab-sp` has visible app configuration write control." + }, + "current_state": { + "kind": "AppService", + "instrumentation_clues": [ + "APPLICATIONINSIGHTS_CONNECTION_STRING" + ], + "sampling_clues": [ + "ApplicationInsights__Sampling__Percentage=25" + ], + "filtering_clues": [ + "ApplicationInsights__TelemetryProcessor__HealthCheckFilter" + ], + "logging_level_clues": [ + "Logging__ApplicationInsights__LogLevel__Default=Warning" + ], + "visible_telemetry_types": [ + "traces" + ], + "posture": "filtering clue(s) visible; sampling clue(s) visible; logging-level clue(s) visible" + }, + "not_collected_by_default": [ + { + "name": "setting values", + "classification": "recon safety", + "reason": "Default output uses setting names as posture clues and does not print instrumentation keys or connection strings." + }, + { + "name": "code-level processors", + "classification": "proof boundary", + "reason": "Telemetry processor bodies usually live in source code or binaries, outside management-plane posture." + }, + { + "name": "host.json body", + "classification": "collector issue", + "reason": "Function sampling can live in host.json; this helper only uses visible app setting names by default." + }, + { + "name": "true unsampled count", + "classification": "proof boundary", + "reason": "Current posture cannot prove how many events were dropped or retained." + }, + { + "name": "detector failure", + "classification": "proof boundary", + "reason": "The command does not inspect detections, so it cannot claim a rule missed activity." + } + ], + "summary": "target \"app-public-api\" ranks 5/5 for Application Insights truth-disruption posture; 1 filtering clue(s); 1 sampling clue(s); current identity can modify app configuration from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders", + "target": "func-orders", + "resource_group": "rg-apps", + "location": "eastus", + "disruption_rank": 3, + "disruption_reason": "sampling posture clues can reduce retained event examples; current identity has visible app configuration write control", + "capability_steps": [ + { + "action": "change instrumentation posture", + "api_surface": "Microsoft.Web/sites/config/write", + "status": "yes", + "downstream_effect": "Changes app settings that can control Application Insights instrumentation behavior.", + "boundary": "Does not read code-level instrumentation bodies." + }, + { + "action": "choose telemetry target", + "api_surface": "Application Insights component and app settings", + "status": "yes", + "downstream_effect": "Selects the instrumented app or function where telemetry is shaped.", + "boundary": "A visible setting name is a posture clue, not proof of emitted telemetry." + }, + { + "action": "configure sampling", + "api_surface": "sampling app settings or SDK/OpenTelemetry config clues", + "status": "yes", + "downstream_effect": "Can reduce retained request, dependency, trace, or exception examples while dashboards remain alive.", + "boundary": "Does not prove true unsampled event count." + }, + { + "action": "configure filtering or logging level", + "api_surface": "filter, processor, or logging-level setting clues", + "status": "yes", + "downstream_effect": "Can narrow selected telemetry types before investigators query Application Insights.", + "boundary": "Does not prove filtered events occurred." + }, + { + "action": "preserve app-side config", + "api_surface": "stored app settings", + "status": "yes", + "downstream_effect": "The instrumentation posture remains as normal application configuration until changed.", + "boundary": "Change timing and author require history." + }, + { + "action": "blend as observability tuning", + "api_surface": "app setting names and component posture", + "status": "visible posture only", + "downstream_effect": "Common cover stories include cost control, health-check filtering, privacy, and performance tuning.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "app config write", + "summary": "Current foothold `azurefox-lab-sp` has visible app configuration write control." + }, + "current_state": { + "kind": "FunctionApp", + "instrumentation_clues": [ + "APPINSIGHTS_INSTRUMENTATIONKEY" + ], + "sampling_clues": [ + "AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true" + ], + "filtering_clues": [], + "logging_level_clues": [], + "visible_telemetry_types": [], + "posture": "sampling clue(s) visible" + }, + "not_collected_by_default": [ + { + "name": "setting values", + "classification": "recon safety", + "reason": "Default output uses setting names as posture clues and does not print instrumentation keys or connection strings." + }, + { + "name": "code-level processors", + "classification": "proof boundary", + "reason": "Telemetry processor bodies usually live in source code or binaries, outside management-plane posture." + }, + { + "name": "host.json body", + "classification": "collector issue", + "reason": "Function sampling can live in host.json; this helper only uses visible app setting names by default." + }, + { + "name": "true unsampled count", + "classification": "proof boundary", + "reason": "Current posture cannot prove how many events were dropped or retained." + }, + { + "name": "detector failure", + "classification": "proof boundary", + "reason": "The command does not inspect detections, so it cannot claim a rule missed activity." + } + ], + "summary": "target \"func-orders\" ranks 3/5 for Application Insights truth-disruption posture; 1 sampling clue(s); current identity can modify app configuration from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ] + } + ], + "components": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/components/ai-public-api", + "name": "ai-public-api", + "resource_group": "rg-monitor", + "location": "eastus", + "kind": "web", + "application_type": "web", + "workspace_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "ingestion_mode": "LogAnalytics", + "summary": "Application Insights component \"ai-public-api\" is visible in eastus.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/components/ai-public-api" + ] + } + ], + "issues": [] +} diff --git a/testdata/evasion-appinsights.golden.table.txt b/testdata/evasion-appinsights.golden.table.txt new file mode 100644 index 0000000..5940b0f --- /dev/null +++ b/testdata/evasion-appinsights.golden.table.txt @@ -0,0 +1,40 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[evasion] Application Insights evasion means visible instrumentation, sampling, filtering, and logging-level posture can reduce retained telemetry while the app still emits health signals. +Application Insights evasion capability + +╭──────────────────────────────────────┬─────────────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├──────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────┤ +│ change instrumentation posture │ Microsoft.Web/sites/config/write │ yes │ +│ choose telemetry target │ Application Insights component and app settings │ yes │ +│ configure sampling │ sampling app settings or SDK/OpenTelemetry config clues │ yes │ +│ configure filtering or logging level │ filter, processor, or logging-level setting clues │ yes │ +│ preserve app-side config │ stored app settings │ yes │ +│ blend as observability tuning │ app setting names and component posture │ visible posture only │ +╰──────────────────────────────────────┴─────────────────────────────────────────────────────────┴──────────────────────╯ +This walkthrough shows the strongest currently visible Application Insights truth-disruption path. The inventory below lists the other visible targets without repeating the same narrative. + +Operator read +target "app-public-api" ranks 5/5 for Application Insights truth-disruption posture; 1 filtering clue(s); 1 sampling clue(s); current identity can modify app configuration from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible app configuration write control. +Downstream effect: filtering and sampling posture clues are both visible; current identity has visible app configuration write control +First boundary: this is visible Application Insights and app-setting posture, not code-body proof, runtime proof, or detector-failure proof. +Posture: filtering clue(s) visible; sampling clue(s) visible; logging-level clue(s) visible. + +Visible Targets +target | rank | kind | sampling | filtering | current identity + +app-public-api | 5/5 | AppService | ApplicationInsights__Sampling__Percentage=25 | ApplicationInsights__TelemetryProcessor__HealthCheckFilter | app config write +func-orders | 3/5 | FunctionApp | AzureFunctionsJobHost__logging__applicationInsights__samplingSettings__isEnabled=true | none visible | app config write + + +Not collected by default +item | classification | reason + +setting values | recon safety | Default output uses setting names as posture clues and does not print instrumentation keys or connection strings. +code-level processors | proof boundary | Telemetry processor bodies usually live in source code or binaries, outside management-plane posture. +host.json body | collector issue | Function sampling can live in host.json; this helper only uses visible app setting names by default. +true unsampled count | proof boundary | Current posture cannot prove how many events were dropped or retained. +detector failure | proof boundary | The command does not inspect detections, so it cannot claim a rule missed activity. diff --git a/testdata/evasion-dcr.golden.csv b/testdata/evasion-dcr.golden.csv new file mode 100644 index 0000000..10b6873 --- /dev/null +++ b/testdata/evasion-dcr.golden.csv @@ -0,0 +1,3 @@ +id,dcr,resource_group,location,disruption_rank,disruption_reason,capability_steps,current_identity_summary,current_state,not_collected_by_default,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host,dcr-prod-host,rg-monitor,eastus,5,transformations can alter selected high-signal data while collection remains configured; current identity has visible rule and association write control,"[{""action"":""choose or create DCR"",""api_surface"":""Microsoft.Insights/dataCollectionRules/write"",""status"":""yes"",""downstream_effect"":""Sets the Azure Monitor object that can define collection, data flows, destinations, and transform posture."",""boundary"":""Does not prove a monitored agent has applied the rule.""},{""action"":""associate monitored scope"",""api_surface"":""Microsoft.Insights/dataCollectionRuleAssociations/write"",""status"":""yes"",""downstream_effect"":""Selects which visible resource scope receives the DCR collection and routing posture."",""boundary"":""Does not prove runtime agent state or log arrival.""},{""action"":""select data sources and streams"",""api_surface"":""dataSources / dataFlows.streams"",""status"":""yes"",""downstream_effect"":""Controls which telemetry classes are collected, including host and security-adjacent streams when present."",""boundary"":""Ranks by visible stream value only; missing expected streams require a defended baseline.""},{""action"":""configure data flows and transformations"",""api_surface"":""dataFlows.transformKql"",""status"":""yes"",""downstream_effect"":""Can filter or reshape selected records before storage while the pipeline still appears configured."",""boundary"":""Prints only transform presence, fingerprint, and length; it does not print transformKql or infer intent.""},{""action"":""select destinations"",""api_surface"":""destinations / dataFlows.destinations"",""status"":""yes"",""downstream_effect"":""Chooses where collected data goes, which can preserve logging while moving it away from a SOC workspace."",""boundary"":""Does not claim destination drift without an expected destination model.""},{""action"":""save or re-associate rule"",""api_surface"":""DCR write plus association write"",""status"":""yes"",""downstream_effect"":""Makes the Azure-side collection posture durable as management-plane configuration."",""boundary"":""Persistence here means Azure configuration remains until changed; runtime application is not proven.""},{""action"":""shape defender truth"",""api_surface"":""streams, dataFlows, destinations, transformKql"",""status"":""yes"",""downstream_effect"":""Visible levers can narrow collection, reroute telemetry, or transform records without a full logging disablement."",""boundary"":""Does not claim malicious filtering or downstream detector failure from posture alone.""},{""action"":""preserve Azure-side config"",""api_surface"":""stored DCR and association resources"",""status"":""yes"",""downstream_effect"":""The changed rule or association can remain in place like normal monitoring migration, cost, or schema configuration."",""boundary"":""History, author, and timing require activity-log evidence not collected here by default.""},{""action"":""blend as monitoring change"",""api_surface"":""DCR metadata and ARM posture"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include AMA migration, workspace consolidation, cost control, schema normalization, and noise reduction."",""boundary"":""Cover story is an administrative explanation, not a claim of benign or malicious intent.""}]",Current foothold `azurefox-lab-sp` has visible DCR write control and association write control.,"{""data_source_types"":[""syslog"",""windowsEventLogs""],""streams"":[""Microsoft-Syslog"",""Microsoft-WindowsEvent""],""high_signal_streams"":[""Microsoft-WindowsEvent"",""Microsoft-Syslog""],""destination_types"":[""logAnalytics""],""association_targets"":[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01""],""transformation_count"":1,""association_count"":1,""transformation_posture"":""transformation posture is visible on a DCR with high-signal streams"",""destination_posture"":""operator-selected destinations visible: logAnalytics""}","[{""name"":""transformKql body"",""classification"":""operational anomaly"",""reason"":""Presence, length, and fingerprint are enough for posture; printing full transform logic can expose sensitive filtering logic and encourages overclaiming intent.""},{""name"":""log arrival or missing-record proof"",""classification"":""proof boundary"",""reason"":""Management-plane DCR posture cannot prove which records arrived, were dropped, or were later queried from Log Analytics.""},{""name"":""agent applied-state"",""classification"":""product-model gap"",""reason"":""DCR association shows intended scope, not that the Azure Monitor Agent applied the rule on the target at runtime.""},{""name"":""activity-log history and actor timing"",""classification"":""API/noise"",""reason"":""Broad activity-log pulls can be noisy and are not required for the default posture view; use history only when sequencing is explicitly needed.""},{""name"":""expected SOC destination baseline"",""classification"":""scope/sequencing"",""reason"":""Destination drift needs a defended expected-workspace model before the tool can say the current destination is wrong.""}]","DCR ""dcr-prod-host"" ranks 5/5 for quiet truth-disruption posture; 1 transformation clue(s); high-signal streams: Microsoft-WindowsEvent, Microsoft-Syslog; destinations: logAnalytics; current identity can affect both rule content and association scope from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration,dcr-ama-migration,rg-monitor,eastus,2,destinations can move collected data without disabling collection; current identity has visible rule and association write control,"[{""action"":""choose or create DCR"",""api_surface"":""Microsoft.Insights/dataCollectionRules/write"",""status"":""yes"",""downstream_effect"":""Sets the Azure Monitor object that can define collection, data flows, destinations, and transform posture."",""boundary"":""Does not prove a monitored agent has applied the rule.""},{""action"":""associate monitored scope"",""api_surface"":""Microsoft.Insights/dataCollectionRuleAssociations/write"",""status"":""yes"",""downstream_effect"":""Selects which visible resource scope receives the DCR collection and routing posture."",""boundary"":""Does not prove runtime agent state or log arrival.""},{""action"":""select data sources and streams"",""api_surface"":""dataSources / dataFlows.streams"",""status"":""yes"",""downstream_effect"":""Controls which telemetry classes are collected, including host and security-adjacent streams when present."",""boundary"":""Ranks by visible stream value only; missing expected streams require a defended baseline.""},{""action"":""configure data flows and transformations"",""api_surface"":""dataFlows.transformKql"",""status"":""yes"",""downstream_effect"":""Can filter or reshape selected records before storage while the pipeline still appears configured."",""boundary"":""Prints only transform presence, fingerprint, and length; it does not print transformKql or infer intent.""},{""action"":""select destinations"",""api_surface"":""destinations / dataFlows.destinations"",""status"":""yes"",""downstream_effect"":""Chooses where collected data goes, which can preserve logging while moving it away from a SOC workspace."",""boundary"":""Does not claim destination drift without an expected destination model.""},{""action"":""save or re-associate rule"",""api_surface"":""DCR write plus association write"",""status"":""yes"",""downstream_effect"":""Makes the Azure-side collection posture durable as management-plane configuration."",""boundary"":""Persistence here means Azure configuration remains until changed; runtime application is not proven.""},{""action"":""shape defender truth"",""api_surface"":""streams, dataFlows, destinations, transformKql"",""status"":""yes"",""downstream_effect"":""Visible levers can narrow collection, reroute telemetry, or transform records without a full logging disablement."",""boundary"":""Does not claim malicious filtering or downstream detector failure from posture alone.""},{""action"":""preserve Azure-side config"",""api_surface"":""stored DCR and association resources"",""status"":""yes"",""downstream_effect"":""The changed rule or association can remain in place like normal monitoring migration, cost, or schema configuration."",""boundary"":""History, author, and timing require activity-log evidence not collected here by default.""},{""action"":""blend as monitoring change"",""api_surface"":""DCR metadata and ARM posture"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include AMA migration, workspace consolidation, cost control, schema normalization, and noise reduction."",""boundary"":""Cover story is an administrative explanation, not a claim of benign or malicious intent.""}]",Current foothold `azurefox-lab-sp` has visible DCR write control and association write control.,"{""data_source_types"":[""logFiles"",""performanceCounters""],""streams"":[""Custom-AppText_CL"",""Microsoft-Perf""],""high_signal_streams"":[],""destination_types"":[""eventHubs""],""association_targets"":[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch""],""transformation_count"":0,""association_count"":1,""transformation_posture"":""no transformation posture visible"",""destination_posture"":""operator-selected destinations visible: eventHubs""}","[{""name"":""transformKql body"",""classification"":""operational anomaly"",""reason"":""Presence, length, and fingerprint are enough for posture; printing full transform logic can expose sensitive filtering logic and encourages overclaiming intent.""},{""name"":""log arrival or missing-record proof"",""classification"":""proof boundary"",""reason"":""Management-plane DCR posture cannot prove which records arrived, were dropped, or were later queried from Log Analytics.""},{""name"":""agent applied-state"",""classification"":""product-model gap"",""reason"":""DCR association shows intended scope, not that the Azure Monitor Agent applied the rule on the target at runtime.""},{""name"":""activity-log history and actor timing"",""classification"":""API/noise"",""reason"":""Broad activity-log pulls can be noisy and are not required for the default posture view; use history only when sequencing is explicitly needed.""},{""name"":""expected SOC destination baseline"",""classification"":""scope/sequencing"",""reason"":""Destination drift needs a defended expected-workspace model before the tool can say the current destination is wrong.""}]","DCR ""dcr-ama-migration"" ranks 2/5 for quiet truth-disruption posture; destinations: eventHubs; current identity can affect both rule content and association scope from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch""]" diff --git a/testdata/evasion-dcr.golden.json b/testdata/evasion-dcr.golden.json new file mode 100644 index 0000000..f5f2fbf --- /dev/null +++ b/testdata/evasion-dcr.golden.json @@ -0,0 +1,362 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "evasion", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "evasion", + "surface": "dcr", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review Data Collection Rules for collection, stream, destination, association, and transformation posture that can quietly reshape monitoring truth.", + "backing_commands": [ + "dcr", + "permissions", + "rbac" + ], + "monitoring_sinks": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "name": "law-soc-prod", + "kind": "logAnalytics", + "resource_type": "Microsoft.OperationalInsights/workspaces", + "resource_group": "rg-monitor", + "location": "", + "visibility_source": "declared destination", + "references": [ + { + "source_command": "dcr", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "source_name": "dcr-prod-host", + "reference_name": "soc-workspace", + "reference_type": "logAnalytics", + "destination_detail": "soc-workspace" + } + ], + "reference_count": 1, + "summary": "logAnalytics sink \"law-soc-prod\" is visible through declared destination; referenced by 1 telemetry route(s).", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "name": "send", + "kind": "eventHubs", + "resource_type": "Microsoft.EventHub/namespaces", + "resource_group": "rg-monitor", + "location": "", + "visibility_source": "declared destination", + "references": [ + { + "source_command": "dcr", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "source_name": "dcr-ama-migration", + "reference_name": "migration-eventhub", + "reference_type": "eventHubs", + "destination_detail": "monitoring-migration" + } + ], + "reference_count": 1, + "summary": "eventHubs sink \"send\" is visible through declared destination; referenced by 1 telemetry route(s).", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration" + ] + } + ], + "dcrs": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "dcr": "dcr-prod-host", + "resource_group": "rg-monitor", + "location": "eastus", + "disruption_rank": 5, + "disruption_reason": "transformations can alter selected high-signal data while collection remains configured; current identity has visible rule and association write control", + "capability_steps": [ + { + "action": "choose or create DCR", + "api_surface": "Microsoft.Insights/dataCollectionRules/write", + "status": "yes", + "downstream_effect": "Sets the Azure Monitor object that can define collection, data flows, destinations, and transform posture.", + "boundary": "Does not prove a monitored agent has applied the rule." + }, + { + "action": "associate monitored scope", + "api_surface": "Microsoft.Insights/dataCollectionRuleAssociations/write", + "status": "yes", + "downstream_effect": "Selects which visible resource scope receives the DCR collection and routing posture.", + "boundary": "Does not prove runtime agent state or log arrival." + }, + { + "action": "select data sources and streams", + "api_surface": "dataSources / dataFlows.streams", + "status": "yes", + "downstream_effect": "Controls which telemetry classes are collected, including host and security-adjacent streams when present.", + "boundary": "Ranks by visible stream value only; missing expected streams require a defended baseline." + }, + { + "action": "configure data flows and transformations", + "api_surface": "dataFlows.transformKql", + "status": "yes", + "downstream_effect": "Can filter or reshape selected records before storage while the pipeline still appears configured.", + "boundary": "Prints only transform presence, fingerprint, and length; it does not print transformKql or infer intent." + }, + { + "action": "select destinations", + "api_surface": "destinations / dataFlows.destinations", + "status": "yes", + "downstream_effect": "Chooses where collected data goes, which can preserve logging while moving it away from a SOC workspace.", + "boundary": "Does not claim destination drift without an expected destination model." + }, + { + "action": "save or re-associate rule", + "api_surface": "DCR write plus association write", + "status": "yes", + "downstream_effect": "Makes the Azure-side collection posture durable as management-plane configuration.", + "boundary": "Persistence here means Azure configuration remains until changed; runtime application is not proven." + }, + { + "action": "shape defender truth", + "api_surface": "streams, dataFlows, destinations, transformKql", + "status": "yes", + "downstream_effect": "Visible levers can narrow collection, reroute telemetry, or transform records without a full logging disablement.", + "boundary": "Does not claim malicious filtering or downstream detector failure from posture alone." + }, + { + "action": "preserve Azure-side config", + "api_surface": "stored DCR and association resources", + "status": "yes", + "downstream_effect": "The changed rule or association can remain in place like normal monitoring migration, cost, or schema configuration.", + "boundary": "History, author, and timing require activity-log evidence not collected here by default." + }, + { + "action": "blend as monitoring change", + "api_surface": "DCR metadata and ARM posture", + "status": "visible posture only", + "downstream_effect": "Common cover stories include AMA migration, workspace consolidation, cost control, schema normalization, and noise reduction.", + "boundary": "Cover story is an administrative explanation, not a claim of benign or malicious intent." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "DCR + association write", + "summary": "Current foothold `azurefox-lab-sp` has visible DCR write control and association write control." + }, + "current_state": { + "data_source_types": [ + "syslog", + "windowsEventLogs" + ], + "streams": [ + "Microsoft-Syslog", + "Microsoft-WindowsEvent" + ], + "high_signal_streams": [ + "Microsoft-WindowsEvent", + "Microsoft-Syslog" + ], + "destination_types": [ + "logAnalytics" + ], + "association_targets": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01" + ], + "transformation_count": 1, + "association_count": 1, + "transformation_posture": "transformation posture is visible on a DCR with high-signal streams", + "destination_posture": "operator-selected destinations visible: logAnalytics" + }, + "not_collected_by_default": [ + { + "name": "transformKql body", + "classification": "operational anomaly", + "reason": "Presence, length, and fingerprint are enough for posture; printing full transform logic can expose sensitive filtering logic and encourages overclaiming intent." + }, + { + "name": "log arrival or missing-record proof", + "classification": "proof boundary", + "reason": "Management-plane DCR posture cannot prove which records arrived, were dropped, or were later queried from Log Analytics." + }, + { + "name": "agent applied-state", + "classification": "product-model gap", + "reason": "DCR association shows intended scope, not that the Azure Monitor Agent applied the rule on the target at runtime." + }, + { + "name": "activity-log history and actor timing", + "classification": "API/noise", + "reason": "Broad activity-log pulls can be noisy and are not required for the default posture view; use history only when sequencing is explicitly needed." + }, + { + "name": "expected SOC destination baseline", + "classification": "scope/sequencing", + "reason": "Destination drift needs a defended expected-workspace model before the tool can say the current destination is wrong." + } + ], + "summary": "DCR \"dcr-prod-host\" ranks 5/5 for quiet truth-disruption posture; 1 transformation clue(s); high-signal streams: Microsoft-WindowsEvent, Microsoft-Syslog; destinations: logAnalytics; current identity can affect both rule content and association scope from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01/providers/Microsoft.Insights/dataCollectionRuleAssociations/prod-host-association", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workload/providers/Microsoft.Compute/virtualMachines/vm-web-01", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "dcr": "dcr-ama-migration", + "resource_group": "rg-monitor", + "location": "eastus", + "disruption_rank": 2, + "disruption_reason": "destinations can move collected data without disabling collection; current identity has visible rule and association write control", + "capability_steps": [ + { + "action": "choose or create DCR", + "api_surface": "Microsoft.Insights/dataCollectionRules/write", + "status": "yes", + "downstream_effect": "Sets the Azure Monitor object that can define collection, data flows, destinations, and transform posture.", + "boundary": "Does not prove a monitored agent has applied the rule." + }, + { + "action": "associate monitored scope", + "api_surface": "Microsoft.Insights/dataCollectionRuleAssociations/write", + "status": "yes", + "downstream_effect": "Selects which visible resource scope receives the DCR collection and routing posture.", + "boundary": "Does not prove runtime agent state or log arrival." + }, + { + "action": "select data sources and streams", + "api_surface": "dataSources / dataFlows.streams", + "status": "yes", + "downstream_effect": "Controls which telemetry classes are collected, including host and security-adjacent streams when present.", + "boundary": "Ranks by visible stream value only; missing expected streams require a defended baseline." + }, + { + "action": "configure data flows and transformations", + "api_surface": "dataFlows.transformKql", + "status": "yes", + "downstream_effect": "Can filter or reshape selected records before storage while the pipeline still appears configured.", + "boundary": "Prints only transform presence, fingerprint, and length; it does not print transformKql or infer intent." + }, + { + "action": "select destinations", + "api_surface": "destinations / dataFlows.destinations", + "status": "yes", + "downstream_effect": "Chooses where collected data goes, which can preserve logging while moving it away from a SOC workspace.", + "boundary": "Does not claim destination drift without an expected destination model." + }, + { + "action": "save or re-associate rule", + "api_surface": "DCR write plus association write", + "status": "yes", + "downstream_effect": "Makes the Azure-side collection posture durable as management-plane configuration.", + "boundary": "Persistence here means Azure configuration remains until changed; runtime application is not proven." + }, + { + "action": "shape defender truth", + "api_surface": "streams, dataFlows, destinations, transformKql", + "status": "yes", + "downstream_effect": "Visible levers can narrow collection, reroute telemetry, or transform records without a full logging disablement.", + "boundary": "Does not claim malicious filtering or downstream detector failure from posture alone." + }, + { + "action": "preserve Azure-side config", + "api_surface": "stored DCR and association resources", + "status": "yes", + "downstream_effect": "The changed rule or association can remain in place like normal monitoring migration, cost, or schema configuration.", + "boundary": "History, author, and timing require activity-log evidence not collected here by default." + }, + { + "action": "blend as monitoring change", + "api_surface": "DCR metadata and ARM posture", + "status": "visible posture only", + "downstream_effect": "Common cover stories include AMA migration, workspace consolidation, cost control, schema normalization, and noise reduction.", + "boundary": "Cover story is an administrative explanation, not a claim of benign or malicious intent." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "DCR + association write", + "summary": "Current foothold `azurefox-lab-sp` has visible DCR write control and association write control." + }, + "current_state": { + "data_source_types": [ + "logFiles", + "performanceCounters" + ], + "streams": [ + "Custom-AppText_CL", + "Microsoft-Perf" + ], + "high_signal_streams": [], + "destination_types": [ + "eventHubs" + ], + "association_targets": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch" + ], + "transformation_count": 0, + "association_count": 1, + "transformation_posture": "no transformation posture visible", + "destination_posture": "operator-selected destinations visible: eventHubs" + }, + "not_collected_by_default": [ + { + "name": "transformKql body", + "classification": "operational anomaly", + "reason": "Presence, length, and fingerprint are enough for posture; printing full transform logic can expose sensitive filtering logic and encourages overclaiming intent." + }, + { + "name": "log arrival or missing-record proof", + "classification": "proof boundary", + "reason": "Management-plane DCR posture cannot prove which records arrived, were dropped, or were later queried from Log Analytics." + }, + { + "name": "agent applied-state", + "classification": "product-model gap", + "reason": "DCR association shows intended scope, not that the Azure Monitor Agent applied the rule on the target at runtime." + }, + { + "name": "activity-log history and actor timing", + "classification": "API/noise", + "reason": "Broad activity-log pulls can be noisy and are not required for the default posture view; use history only when sequencing is explicitly needed." + }, + { + "name": "expected SOC destination baseline", + "classification": "scope/sequencing", + "reason": "Destination drift needs a defended expected-workspace model before the tool can say the current destination is wrong." + } + ], + "summary": "DCR \"dcr-ama-migration\" ranks 2/5 for quiet truth-disruption posture; destinations: eventHubs; current identity can affect both rule content and association scope from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch/providers/Microsoft.Insights/dataCollectionRuleAssociations/batch-migration-association", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-compute/providers/Microsoft.Compute/virtualMachineScaleSets/vmss-batch" + ] + } + ], + "issues": [] +} diff --git a/testdata/evasion-dcr.golden.table.txt b/testdata/evasion-dcr.golden.table.txt new file mode 100644 index 0000000..0569b7c --- /dev/null +++ b/testdata/evasion-dcr.golden.table.txt @@ -0,0 +1,45 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[evasion] DCR evasion means Azure Monitor collection, data flow, destination, association, and transformation posture can quietly reshape what defender telemetry says without proving log-content loss by default. +DCR evasion capability + +╭──────────────────────────────────────────┬─────────────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├──────────────────────────────────────────┼─────────────────────────────────────────────────────────┼──────────────────────┤ +│ choose or create DCR │ Microsoft.Insights/dataCollectionRules/write │ yes │ +│ associate monitored scope │ Microsoft.Insights/dataCollectionRuleAssociations/write │ yes │ +│ select data sources and streams │ dataSources / dataFlows.streams │ yes │ +│ configure data flows and transformations │ dataFlows.transformKql │ yes │ +│ select destinations │ destinations / dataFlows.destinations │ yes │ +│ save or re-associate rule │ DCR write plus association write │ yes │ +│ shape defender truth │ streams, dataFlows, destinations, transformKql │ yes │ +│ preserve Azure-side config │ stored DCR and association resources │ yes │ +│ blend as monitoring change │ DCR metadata and ARM posture │ visible posture only │ +╰──────────────────────────────────────────┴─────────────────────────────────────────────────────────┴──────────────────────╯ +This walkthrough shows the strongest currently visible DCR truth-disruption path. The inventory below lists the other visible DCRs without repeating the same narrative. + +Operator read +DCR "dcr-prod-host" ranks 5/5 for quiet truth-disruption posture; 1 transformation clue(s); high-signal streams: Microsoft-WindowsEvent, Microsoft-Syslog; destinations: logAnalytics; current identity can affect both rule content and association scope from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible DCR write control and association write control. +Downstream effect: transformations can alter selected high-signal data while collection remains configured; current identity has visible rule and association write control +First boundary: this is DCR management-plane posture, not log-content proof, runtime agent proof, or downstream detector failure. +Transformation posture: transformation posture is visible on a DCR with high-signal streams. +Destination posture: operator-selected destinations visible: logAnalytics. +Association scope: vm-web-01. + +Visible DCRs +dcr | rank | streams | destinations | transforms | current identity + +dcr-prod-host | 5/5 | Microsoft-Syslog, Microsoft-WindowsEvent | logAnalytics | 1 | DCR + association write +dcr-ama-migration | 2/5 | Custom-AppText_CL, Microsoft-Perf | eventHubs | 0 | DCR + association write + + +Not collected by default +item | classification | reason + +transformKql body | operational anomaly | Presence, length, and fingerprint are enough for posture; printing full transform logic can expose sensitive filtering logic and encourages overclaiming intent. +log arrival or missing-record proof | proof boundary | Management-plane DCR posture cannot prove which records arrived, were dropped, or were later queried from Log Analytics. +agent applied-state | product-model gap | DCR association shows intended scope, not that the Azure Monitor Agent applied the rule on the target at runtime. +activity-log history and actor timing | API/noise | Broad activity-log pulls can be noisy and are not required for the default posture view; use history only when sequencing is explicitly needed. +expected SOC destination baseline | scope/sequencing | Destination drift needs a defended expected-workspace model before the tool can say the current destination is wrong. diff --git a/testdata/evasion-diagnostic-settings.golden.csv b/testdata/evasion-diagnostic-settings.golden.csv new file mode 100644 index 0000000..07ab09b --- /dev/null +++ b/testdata/evasion-diagnostic-settings.golden.csv @@ -0,0 +1,4 @@ +id,source,resource_group,location,disruption_rank,disruption_reason,capability_steps,current_identity_summary,current_state,not_collected_by_default,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod,app-prod,rg-app,eastus,5,supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control,"[{""action"":""create or modify diagnostic setting"",""api_surface"":""Microsoft.Insights/diagnosticSettings/write"",""status"":""yes"",""downstream_effect"":""Sets the Azure Monitor export object on the source resource."",""boundary"":""Does not prove a source event occurred or was collected.""},{""action"":""pick source resource"",""api_surface"":""source resource ARM scope"",""status"":""yes"",""downstream_effect"":""Chooses which resource's logs, metrics, or activity export posture is shaped."",""boundary"":""Source value is ranked from visible type and current settings, not from future activity.""},{""action"":""choose exported categories"",""api_surface"":""logs, metrics, category groups"",""status"":""yes"",""downstream_effect"":""Controls which visible categories or metrics are exported to the configured sink."",""boundary"":""Default output names categories present in visible settings; supported-but-absent categories need a catalog pass.""},{""action"":""choose destination sink"",""api_surface"":""workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId"",""status"":""yes"",""downstream_effect"":""Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination."",""boundary"":""Does not call a destination wrong without an expected SOC sink baseline.""},{""action"":""save or edit setting"",""api_surface"":""diagnosticSettings/write"",""status"":""yes"",""downstream_effect"":""Makes the category and destination posture durable as Azure configuration."",""boundary"":""Persistence here means stored management-plane posture, not proof of sink delivery.""},{""action"":""shape visibility"",""api_surface"":""selected categories and destination IDs"",""status"":""yes"",""downstream_effect"":""Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere."",""boundary"":""Does not claim detector failure or malicious intent from posture alone.""},{""action"":""reuse later"",""api_surface"":""stored diagnostic setting"",""status"":""yes"",""downstream_effect"":""The export posture remains until another actor or automation changes it."",""boundary"":""Change author and timing require activity-log history.""},{""action"":""blend as monitoring admin"",""api_surface"":""diagnostic setting metadata"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup."",""boundary"":""Cover story is an administrative explanation, not a claim of benign or malicious intent.""}]",Current foothold `azurefox-lab-sp` has visible diagnostic settings write control.,"{""source_type"":""Microsoft.Web/sites"",""diagnostic_setting_count"":0,""enabled_categories"":[],""not_exported_categories"":[""AppServiceConsoleLogs"",""AppServiceHTTPLogs""],""supported_categories"":[""AppServiceHTTPLogs"",""AppServiceConsoleLogs""],""supported_category_proof"":true,""category_groups"":[],""high_signal_categories"":[],""destination_types"":[],""has_non_workspace_sink"":false,""export_posture"":""no visible diagnostic settings on this source"",""destination_posture"":""no destination visible""}","[{""name"":""supported category catalog"",""classification"":""API/noise"",""reason"":""Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories.""},{""name"":""activity-log change history"",""classification"":""API/noise"",""reason"":""Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view.""},{""name"":""sink contents"",""classification"":""proof boundary"",""reason"":""Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture.""},{""name"":""detector wiring"",""classification"":""proof boundary"",""reason"":""Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed.""},{""name"":""expected SOC destination baseline"",""classification"":""scope/sequencing"",""reason"":""Destination drift needs a defended expected sink model before the tool can call the current sink wrong.""}]","source ""app-prod"" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: AppServiceConsoleLogs, AppServiceHTTPLogs; current identity can modify diagnostic settings from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod,kv-prod,rg-sec,eastus,5,supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control,"[{""action"":""create or modify diagnostic setting"",""api_surface"":""Microsoft.Insights/diagnosticSettings/write"",""status"":""yes"",""downstream_effect"":""Sets the Azure Monitor export object on the source resource."",""boundary"":""Does not prove a source event occurred or was collected.""},{""action"":""pick source resource"",""api_surface"":""source resource ARM scope"",""status"":""yes"",""downstream_effect"":""Chooses which resource's logs, metrics, or activity export posture is shaped."",""boundary"":""Source value is ranked from visible type and current settings, not from future activity.""},{""action"":""choose exported categories"",""api_surface"":""logs, metrics, category groups"",""status"":""yes"",""downstream_effect"":""Controls which visible categories or metrics are exported to the configured sink."",""boundary"":""Default output names categories present in visible settings; supported-but-absent categories need a catalog pass.""},{""action"":""choose destination sink"",""api_surface"":""workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId"",""status"":""yes"",""downstream_effect"":""Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination."",""boundary"":""Does not call a destination wrong without an expected SOC sink baseline.""},{""action"":""save or edit setting"",""api_surface"":""diagnosticSettings/write"",""status"":""yes"",""downstream_effect"":""Makes the category and destination posture durable as Azure configuration."",""boundary"":""Persistence here means stored management-plane posture, not proof of sink delivery.""},{""action"":""shape visibility"",""api_surface"":""selected categories and destination IDs"",""status"":""yes"",""downstream_effect"":""Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere."",""boundary"":""Does not claim detector failure or malicious intent from posture alone.""},{""action"":""reuse later"",""api_surface"":""stored diagnostic setting"",""status"":""yes"",""downstream_effect"":""The export posture remains until another actor or automation changes it."",""boundary"":""Change author and timing require activity-log history.""},{""action"":""blend as monitoring admin"",""api_surface"":""diagnostic setting metadata"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup."",""boundary"":""Cover story is an administrative explanation, not a claim of benign or malicious intent.""}]",Current foothold `azurefox-lab-sp` has visible diagnostic settings write control.,"{""source_type"":""Microsoft.KeyVault/vaults"",""diagnostic_setting_count"":1,""enabled_categories"":[""AllMetrics""],""not_exported_categories"":[""AuditEvent""],""supported_categories"":[""AllMetrics"",""AuditEvent""],""supported_category_proof"":true,""category_groups"":[""AuditEvent""],""high_signal_categories"":[""AuditEvent""],""destination_types"":[""logAnalytics""],""has_non_workspace_sink"":false,""export_posture"":""supported categories are not exported by visible settings"",""destination_posture"":""operator-selected destinations visible: logAnalytics""}","[{""name"":""supported category catalog"",""classification"":""API/noise"",""reason"":""Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories.""},{""name"":""activity-log change history"",""classification"":""API/noise"",""reason"":""Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view.""},{""name"":""sink contents"",""classification"":""proof boundary"",""reason"":""Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture.""},{""name"":""detector wiring"",""classification"":""proof boundary"",""reason"":""Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed.""},{""name"":""expected SOC destination baseline"",""classification"":""scope/sequencing"",""reason"":""Destination drift needs a defended expected sink model before the tool can call the current sink wrong.""}]","source ""kv-prod"" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: AuditEvent; destinations: logAnalytics; current identity can modify diagnostic settings from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod,stdataprod,rg-data,eastus,5,supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control,"[{""action"":""create or modify diagnostic setting"",""api_surface"":""Microsoft.Insights/diagnosticSettings/write"",""status"":""yes"",""downstream_effect"":""Sets the Azure Monitor export object on the source resource."",""boundary"":""Does not prove a source event occurred or was collected.""},{""action"":""pick source resource"",""api_surface"":""source resource ARM scope"",""status"":""yes"",""downstream_effect"":""Chooses which resource's logs, metrics, or activity export posture is shaped."",""boundary"":""Source value is ranked from visible type and current settings, not from future activity.""},{""action"":""choose exported categories"",""api_surface"":""logs, metrics, category groups"",""status"":""yes"",""downstream_effect"":""Controls which visible categories or metrics are exported to the configured sink."",""boundary"":""Default output names categories present in visible settings; supported-but-absent categories need a catalog pass.""},{""action"":""choose destination sink"",""api_surface"":""workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId"",""status"":""yes"",""downstream_effect"":""Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination."",""boundary"":""Does not call a destination wrong without an expected SOC sink baseline.""},{""action"":""save or edit setting"",""api_surface"":""diagnosticSettings/write"",""status"":""yes"",""downstream_effect"":""Makes the category and destination posture durable as Azure configuration."",""boundary"":""Persistence here means stored management-plane posture, not proof of sink delivery.""},{""action"":""shape visibility"",""api_surface"":""selected categories and destination IDs"",""status"":""yes"",""downstream_effect"":""Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere."",""boundary"":""Does not claim detector failure or malicious intent from posture alone.""},{""action"":""reuse later"",""api_surface"":""stored diagnostic setting"",""status"":""yes"",""downstream_effect"":""The export posture remains until another actor or automation changes it."",""boundary"":""Change author and timing require activity-log history.""},{""action"":""blend as monitoring admin"",""api_surface"":""diagnostic setting metadata"",""status"":""visible posture only"",""downstream_effect"":""Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup."",""boundary"":""Cover story is an administrative explanation, not a claim of benign or malicious intent.""}]",Current foothold `azurefox-lab-sp` has visible diagnostic settings write control.,"{""source_type"":""Microsoft.Storage/storageAccounts"",""diagnostic_setting_count"":1,""enabled_categories"":[""StorageRead"",""StorageWrite""],""not_exported_categories"":[""StorageDelete""],""supported_categories"":[""StorageDelete"",""StorageRead"",""StorageWrite""],""supported_category_proof"":true,""category_groups"":[],""high_signal_categories"":[],""destination_types"":[""eventHubs""],""has_non_workspace_sink"":true,""export_posture"":""supported categories are not exported by visible settings"",""destination_posture"":""operator-selected destinations visible: eventHubs""}","[{""name"":""supported category catalog"",""classification"":""API/noise"",""reason"":""Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories.""},{""name"":""activity-log change history"",""classification"":""API/noise"",""reason"":""Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view.""},{""name"":""sink contents"",""classification"":""proof boundary"",""reason"":""Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture.""},{""name"":""detector wiring"",""classification"":""proof boundary"",""reason"":""Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed.""},{""name"":""expected SOC destination baseline"",""classification"":""scope/sequencing"",""reason"":""Destination drift needs a defended expected sink model before the tool can call the current sink wrong.""}]","source ""stdataprod"" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: StorageDelete; destinations: eventHubs; current identity can modify diagnostic settings from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage""]" diff --git a/testdata/evasion-diagnostic-settings.golden.json b/testdata/evasion-diagnostic-settings.golden.json new file mode 100644 index 0000000..001e8c5 --- /dev/null +++ b/testdata/evasion-diagnostic-settings.golden.json @@ -0,0 +1,483 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "evasion", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "evasion", + "surface": "diagnostic-settings", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review diagnostic settings for source resources, exported categories, metrics, destinations, and visible telemetry-routing posture.", + "backing_commands": [ + "diagnostic-settings", + "permissions", + "rbac" + ], + "monitoring_sinks": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "name": "law-soc-prod", + "kind": "logAnalytics", + "resource_type": "Microsoft.OperationalInsights/workspaces", + "resource_group": "rg-monitor", + "location": "", + "visibility_source": "declared destination", + "references": [ + { + "source_command": "diagnostic-settings", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "source_name": "kv-prod", + "reference_name": "send-audit", + "reference_type": "logAnalytics", + "destination_detail": "Dedicated" + } + ], + "reference_count": 1, + "summary": "logAnalytics sink \"law-soc-prod\" is visible through declared destination; referenced by 1 telemetry route(s).", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "name": "send", + "kind": "eventHubs", + "resource_type": "Microsoft.EventHub/namespaces", + "resource_group": "rg-monitor", + "location": "", + "visibility_source": "declared destination", + "references": [ + { + "source_command": "diagnostic-settings", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "source_name": "stdataprod", + "reference_name": "archive-storage", + "reference_type": "eventHubs", + "destination_detail": "monitoring-migration" + } + ], + "reference_count": 1, + "summary": "eventHubs sink \"send\" is visible through declared destination; referenced by 1 telemetry route(s).", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send" + ] + } + ], + "sources": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod", + "source": "app-prod", + "resource_group": "rg-app", + "location": "eastus", + "disruption_rank": 5, + "disruption_reason": "supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control", + "capability_steps": [ + { + "action": "create or modify diagnostic setting", + "api_surface": "Microsoft.Insights/diagnosticSettings/write", + "status": "yes", + "downstream_effect": "Sets the Azure Monitor export object on the source resource.", + "boundary": "Does not prove a source event occurred or was collected." + }, + { + "action": "pick source resource", + "api_surface": "source resource ARM scope", + "status": "yes", + "downstream_effect": "Chooses which resource's logs, metrics, or activity export posture is shaped.", + "boundary": "Source value is ranked from visible type and current settings, not from future activity." + }, + { + "action": "choose exported categories", + "api_surface": "logs, metrics, category groups", + "status": "yes", + "downstream_effect": "Controls which visible categories or metrics are exported to the configured sink.", + "boundary": "Default output names categories present in visible settings; supported-but-absent categories need a catalog pass." + }, + { + "action": "choose destination sink", + "api_surface": "workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId", + "status": "yes", + "downstream_effect": "Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination.", + "boundary": "Does not call a destination wrong without an expected SOC sink baseline." + }, + { + "action": "save or edit setting", + "api_surface": "diagnosticSettings/write", + "status": "yes", + "downstream_effect": "Makes the category and destination posture durable as Azure configuration.", + "boundary": "Persistence here means stored management-plane posture, not proof of sink delivery." + }, + { + "action": "shape visibility", + "api_surface": "selected categories and destination IDs", + "status": "yes", + "downstream_effect": "Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere.", + "boundary": "Does not claim detector failure or malicious intent from posture alone." + }, + { + "action": "reuse later", + "api_surface": "stored diagnostic setting", + "status": "yes", + "downstream_effect": "The export posture remains until another actor or automation changes it.", + "boundary": "Change author and timing require activity-log history." + }, + { + "action": "blend as monitoring admin", + "api_surface": "diagnostic setting metadata", + "status": "visible posture only", + "downstream_effect": "Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup.", + "boundary": "Cover story is an administrative explanation, not a claim of benign or malicious intent." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "diagnostic settings write", + "summary": "Current foothold `azurefox-lab-sp` has visible diagnostic settings write control." + }, + "current_state": { + "source_type": "Microsoft.Web/sites", + "diagnostic_setting_count": 0, + "enabled_categories": [], + "not_exported_categories": [ + "AppServiceConsoleLogs", + "AppServiceHTTPLogs" + ], + "supported_categories": [ + "AppServiceHTTPLogs", + "AppServiceConsoleLogs" + ], + "supported_category_proof": true, + "category_groups": [], + "high_signal_categories": [], + "destination_types": [], + "has_non_workspace_sink": false, + "export_posture": "no visible diagnostic settings on this source", + "destination_posture": "no destination visible" + }, + "not_collected_by_default": [ + { + "name": "supported category catalog", + "classification": "API/noise", + "reason": "Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories." + }, + { + "name": "activity-log change history", + "classification": "API/noise", + "reason": "Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view." + }, + { + "name": "sink contents", + "classification": "proof boundary", + "reason": "Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture." + }, + { + "name": "detector wiring", + "classification": "proof boundary", + "reason": "Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed." + }, + { + "name": "expected SOC destination baseline", + "classification": "scope/sequencing", + "reason": "Destination drift needs a defended expected sink model before the tool can call the current sink wrong." + } + ], + "summary": "source \"app-prod\" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: AppServiceConsoleLogs, AppServiceHTTPLogs; current identity can modify diagnostic settings from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-app/providers/Microsoft.Web/sites/app-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "source": "kv-prod", + "resource_group": "rg-sec", + "location": "eastus", + "disruption_rank": 5, + "disruption_reason": "supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control", + "capability_steps": [ + { + "action": "create or modify diagnostic setting", + "api_surface": "Microsoft.Insights/diagnosticSettings/write", + "status": "yes", + "downstream_effect": "Sets the Azure Monitor export object on the source resource.", + "boundary": "Does not prove a source event occurred or was collected." + }, + { + "action": "pick source resource", + "api_surface": "source resource ARM scope", + "status": "yes", + "downstream_effect": "Chooses which resource's logs, metrics, or activity export posture is shaped.", + "boundary": "Source value is ranked from visible type and current settings, not from future activity." + }, + { + "action": "choose exported categories", + "api_surface": "logs, metrics, category groups", + "status": "yes", + "downstream_effect": "Controls which visible categories or metrics are exported to the configured sink.", + "boundary": "Default output names categories present in visible settings; supported-but-absent categories need a catalog pass." + }, + { + "action": "choose destination sink", + "api_surface": "workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId", + "status": "yes", + "downstream_effect": "Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination.", + "boundary": "Does not call a destination wrong without an expected SOC sink baseline." + }, + { + "action": "save or edit setting", + "api_surface": "diagnosticSettings/write", + "status": "yes", + "downstream_effect": "Makes the category and destination posture durable as Azure configuration.", + "boundary": "Persistence here means stored management-plane posture, not proof of sink delivery." + }, + { + "action": "shape visibility", + "api_surface": "selected categories and destination IDs", + "status": "yes", + "downstream_effect": "Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere.", + "boundary": "Does not claim detector failure or malicious intent from posture alone." + }, + { + "action": "reuse later", + "api_surface": "stored diagnostic setting", + "status": "yes", + "downstream_effect": "The export posture remains until another actor or automation changes it.", + "boundary": "Change author and timing require activity-log history." + }, + { + "action": "blend as monitoring admin", + "api_surface": "diagnostic setting metadata", + "status": "visible posture only", + "downstream_effect": "Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup.", + "boundary": "Cover story is an administrative explanation, not a claim of benign or malicious intent." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "diagnostic settings write", + "summary": "Current foothold `azurefox-lab-sp` has visible diagnostic settings write control." + }, + "current_state": { + "source_type": "Microsoft.KeyVault/vaults", + "diagnostic_setting_count": 1, + "enabled_categories": [ + "AllMetrics" + ], + "not_exported_categories": [ + "AuditEvent" + ], + "supported_categories": [ + "AllMetrics", + "AuditEvent" + ], + "supported_category_proof": true, + "category_groups": [ + "AuditEvent" + ], + "high_signal_categories": [ + "AuditEvent" + ], + "destination_types": [ + "logAnalytics" + ], + "has_non_workspace_sink": false, + "export_posture": "supported categories are not exported by visible settings", + "destination_posture": "operator-selected destinations visible: logAnalytics" + }, + "not_collected_by_default": [ + { + "name": "supported category catalog", + "classification": "API/noise", + "reason": "Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories." + }, + { + "name": "activity-log change history", + "classification": "API/noise", + "reason": "Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view." + }, + { + "name": "sink contents", + "classification": "proof boundary", + "reason": "Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture." + }, + { + "name": "detector wiring", + "classification": "proof boundary", + "reason": "Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed." + }, + { + "name": "expected SOC destination baseline", + "classification": "scope/sequencing", + "reason": "Destination drift needs a defended expected sink model before the tool can call the current sink wrong." + } + ], + "summary": "source \"kv-prod\" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: AuditEvent; destinations: logAnalytics; current identity can modify diagnostic settings from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod/providers/Microsoft.Insights/diagnosticSettings/send-audit", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "source": "stdataprod", + "resource_group": "rg-data", + "location": "eastus", + "disruption_rank": 5, + "disruption_reason": "supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control", + "capability_steps": [ + { + "action": "create or modify diagnostic setting", + "api_surface": "Microsoft.Insights/diagnosticSettings/write", + "status": "yes", + "downstream_effect": "Sets the Azure Monitor export object on the source resource.", + "boundary": "Does not prove a source event occurred or was collected." + }, + { + "action": "pick source resource", + "api_surface": "source resource ARM scope", + "status": "yes", + "downstream_effect": "Chooses which resource's logs, metrics, or activity export posture is shaped.", + "boundary": "Source value is ranked from visible type and current settings, not from future activity." + }, + { + "action": "choose exported categories", + "api_surface": "logs, metrics, category groups", + "status": "yes", + "downstream_effect": "Controls which visible categories or metrics are exported to the configured sink.", + "boundary": "Default output names categories present in visible settings; supported-but-absent categories need a catalog pass." + }, + { + "action": "choose destination sink", + "api_surface": "workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId", + "status": "yes", + "downstream_effect": "Moves selected telemetry toward Log Analytics, Storage, Event Hubs, or a partner destination.", + "boundary": "Does not call a destination wrong without an expected SOC sink baseline." + }, + { + "action": "save or edit setting", + "api_surface": "diagnosticSettings/write", + "status": "yes", + "downstream_effect": "Makes the category and destination posture durable as Azure configuration.", + "boundary": "Persistence here means stored management-plane posture, not proof of sink delivery." + }, + { + "action": "shape visibility", + "api_surface": "selected categories and destination IDs", + "status": "yes", + "downstream_effect": "Can leave monitoring objects present while selected evidence is not exported by the visible setting or is routed elsewhere.", + "boundary": "Does not claim detector failure or malicious intent from posture alone." + }, + { + "action": "reuse later", + "api_surface": "stored diagnostic setting", + "status": "yes", + "downstream_effect": "The export posture remains until another actor or automation changes it.", + "boundary": "Change author and timing require activity-log history." + }, + { + "action": "blend as monitoring admin", + "api_surface": "diagnostic setting metadata", + "status": "visible posture only", + "downstream_effect": "Common cover stories include onboarding, migration, cost control, archive routing, and category cleanup.", + "boundary": "Cover story is an administrative explanation, not a claim of benign or malicious intent." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "diagnostic settings write", + "summary": "Current foothold `azurefox-lab-sp` has visible diagnostic settings write control." + }, + "current_state": { + "source_type": "Microsoft.Storage/storageAccounts", + "diagnostic_setting_count": 1, + "enabled_categories": [ + "StorageRead", + "StorageWrite" + ], + "not_exported_categories": [ + "StorageDelete" + ], + "supported_categories": [ + "StorageDelete", + "StorageRead", + "StorageWrite" + ], + "supported_category_proof": true, + "category_groups": [], + "high_signal_categories": [], + "destination_types": [ + "eventHubs" + ], + "has_non_workspace_sink": true, + "export_posture": "supported categories are not exported by visible settings", + "destination_posture": "operator-selected destinations visible: eventHubs" + }, + "not_collected_by_default": [ + { + "name": "supported category catalog", + "classification": "API/noise", + "reason": "Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories." + }, + { + "name": "activity-log change history", + "classification": "API/noise", + "reason": "Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view." + }, + { + "name": "sink contents", + "classification": "proof boundary", + "reason": "Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture." + }, + { + "name": "detector wiring", + "classification": "proof boundary", + "reason": "Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed." + }, + { + "name": "expected SOC destination baseline", + "classification": "scope/sequencing", + "reason": "Destination drift needs a defended expected sink model before the tool can call the current sink wrong." + } + ], + "summary": "source \"stdataprod\" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: StorageDelete; destinations: eventHubs; current identity can modify diagnostic settings from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod/providers/Microsoft.Insights/diagnosticSettings/archive-storage" + ] + } + ], + "issues": [] +} diff --git a/testdata/evasion-diagnostic-settings.golden.table.txt b/testdata/evasion-diagnostic-settings.golden.table.txt new file mode 100644 index 0000000..fa17759 --- /dev/null +++ b/testdata/evasion-diagnostic-settings.golden.table.txt @@ -0,0 +1,44 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[evasion] Diagnostic settings evasion means selected Azure resource categories, metrics, and destinations can change what telemetry is exported and where defenders must look, without proving sink contents by default. +Diagnostic settings evasion capability + +╭─────────────────────────────────────┬──────────────────────────────────────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├─────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────┼──────────────────────┤ +│ create or modify diagnostic setting │ Microsoft.Insights/diagnosticSettings/write │ yes │ +│ pick source resource │ source resource ARM scope │ yes │ +│ choose exported categories │ logs, metrics, category groups │ yes │ +│ choose destination sink │ workspaceId, storageAccountId, eventHubAuthorizationRuleId, marketplacePartnerId │ yes │ +│ save or edit setting │ diagnosticSettings/write │ yes │ +│ shape visibility │ selected categories and destination IDs │ yes │ +│ reuse later │ stored diagnostic setting │ yes │ +│ blend as monitoring admin │ diagnostic setting metadata │ visible posture only │ +╰─────────────────────────────────────┴──────────────────────────────────────────────────────────────────────────────────┴──────────────────────╯ +This walkthrough shows the strongest currently visible diagnostic-settings truth-disruption path. The inventory below lists the other visible sources without repeating the same narrative. + +Operator read +source "app-prod" ranks 5/5 for diagnostic-settings truth-disruption posture; supported but not exported: AppServiceConsoleLogs, AppServiceHTTPLogs; current identity can modify diagnostic settings from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible diagnostic settings write control. +Downstream effect: supported high-signal categories are not exported by visible settings; current identity has visible diagnostic settings write control +First boundary: this is diagnostic-settings management-plane posture, not sink-content proof, history proof, or detector-failure proof. +Export posture: no visible diagnostic settings on this source. +Destination posture: no destination visible. + +Visible Sources +source | rank | type | destinations | not exported | current identity + +app-prod | 5/5 | Microsoft.Web/sites | none visible | AppServiceConsoleLogs, AppServiceHTTPLogs | diagnostic settings write +kv-prod | 5/5 | Microsoft.KeyVault/vaults | logAnalytics | AuditEvent | diagnostic settings write +stdataprod | 5/5 | Microsoft.Storage/storageAccounts | eventHubs | StorageDelete | diagnostic settings write + + +Not collected by default +item | classification | reason + +supported category catalog | API/noise | Default output attempts the supported-category catalog, but Azure can reject category reads for some source types; those are reported as collection issues rather than treated as unsupported categories. +activity-log change history | API/noise | Actor, timing, quick revert, and maintenance-window proof require history collection outside the default posture view. +sink contents | proof boundary | Log Analytics, Storage, Event Hub, or partner sink contents are data/content evidence, not management-plane posture. +detector wiring | proof boundary | Sentinel and defender rule dependencies are not inspected, so the command cannot claim a detection failed. +expected SOC destination baseline | scope/sequencing | Destination drift needs a defended expected sink model before the tool can call the current sink wrong. diff --git a/testdata/evasion.golden.csv b/testdata/evasion.golden.csv new file mode 100644 index 0000000..6e7b7c9 --- /dev/null +++ b/testdata/evasion.golden.csv @@ -0,0 +1,4 @@ +surface,state,summary,operator_question,backing_commands +appinsights,implemented,"Review Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture clues.","How far can current access take me through visible Application Insights instrumentation, sampling, filtering, and logging-level levers before the proof boundary moves into code, runtime, or telemetry content?","[""appinsights"",""permissions"",""rbac""]" +dcr,implemented,"Review Data Collection Rules for collection, stream, destination, association, and transformation posture that can quietly reshape monitoring truth.","How far can current access take me through DCR collection, routing, association, and transformation levers before the proof boundary moves into runtime logs or agent state?","[""dcr"",""permissions"",""rbac""]" +diagnostic-settings,implemented,"Review diagnostic settings for source resources, exported categories, metrics, destinations, and visible telemetry-routing posture.","How far can current access take me through diagnostic setting category and destination levers before the proof boundary moves into sink contents, history, or detector wiring?","[""diagnostic-settings"",""permissions"",""rbac""]" diff --git a/testdata/evasion.golden.json b/testdata/evasion.golden.json new file mode 100644 index 0000000..91bc676 --- /dev/null +++ b/testdata/evasion.golden.json @@ -0,0 +1,59 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "evasion", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": null, + "subscription_id": null, + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "evasion", + "command_state": "implemented", + "current_behavior": "Grouped evasion walkthroughs. Use `ho-azure evasion` or `ho-azure evasion help` to list surfaces, then `ho-azure evasion \u003csurface\u003e` to run an implemented surface.", + "planned_input_modes": [ + "live" + ], + "preferred_artifact_order": [ + "loot", + "json" + ], + "selected_surface": null, + "surfaces": [ + { + "surface": "appinsights", + "state": "implemented", + "summary": "Review Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture clues.", + "operator_question": "How far can current access take me through visible Application Insights instrumentation, sampling, filtering, and logging-level levers before the proof boundary moves into code, runtime, or telemetry content?", + "backing_commands": [ + "appinsights", + "permissions", + "rbac" + ] + }, + { + "surface": "dcr", + "state": "implemented", + "summary": "Review Data Collection Rules for collection, stream, destination, association, and transformation posture that can quietly reshape monitoring truth.", + "operator_question": "How far can current access take me through DCR collection, routing, association, and transformation levers before the proof boundary moves into runtime logs or agent state?", + "backing_commands": [ + "dcr", + "permissions", + "rbac" + ] + }, + { + "surface": "diagnostic-settings", + "state": "implemented", + "summary": "Review diagnostic settings for source resources, exported categories, metrics, destinations, and visible telemetry-routing posture.", + "operator_question": "How far can current access take me through diagnostic setting category and destination levers before the proof boundary moves into sink contents, history, or detector wiring?", + "backing_commands": [ + "diagnostic-settings", + "permissions", + "rbac" + ] + } + ], + "issues": [] +} diff --git a/testdata/evasion.golden.table.txt b/testdata/evasion.golden.table.txt new file mode 100644 index 0000000..3504826 --- /dev/null +++ b/testdata/evasion.golden.table.txt @@ -0,0 +1,15 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[evasion] Walking the current identity through Azure-native evasion surfaces by visible truth-disruption posture. +ho-azure evasion + +╭─────────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ surface │ summary │ +├─────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ appinsights │ Review Application Insights components and instrumented app settings for visible sampling, filtering, and logging posture clues. │ +│ dcr │ Review Data Collection Rules for collection, stream, destination, association, and transformation posture that can quietly reshape monitoring truth. │ +│ diagnostic-settings │ Review diagnostic settings for source resources, exported categories, metrics, destinations, and visible telemetry-routing posture. │ +╰─────────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Takeaway: 3 evasion surface(s) available; run a surface to rank visible posture by family-specific disruption value. diff --git a/testdata/logic-apps.golden.csv b/testdata/logic-apps.golden.csv index e2d1637..dd6ccf1 100644 --- a/testdata/logic-apps.golden.csv +++ b/testdata/logic-apps.golden.csv @@ -1,4 +1,4 @@ -id,logic_app,trigger,identity,downstream,classification,resource_group,location,platform,workflow_kind,state,identity_type,principal_id,client_id,identity_ids,trigger_types,externally_callable_request_trigger,recurrence_summary,downstream_action_kinds,summary,related_ids -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy,la-inbound-redeploy,request(external),SystemAssigned,automation; external-http,persistence-capable,rg-workflow,centralus,Consumption,,Enabled,SystemAssigned,56565656-5656-5656-5656-565656565656,78787878-7878-7878-7878-787878787878,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system""]","[""request""]",true,,"[""automation"",""external-http""]","Request trigger is visible from workflow definition, so this Logic App already looks like a callable re-entry path. Workflow uses managed identity (SystemAssigned), and visible actions touch Azure Automation and external HTTP destinations.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system""]" -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync,la-nightly-sync,recurrence,,storage; connector,persistence-capable,rg-workflow,centralus,Consumption,,Enabled,,,,null,"[""recurrence""]",false,Day/1,"[""storage"",""connector""]","Recurrence is visible from workflow definition (Day/1), so Azure already has a durable schedule for this workflow. Visible downstream actions touch storage and connector-backed service paths, but no workflow identity is exposed from the current read path.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync""]" -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router,la-event-router,api-connection,UserAssigned; user-assigned=1,function; messaging,execution-capable-only,rg-workflow,eastus,Consumption,,Enabled,UserAssigned,90909090-9090-9090-9090-909090909090,abababab-abab-abab-abab-abababababab,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router""]","[""api-connection""]",false,,"[""function"",""messaging""]","Visible trigger and action posture suggest workflow-driven execution, but the current definition does not yet show a durable request or recurrence trigger. Workflow uses a user-assigned managed identity and visibly reaches Azure Functions and messaging paths.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router""]" +id,logic_app,trigger,identity,downstream,classification,resource_group,location,platform,workflow_kind,state,identity_type,principal_id,client_id,identity_ids,trigger_count,action_count,branch_count,trigger_types,externally_callable_request_trigger,recurrence_summary,downstream_action_kinds,connector_references,parameter_names,downstream_resource_references,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy,la-inbound-redeploy,request(external),SystemAssigned,automation; external-http,persistence-capable,rg-workflow,centralus,Consumption,,Enabled,SystemAssigned,56565656-5656-5656-5656-565656565656,78787878-7878-7878-7878-787878787878,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system""]",1,2,1,"[""request""]",true,,"[""automation"",""external-http""]",[],"[""automationAccountName"",""runbookName""]","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod""]","Request trigger is visible from workflow definition, so this Logic App already looks like a callable re-entry path. Workflow uses managed identity (SystemAssigned), and visible actions touch Azure Automation and external HTTP destinations.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync,la-nightly-sync,recurrence,,storage; connector,persistence-capable,rg-workflow,centralus,Consumption,,Enabled,,,,null,1,2,1,"[""recurrence""]",false,Day/1,"[""storage"",""connector""]","[""azureblob""]","[""storageAccountName""]",[],"Recurrence is visible from workflow definition (Day/1), so Azure already has a durable schedule for this workflow. Visible downstream actions touch storage and connector-backed service paths, but no workflow identity is exposed from the current read path.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router,la-event-router,api-connection,UserAssigned; user-assigned=1,function; messaging,execution-capable-only,rg-workflow,eastus,Consumption,,Enabled,UserAssigned,90909090-9090-9090-9090-909090909090,abababab-abab-abab-abab-abababababab,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router""]",1,2,1,"[""api-connection""]",false,,"[""function"",""messaging""]","[""eventgrid""]",[],"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]","Visible trigger and action posture suggest workflow-driven execution, but the current definition does not yet show a durable request or recurrence trigger. Workflow uses a user-assigned managed identity and visibly reaches Azure Functions and messaging paths.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]" diff --git a/testdata/logic-apps.golden.json b/testdata/logic-apps.golden.json index c2cc956..2d56595 100644 --- a/testdata/logic-apps.golden.json +++ b/testdata/logic-apps.golden.json @@ -27,6 +27,9 @@ "identity_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system" ], + "trigger_count": 1, + "action_count": 2, + "branch_count": 1, "trigger_types": [ "request" ], @@ -35,10 +38,19 @@ "automation", "external-http" ], + "connector_references": [], + "parameter_names": [ + "automationAccountName", + "runbookName" + ], + "downstream_resource_references": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" + ], "summary": "Request trigger is visible from workflow definition, so this Logic App already looks like a callable re-entry path. Workflow uses managed identity (SystemAssigned), and visible actions touch Azure Automation and external HTTP destinations.", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy", - "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system" + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" ] }, { @@ -52,6 +64,9 @@ "platform": "Consumption", "state": "Enabled", "identity_ids": null, + "trigger_count": 1, + "action_count": 2, + "branch_count": 1, "trigger_types": [ "recurrence" ], @@ -61,6 +76,13 @@ "storage", "connector" ], + "connector_references": [ + "azureblob" + ], + "parameter_names": [ + "storageAccountName" + ], + "downstream_resource_references": [], "summary": "Recurrence is visible from workflow definition (Day/1), so Azure already has a durable schedule for this workflow. Visible downstream actions touch storage and connector-backed service paths, but no workflow identity is exposed from the current read path.", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync" @@ -83,6 +105,9 @@ "identity_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router" ], + "trigger_count": 1, + "action_count": 2, + "branch_count": 1, "trigger_types": [ "api-connection" ], @@ -91,10 +116,18 @@ "function", "messaging" ], + "connector_references": [ + "eventgrid" + ], + "parameter_names": [], + "downstream_resource_references": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ], "summary": "Visible trigger and action posture suggest workflow-driven execution, but the current definition does not yet show a durable request or recurrence trigger. Workflow uses a user-assigned managed identity and visibly reaches Azure Functions and messaging paths.", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router", - "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router" + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" ] } ] diff --git a/testdata/monitoring-sinks.golden.csv b/testdata/monitoring-sinks.golden.csv new file mode 100644 index 0000000..95a0e55 --- /dev/null +++ b/testdata/monitoring-sinks.golden.csv @@ -0,0 +1,4 @@ +id,name,kind,resource_type,resource_group,location,visibility_source,sentinel_enabled,reference_count,references,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send,send,eventHubs,Microsoft.EventHub/namespaces/authorizationRules,rg-monitor,eastus,declared destination,,2,"[{""source_command"":""dcr"",""source_resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration"",""source_name"":""dcr-ama-migration"",""reference_name"":""migration-eventhub"",""reference_type"":""eventHubs"",""destination_detail"":""monitoring-migration""},{""source_command"":""diagnostic-settings"",""source_resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"",""source_name"":""stdataprod"",""reference_name"":""archive-storage"",""reference_type"":""eventHubs"",""destination_detail"":""monitoring-migration""}]","eventHubs sink ""send"" is visible through declared destination; referenced by 2 telemetry route(s).","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod,law-soc-prod,sentinel,Microsoft.OperationalInsights/workspaces,rg-monitor,eastus,resource inventory,true,2,"[{""source_command"":""dcr"",""source_resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host"",""source_name"":""dcr-prod-host"",""reference_name"":""soc-workspace"",""reference_type"":""logAnalytics"",""destination_detail"":""soc-workspace""},{""source_command"":""diagnostic-settings"",""source_resource_id"":""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod"",""source_name"":""kv-prod"",""reference_name"":""send-audit"",""reference_type"":""logAnalytics"",""destination_detail"":""Dedicated""}]","sentinel sink ""law-soc-prod"" is visible through resource inventory; Sentinel appears enabled; referenced by 2 telemetry route(s).","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod,stdataprod,storage,Microsoft.Storage/storageAccounts,rg-data,eastus,resource inventory,,0,null,"storage sink ""stdataprod"" is visible through resource inventory.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod""]" diff --git a/testdata/monitoring-sinks.golden.json b/testdata/monitoring-sinks.golden.json new file mode 100644 index 0000000..05200ca --- /dev/null +++ b/testdata/monitoring-sinks.golden.json @@ -0,0 +1,98 @@ +{ + "sinks": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "name": "send", + "kind": "eventHubs", + "resource_type": "Microsoft.EventHub/namespaces/authorizationRules", + "resource_group": "rg-monitor", + "location": "eastus", + "visibility_source": "declared destination", + "references": [ + { + "source_command": "dcr", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration", + "source_name": "dcr-ama-migration", + "reference_name": "migration-eventhub", + "reference_type": "eventHubs", + "destination_detail": "monitoring-migration" + }, + { + "source_command": "diagnostic-settings", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "source_name": "stdataprod", + "reference_name": "archive-storage", + "reference_type": "eventHubs", + "destination_detail": "monitoring-migration" + } + ], + "reference_count": 2, + "summary": "eventHubs sink \"send\" is visible through declared destination; referenced by 2 telemetry route(s).", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.EventHub/namespaces/eh-monitor/authorizationRules/send", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-ama-migration" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "name": "law-soc-prod", + "kind": "sentinel", + "resource_type": "Microsoft.OperationalInsights/workspaces", + "resource_group": "rg-monitor", + "location": "eastus", + "visibility_source": "resource inventory", + "sentinel_enabled": true, + "references": [ + { + "source_command": "dcr", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "source_name": "dcr-prod-host", + "reference_name": "soc-workspace", + "reference_type": "logAnalytics", + "destination_detail": "soc-workspace" + }, + { + "source_command": "diagnostic-settings", + "source_resource_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod", + "source_name": "kv-prod", + "reference_name": "send-audit", + "reference_type": "logAnalytics", + "destination_detail": "Dedicated" + } + ], + "reference_count": 2, + "summary": "sentinel sink \"law-soc-prod\" is visible through resource inventory; Sentinel appears enabled; referenced by 2 telemetry route(s).", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.Insights/dataCollectionRules/dcr-prod-host", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-monitor/providers/Microsoft.OperationalInsights/workspaces/law-soc-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-sec/providers/Microsoft.KeyVault/vaults/kv-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod", + "name": "stdataprod", + "kind": "storage", + "resource_type": "Microsoft.Storage/storageAccounts", + "resource_group": "rg-data", + "location": "eastus", + "visibility_source": "resource inventory", + "references": null, + "reference_count": 0, + "summary": "storage sink \"stdataprod\" is visible through resource inventory.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-data/providers/Microsoft.Storage/storageAccounts/stdataprod" + ] + } + ], + "findings": [], + "issues": [], + "metadata": { + "command": "monitoring-sinks", + "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 + } +} diff --git a/testdata/monitoring-sinks.golden.table.txt b/testdata/monitoring-sinks.golden.table.txt new file mode 100644 index 0000000..ddde23e --- /dev/null +++ b/testdata/monitoring-sinks.golden.table.txt @@ -0,0 +1,23 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[monitoring-sinks] Reviewing visible and declared monitoring destinations that DCRs and diagnostic settings can route telemetry toward. +table view is compact by design; the JSON artifact keeps the fuller visible field set +ho-azure monitoring-sinks + +╭──────────────┬───────────┬──────────────────────┬────────┬──────────╮ +│ sink │ kind │ visible from │ routes │ sentinel │ +├──────────────┼───────────┼──────────────────────┼────────┼──────────┤ +│ send │ eventHubs │ declared destination │ 2 │ unknown │ +│ law-soc-prod │ sentinel │ resource inventory │ 2 │ visible │ +│ stdataprod │ storage │ resource inventory │ 0 │ unknown │ +╰──────────────┴───────────┴──────────────────────┴────────┴──────────╯ + +Takeaway: 3 visible or declared monitoring sink(s); 2 referenced by DCR or diagnostic-settings routes; kinds: eventHubs, sentinel, storage. + +Not collected by default +item | classification | reason + +expected SOC baseline | proof boundary | Visible sinks and declared telemetry routes do not prove which sink defenders expect. +sink contents | proof boundary | The helper does not query Log Analytics, Storage, Event Hub, or partner sink contents. +detector wiring | proof boundary | Sentinel enablement is posture only; rule dependencies and alert behavior are not inspected. diff --git a/testdata/pathmasking-api-mgmt.golden.csv b/testdata/pathmasking-api-mgmt.golden.csv new file mode 100644 index 0000000..40d7e5a --- /dev/null +++ b/testdata/pathmasking-api-mgmt.golden.csv @@ -0,0 +1,2 @@ +id,api_management_service,resource_group,location,masking_rank,masking_reason,gateway_hostnames,backend_hostnames,api_count,subscription_count,current_identity,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01,apim-edge-01,rg-apps,eastus,5,"public gateway, backend indirection, and API/consumer contract posture are visible; current identity has visible APIM route or backend policy control","[""apim-edge-01.azure-api.net"",""api.contoso.com""]","[""orders-internal.contoso.local""]",2,3,APIM route/backend write,"service ""apim-edge-01"" ranks 5/5 for APIM pathmasking posture; 2 gateway hostname(s); 1 backend hostname(s); current identity can change route or backend posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01"",""99990000-0000-0000-0000-000000000001"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01""]" diff --git a/testdata/pathmasking-api-mgmt.golden.json b/testdata/pathmasking-api-mgmt.golden.json new file mode 100644 index 0000000..728b9c2 --- /dev/null +++ b/testdata/pathmasking-api-mgmt.golden.json @@ -0,0 +1,139 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "pathmasking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "pathmasking", + "surface": "api-mgmt", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review API Management services for gateway, backend, hostname, subscription, and named-value posture that can mask the true public-to-backend path.", + "backing_commands": [ + "api-mgmt", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01", + "api_management_service": "apim-edge-01", + "resource_group": "rg-apps", + "location": "eastus", + "masking_rank": 5, + "masking_reason": "public gateway, backend indirection, and API/consumer contract posture are visible; current identity has visible APIM route or backend policy control", + "capability_steps": [ + { + "action": "select public gateway", + "api_surface": "Microsoft.ApiManagement/service", + "status": "visible posture only", + "downstream_effect": "Clients see the APIM hostname instead of the backend service path.", + "boundary": "Gateway posture does not prove traffic volume." + }, + { + "action": "identify backend indirection", + "api_surface": "Microsoft.ApiManagement/service/backends", + "status": "visible posture only", + "downstream_effect": "Shows where the published API surface can forward requests behind the gateway.", + "boundary": "Backend hostname posture does not prove ownership or reachability." + }, + { + "action": "apply route or transform policy", + "api_surface": "APIM policies and backend settings", + "status": "yes", + "downstream_effect": "Can rewrite paths, switch backends, normalize auth, or keep the true route opaque to callers.", + "boundary": "Policy XML bodies are not collected by default." + }, + { + "action": "preserve consumer-facing contract", + "api_surface": "APIs, operations, products, subscriptions", + "status": "yes", + "downstream_effect": "Existing products and subscriptions can keep caller behavior normal while the backend path stays abstracted.", + "boundary": "Consumer use and request contents require runtime logs." + }, + { + "action": "blend as API gateway operations", + "api_surface": "APIM service configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include API versioning, throttling, partner exposure, backend migration, and failover.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "APIM route/backend write", + "summary": "Current foothold `azurefox-lab-sp` has visible APIM route or backend policy control." + }, + "current_state": { + "gateway_hostnames": [ + "apim-edge-01.azure-api.net", + "api.contoso.com" + ], + "backend_hostnames": [ + "orders-internal.contoso.local" + ], + "api_count": 2, + "subscription_count": 3, + "policy_count": 2, + "policy_control_types": [ + "backend-routing", + "conditional-routing", + "header-auth", + "request-rewrite" + ], + "named_value_secret_count": 1, + "named_value_key_vault_count": 1, + "public_network_access": "Enabled", + "virtual_network_type": "External", + "posture": "2 gateway hostname(s); 1 backend hostname(s); 2 API(s); 3 subscription(s); policy controls: backend-routing, conditional-routing, header-auth, request-rewrite" + }, + "not_collected_by_default": [ + { + "name": "policy XML bodies", + "classification": "recon safety", + "reason": "The flat helper parses safe policy control types, but does not print raw policy XML or named-value expansions." + }, + { + "name": "live request flow", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove callers used this path or which backend received traffic." + }, + { + "name": "request contents", + "classification": "proof boundary", + "reason": "The command does not inspect APIM gateway logs or request payloads." + }, + { + "name": "backend ownership", + "classification": "proof boundary", + "reason": "A backend hostname does not prove who controls the target or what process answers." + }, + { + "name": "named-value values", + "classification": "recon safety", + "reason": "Secret and Key Vault-backed named values are counted but values are not printed." + } + ], + "summary": "service \"apim-edge-01\" ranks 5/5 for APIM pathmasking posture; 2 gateway hostname(s); 1 backend hostname(s); current identity can change route or backend posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01", + "99990000-0000-0000-0000-000000000001", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01" + ] + } + ], + "issues": [] +} diff --git a/testdata/pathmasking-api-mgmt.golden.table.txt b/testdata/pathmasking-api-mgmt.golden.table.txt new file mode 100644 index 0000000..c511b89 --- /dev/null +++ b/testdata/pathmasking-api-mgmt.golden.table.txt @@ -0,0 +1,37 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[pathmasking] APIM path masking means the API gateway can preserve a public contract while backend, route, or policy indirection hides the true downstream path. +APIM pathmasking capability + +╭───────────────────────────────────┬───────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├───────────────────────────────────┼───────────────────────────────────────────┼──────────────────────┤ +│ select public gateway │ Microsoft.ApiManagement/service │ visible posture only │ +│ identify backend indirection │ Microsoft.ApiManagement/service/backends │ visible posture only │ +│ apply route or transform policy │ APIM policies and backend settings │ yes │ +│ preserve consumer-facing contract │ APIs, operations, products, subscriptions │ yes │ +│ blend as API gateway operations │ APIM service configuration │ visible posture only │ +╰───────────────────────────────────┴───────────────────────────────────────────┴──────────────────────╯ + +Operator read +service "apim-edge-01" ranks 5/5 for APIM pathmasking posture; 2 gateway hostname(s); 1 backend hostname(s); current identity can change route or backend posture from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible APIM route or backend policy control. +Downstream effect: public gateway, backend indirection, and API/consumer contract posture are visible; current identity has visible APIM route or backend policy control +First boundary: this is APIM management-plane posture, not policy-body proof, live request proof, or backend ownership proof. +Posture: 2 gateway hostname(s); 1 backend hostname(s); 2 API(s); 3 subscription(s); policy controls: backend-routing, conditional-routing, header-auth, request-rewrite. + +Visible APIM Services +service | rank | gateways | backends | subscriptions | current identity + +apim-edge-01 | 5/5 | apim-edge-01.azure-api.net, api.contoso.com | orders-internal.contoso.local | 3 | APIM route/backend write + + +Not collected by default +item | classification | reason + +policy XML bodies | recon safety | The flat helper parses safe policy control types, but does not print raw policy XML or named-value expansions. +live request flow | proof boundary | Management-plane posture cannot prove callers used this path or which backend received traffic. +request contents | proof boundary | The command does not inspect APIM gateway logs or request payloads. +backend ownership | proof boundary | A backend hostname does not prove who controls the target or what process answers. +named-value values | recon safety | Secret and Key Vault-backed named values are counted but values are not printed. diff --git a/testdata/pathmasking-logic-apps.golden.csv b/testdata/pathmasking-logic-apps.golden.csv new file mode 100644 index 0000000..497968e --- /dev/null +++ b/testdata/pathmasking-logic-apps.golden.csv @@ -0,0 +1,4 @@ +id,logic_app,resource_group,location,masking_rank,masking_reason,trigger_types,externally_callable_request_trigger,recurrence_summary,downstream_action_kinds,identity_type,current_identity,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router,la-event-router,rg-workflow,eastus,5,"request or connector path, downstream action posture, and workflow identity are visible; current identity has visible Logic App route or relay workflow write control","[""api-connection""]",false,,"[""function"",""messaging""]",UserAssigned,workflow write,"workflow ""la-event-router"" ranks 5/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy,la-inbound-redeploy,rg-workflow,centralus,5,"request or connector path, downstream action posture, and workflow identity are visible; current identity has visible Logic App route or relay workflow write control","[""request""]",true,,"[""automation"",""external-http""]",SystemAssigned,workflow write,"workflow ""la-inbound-redeploy"" ranks 5/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync,la-nightly-sync,rg-workflow,centralus,3,trigger and downstream action posture are visible; current identity has visible Logic App route or relay workflow write control,"[""recurrence""]",false,Day/1,"[""storage"",""connector""]",,workflow write,"workflow ""la-nightly-sync"" ranks 3/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync""]" diff --git a/testdata/pathmasking-logic-apps.golden.json b/testdata/pathmasking-logic-apps.golden.json new file mode 100644 index 0000000..c66b27a --- /dev/null +++ b/testdata/pathmasking-logic-apps.golden.json @@ -0,0 +1,367 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "pathmasking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "pathmasking", + "surface": "logic-apps", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review Logic Apps for request, schedule, connector, HTTP action, and identity posture that can relay activity through trusted integration workflows.", + "backing_commands": [ + "logic-apps", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router", + "logic_app": "la-event-router", + "resource_group": "rg-workflow", + "location": "eastus", + "masking_rank": 5, + "masking_reason": "request or connector path, downstream action posture, and workflow identity are visible; current identity has visible Logic App route or relay workflow write control", + "capability_steps": [ + { + "action": "select trusted workflow", + "api_surface": "Microsoft.Logic/workflows", + "status": "visible posture only", + "downstream_effect": "Keeps the visible activity inside an existing Azure integration resource rather than a direct caller-to-target path.", + "boundary": "Workflow posture does not prove the workflow ran." + }, + { + "action": "identify trigger entry point", + "api_surface": "request, recurrence, api-connection, or event trigger", + "status": "visible posture only", + "downstream_effect": "Request, schedule, and connector triggers can make the workflow the front door instead of the operator or caller.", + "boundary": "Trigger posture does not prove invocation or caller identity." + }, + { + "action": "map downstream relay actions", + "api_surface": "HTTP, api-connection, or service actions", + "status": "visible posture only", + "downstream_effect": "Downstream actions show where the workflow can forward, transform, or broker activity through trusted connectors.", + "boundary": "Default output does not print full workflow bodies or payloads." + }, + { + "action": "change workflow route", + "api_surface": "workflow definition", + "status": "yes", + "downstream_effect": "Can repoint HTTP actions, reshape branches, or preserve the trigger while changing the downstream path.", + "boundary": "Write capability is inferred only from visible management-plane RBAC." + }, + { + "action": "blend as integration maintenance", + "api_surface": "workflow configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include connector repair, retry tuning, endpoint migration, and integration modernization.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "workflow write", + "summary": "Current foothold `azurefox-lab-sp` has visible Logic App route or relay workflow write control." + }, + "current_state": { + "platform": "Consumption", + "state": "Enabled", + "trigger_types": [ + "api-connection" + ], + "externally_callable_request_trigger": false, + "downstream_action_kinds": [ + "function", + "messaging" + ], + "connector_references": [ + "eventgrid" + ], + "parameter_names": [], + "downstream_resource_references": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ], + "identity_type": "UserAssigned", + "identity_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router" + ], + "posture": "trigger types api-connection; downstream actions function, messaging; connector references eventgrid; 1 downstream resource reference(s); managed identity posture" + }, + "not_collected_by_default": [ + { + "name": "full workflow definition body", + "classification": "collector issue", + "reason": "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default." + }, + { + "name": "run history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove the workflow ran, succeeded, or carried traffic." + }, + { + "name": "connector credential values", + "classification": "recon safety", + "reason": "Connector secrets and connection credential material are not safe default output." + }, + { + "name": "payload and response contents", + "classification": "proof boundary", + "reason": "The command does not inspect trigger payloads, action payloads, or response data." + }, + { + "name": "workflow change history", + "classification": "API/noise", + "reason": "Broad workflow history is not needed for default posture and should stay a narrow follow-up for timing or actor proof." + } + ], + "summary": "workflow \"la-event-router\" ranks 5/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy", + "logic_app": "la-inbound-redeploy", + "resource_group": "rg-workflow", + "location": "centralus", + "masking_rank": 5, + "masking_reason": "request or connector path, downstream action posture, and workflow identity are visible; current identity has visible Logic App route or relay workflow write control", + "capability_steps": [ + { + "action": "select trusted workflow", + "api_surface": "Microsoft.Logic/workflows", + "status": "visible posture only", + "downstream_effect": "Keeps the visible activity inside an existing Azure integration resource rather than a direct caller-to-target path.", + "boundary": "Workflow posture does not prove the workflow ran." + }, + { + "action": "identify trigger entry point", + "api_surface": "request, recurrence, api-connection, or event trigger", + "status": "visible posture only", + "downstream_effect": "Request, schedule, and connector triggers can make the workflow the front door instead of the operator or caller.", + "boundary": "Trigger posture does not prove invocation or caller identity." + }, + { + "action": "map downstream relay actions", + "api_surface": "HTTP, api-connection, or service actions", + "status": "visible posture only", + "downstream_effect": "Downstream actions show where the workflow can forward, transform, or broker activity through trusted connectors.", + "boundary": "Default output does not print full workflow bodies or payloads." + }, + { + "action": "change workflow route", + "api_surface": "workflow definition", + "status": "yes", + "downstream_effect": "Can repoint HTTP actions, reshape branches, or preserve the trigger while changing the downstream path.", + "boundary": "Write capability is inferred only from visible management-plane RBAC." + }, + { + "action": "blend as integration maintenance", + "api_surface": "workflow configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include connector repair, retry tuning, endpoint migration, and integration modernization.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "workflow write", + "summary": "Current foothold `azurefox-lab-sp` has visible Logic App route or relay workflow write control." + }, + "current_state": { + "platform": "Consumption", + "state": "Enabled", + "trigger_types": [ + "request" + ], + "externally_callable_request_trigger": true, + "downstream_action_kinds": [ + "automation", + "external-http" + ], + "connector_references": [], + "parameter_names": [ + "automationAccountName", + "runbookName" + ], + "downstream_resource_references": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" + ], + "identity_type": "SystemAssigned", + "identity_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system" + ], + "posture": "externally callable request trigger; trigger types request; downstream actions automation, external-http; 1 downstream resource reference(s); managed identity posture" + }, + "not_collected_by_default": [ + { + "name": "full workflow definition body", + "classification": "collector issue", + "reason": "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default." + }, + { + "name": "run history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove the workflow ran, succeeded, or carried traffic." + }, + { + "name": "connector credential values", + "classification": "recon safety", + "reason": "Connector secrets and connection credential material are not safe default output." + }, + { + "name": "payload and response contents", + "classification": "proof boundary", + "reason": "The command does not inspect trigger payloads, action payloads, or response data." + }, + { + "name": "workflow change history", + "classification": "API/noise", + "reason": "Broad workflow history is not needed for default posture and should stay a narrow follow-up for timing or actor proof." + } + ], + "summary": "workflow \"la-inbound-redeploy\" ranks 5/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync", + "logic_app": "la-nightly-sync", + "resource_group": "rg-workflow", + "location": "centralus", + "masking_rank": 3, + "masking_reason": "trigger and downstream action posture are visible; current identity has visible Logic App route or relay workflow write control", + "capability_steps": [ + { + "action": "select trusted workflow", + "api_surface": "Microsoft.Logic/workflows", + "status": "visible posture only", + "downstream_effect": "Keeps the visible activity inside an existing Azure integration resource rather than a direct caller-to-target path.", + "boundary": "Workflow posture does not prove the workflow ran." + }, + { + "action": "identify trigger entry point", + "api_surface": "request, recurrence, api-connection, or event trigger", + "status": "visible posture only", + "downstream_effect": "Request, schedule, and connector triggers can make the workflow the front door instead of the operator or caller.", + "boundary": "Trigger posture does not prove invocation or caller identity." + }, + { + "action": "map downstream relay actions", + "api_surface": "HTTP, api-connection, or service actions", + "status": "visible posture only", + "downstream_effect": "Downstream actions show where the workflow can forward, transform, or broker activity through trusted connectors.", + "boundary": "Default output does not print full workflow bodies or payloads." + }, + { + "action": "change workflow route", + "api_surface": "workflow definition", + "status": "yes", + "downstream_effect": "Can repoint HTTP actions, reshape branches, or preserve the trigger while changing the downstream path.", + "boundary": "Write capability is inferred only from visible management-plane RBAC." + }, + { + "action": "blend as integration maintenance", + "api_surface": "workflow configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include connector repair, retry tuning, endpoint migration, and integration modernization.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "workflow write", + "summary": "Current foothold `azurefox-lab-sp` has visible Logic App route or relay workflow write control." + }, + "current_state": { + "platform": "Consumption", + "state": "Enabled", + "trigger_types": [ + "recurrence" + ], + "externally_callable_request_trigger": false, + "recurrence_summary": "Day/1", + "downstream_action_kinds": [ + "storage", + "connector" + ], + "connector_references": [ + "azureblob" + ], + "parameter_names": [ + "storageAccountName" + ], + "downstream_resource_references": [], + "identity_ids": [], + "posture": "recurrence trigger Day/1; trigger types recurrence; downstream actions storage, connector; connector references azureblob" + }, + "not_collected_by_default": [ + { + "name": "full workflow definition body", + "classification": "collector issue", + "reason": "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default." + }, + { + "name": "run history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove the workflow ran, succeeded, or carried traffic." + }, + { + "name": "connector credential values", + "classification": "recon safety", + "reason": "Connector secrets and connection credential material are not safe default output." + }, + { + "name": "payload and response contents", + "classification": "proof boundary", + "reason": "The command does not inspect trigger payloads, action payloads, or response data." + }, + { + "name": "workflow change history", + "classification": "API/noise", + "reason": "Broad workflow history is not needed for default posture and should stay a narrow follow-up for timing or actor proof." + } + ], + "summary": "workflow \"la-nightly-sync\" ranks 3/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync" + ] + } + ], + "issues": [] +} diff --git a/testdata/pathmasking-logic-apps.golden.table.txt b/testdata/pathmasking-logic-apps.golden.table.txt new file mode 100644 index 0000000..0e35109 --- /dev/null +++ b/testdata/pathmasking-logic-apps.golden.table.txt @@ -0,0 +1,40 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[pathmasking] Logic Apps path masking means a trusted workflow can act as the visible relay while downstream actions, connectors, or identities carry the real path. +Logic Apps pathmasking capability + +╭──────────────────────────────────┬───────────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├──────────────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────┤ +│ select trusted workflow │ Microsoft.Logic/workflows │ visible posture only │ +│ identify trigger entry point │ request, recurrence, api-connection, or event trigger │ visible posture only │ +│ map downstream relay actions │ HTTP, api-connection, or service actions │ visible posture only │ +│ change workflow route │ workflow definition │ yes │ +│ blend as integration maintenance │ workflow configuration │ visible posture only │ +╰──────────────────────────────────┴───────────────────────────────────────────────────────┴──────────────────────╯ +This walkthrough shows the strongest currently visible Logic App relay path. The inventory below lists the other visible workflows without repeating the same narrative. + +Operator read +workflow "la-event-router" ranks 5/5 for Logic Apps pathmasking posture; 1 trigger type(s); 2 downstream action kind(s); current identity can change workflow path posture from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible Logic App route or relay workflow write control. +Downstream effect: request or connector path, downstream action posture, and workflow identity are visible; current identity has visible Logic App route or relay workflow write control +First boundary: this is Logic App management-plane posture, not run-history proof, connector payload proof, or credential-material proof. +Posture: trigger types api-connection; downstream actions function, messaging; connector references eventgrid; 1 downstream resource reference(s); managed identity posture. + +Visible Logic Apps +workflow | rank | triggers | actions | identity | current identity + +la-event-router | 5/5 | api-connection | function, messaging | UserAssigned | workflow write +la-inbound-redeploy | 5/5 | request | automation, external-http | SystemAssigned | workflow write +la-nightly-sync | 3/5 | recurrence | storage, connector | | workflow write + + +Not collected by default +item | classification | reason + +full workflow definition body | collector issue | The helper reports trigger and downstream action posture but does not print complete workflow JSON by default. +run history | proof boundary | Management-plane posture cannot prove the workflow ran, succeeded, or carried traffic. +connector credential values | recon safety | Connector secrets and connection credential material are not safe default output. +payload and response contents | proof boundary | The command does not inspect trigger payloads, action payloads, or response data. +workflow change history | API/noise | Broad workflow history is not needed for default posture and should stay a narrow follow-up for timing or actor proof. diff --git a/testdata/pathmasking-relay.golden.csv b/testdata/pathmasking-relay.golden.csv new file mode 100644 index 0000000..1e005bc --- /dev/null +++ b/testdata/pathmasking-relay.golden.csv @@ -0,0 +1,2 @@ +id,relay_namespace,resource_group,location,masking_rank,masking_reason,service_bus_endpoint,hybrid_connection_count,hybrid_connection_names,authorization_rule_count,listener_summary,app_service_attachments,current_identity,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod,relay-hybrid-prod,rg-integration,eastus,5,"Hybrid Connection, authorization rule, listener-count, and App Service attachment posture are visible; current identity has visible Relay namespace or Hybrid Connection write control",https://relay-hybrid-prod.servicebus.windows.net:443/,1,"[""onprem-orders""]",2,onprem-orders=1,"[""onprem-orders-\u003eapp-public-api""]",Relay write,"namespace ""relay-hybrid-prod"" ranks 5/5 for Relay pathmasking posture; 1 Hybrid Connection(s); 2 authorization rule(s); 1 App Service attachment(s); current identity can change Relay path posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod/hybridConnections/onprem-orders"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api/hybridConnectionNamespaces/relay-hybrid-prod/relays/onprem-orders"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api""]" diff --git a/testdata/pathmasking-relay.golden.json b/testdata/pathmasking-relay.golden.json new file mode 100644 index 0000000..8918f87 --- /dev/null +++ b/testdata/pathmasking-relay.golden.json @@ -0,0 +1,130 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "pathmasking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "pathmasking", + "surface": "relay", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review Azure Relay namespaces and Hybrid Connections for cloud rendezvous points that can blur the path to private listeners or internal services.", + "backing_commands": [ + "relay", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod", + "relay_namespace": "relay-hybrid-prod", + "resource_group": "rg-integration", + "location": "eastus", + "masking_rank": 5, + "masking_reason": "Hybrid Connection, authorization rule, listener-count, and App Service attachment posture are visible; current identity has visible Relay namespace or Hybrid Connection write control", + "capability_steps": [ + { + "action": "select Relay namespace", + "api_surface": "Microsoft.Relay/namespaces", + "status": "visible posture only", + "downstream_effect": "Azure becomes the visible rendezvous point while the listener and backend can stay off the direct public path.", + "boundary": "Namespace posture does not prove a listener is currently connected." + }, + { + "action": "identify Hybrid Connections", + "api_surface": "Microsoft.Relay/namespaces/hybridConnections", + "status": "visible posture only", + "downstream_effect": "Hybrid Connections show named private-path channels that can bridge callers toward internal services.", + "boundary": "Hybrid Connection posture does not identify the backend process." + }, + { + "action": "review authorization rules", + "api_surface": "Microsoft.Relay/namespaces/authorizationRules", + "status": "visible posture only", + "downstream_effect": "Authorization rules show where management-plane control could sustain or reshape send/listen access paths.", + "boundary": "The command does not retrieve keys or prove data-plane use." + }, + { + "action": "change namespace or connection posture", + "api_surface": "Relay namespace and Hybrid Connection configuration", + "status": "yes", + "downstream_effect": "Can add, remove, or reconfigure the cloud rendezvous path while preserving an Azure-native integration story.", + "boundary": "Write capability is inferred only from visible management-plane RBAC." + }, + { + "action": "blend as private connectivity", + "api_surface": "Relay service configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include hybrid integration, firewall avoidance for approved apps, partner connectivity, and private service migration.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "Relay write", + "summary": "Current foothold `azurefox-lab-sp` has visible Relay namespace or Hybrid Connection write control." + }, + "current_state": { + "service_bus_endpoint": "https://relay-hybrid-prod.servicebus.windows.net:443/", + "hybrid_connection_count": 1, + "authorization_rule_count": 2, + "hybrid_connection_names": [ + "onprem-orders" + ], + "listener_summary": "onprem-orders=1", + "app_service_attachments": [ + "onprem-orders-\u003eapp-public-api" + ], + "posture": "1 Hybrid Connection(s); 2 authorization rule(s); listener counts onprem-orders=1; 1 App Service Hybrid Connection attachment(s)" + }, + "not_collected_by_default": [ + { + "name": "listener runtime state", + "classification": "proof boundary", + "reason": "Management-plane posture and listener counts do not prove a current listener process, host, or session." + }, + { + "name": "backend process and host", + "classification": "proof boundary", + "reason": "Relay names and endpoints do not identify the private service or process behind the listener." + }, + { + "name": "traffic contents", + "classification": "proof boundary", + "reason": "The command does not inspect Relay traffic or payloads." + }, + { + "name": "authorization keys", + "classification": "recon safety", + "reason": "Authorization rules are counted, but key material is not retrieved or printed." + }, + { + "name": "App Service backend internals", + "classification": "proof boundary", + "reason": "App Service Hybrid Connection attachments can show reachability posture, but they do not identify the private listener host, process, or traffic contents." + } + ], + "summary": "namespace \"relay-hybrid-prod\" ranks 5/5 for Relay pathmasking posture; 1 Hybrid Connection(s); 2 authorization rule(s); 1 App Service attachment(s); current identity can change Relay path posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod/hybridConnections/onprem-orders", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api/hybridConnectionNamespaces/relay-hybrid-prod/relays/onprem-orders", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + ] + } + ], + "issues": [] +} diff --git a/testdata/pathmasking-relay.golden.table.txt b/testdata/pathmasking-relay.golden.table.txt new file mode 100644 index 0000000..193e453 --- /dev/null +++ b/testdata/pathmasking-relay.golden.table.txt @@ -0,0 +1,37 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[pathmasking] Relay path masking means Azure exposes the cloud rendezvous point while the private listener, backend host, and traffic contents stay beyond management-plane proof. +Relay pathmasking capability + +╭────────────────────────────────────────┬─────────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├────────────────────────────────────────┼─────────────────────────────────────────────────────┼──────────────────────┤ +│ select Relay namespace │ Microsoft.Relay/namespaces │ visible posture only │ +│ identify Hybrid Connections │ Microsoft.Relay/namespaces/hybridConnections │ visible posture only │ +│ review authorization rules │ Microsoft.Relay/namespaces/authorizationRules │ visible posture only │ +│ change namespace or connection posture │ Relay namespace and Hybrid Connection configuration │ yes │ +│ blend as private connectivity │ Relay service configuration │ visible posture only │ +╰────────────────────────────────────────┴─────────────────────────────────────────────────────┴──────────────────────╯ + +Operator read +namespace "relay-hybrid-prod" ranks 5/5 for Relay pathmasking posture; 1 Hybrid Connection(s); 2 authorization rule(s); 1 App Service attachment(s); current identity can change Relay path posture from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible Relay namespace or Hybrid Connection write control. +Downstream effect: Hybrid Connection, authorization rule, listener-count, and App Service attachment posture are visible; current identity has visible Relay namespace or Hybrid Connection write control +First boundary: this is Relay management-plane posture, not listener-runtime proof, backend process proof, or traffic-content proof. +Posture: 1 Hybrid Connection(s); 2 authorization rule(s); listener counts onprem-orders=1; 1 App Service Hybrid Connection attachment(s). + +Visible Relay Namespaces +namespace | rank | hybrid connections | auth rules | listeners | current identity + +relay-hybrid-prod | 5/5 | 1 | 2 | onprem-orders=1 | Relay write + + +Not collected by default +item | classification | reason + +listener runtime state | proof boundary | Management-plane posture and listener counts do not prove a current listener process, host, or session. +backend process and host | proof boundary | Relay names and endpoints do not identify the private service or process behind the listener. +traffic contents | proof boundary | The command does not inspect Relay traffic or payloads. +authorization keys | recon safety | Authorization rules are counted, but key material is not retrieved or printed. +App Service backend internals | proof boundary | App Service Hybrid Connection attachments can show reachability posture, but they do not identify the private listener host, process, or traffic contents. diff --git a/testdata/pathmasking.golden.csv b/testdata/pathmasking.golden.csv new file mode 100644 index 0000000..c48cc40 --- /dev/null +++ b/testdata/pathmasking.golden.csv @@ -0,0 +1,4 @@ +surface,state,summary,operator_question,backing_commands +api-mgmt,implemented,"Review API Management services for gateway, backend, hostname, subscription, and named-value posture that can mask the true public-to-backend path.","How far can current access take me through APIM gateway, route, transform, and backend indirection before the proof boundary moves into policy bodies, live traffic, or backend ownership?","[""api-mgmt"",""permissions"",""rbac""]" +logic-apps,implemented,"Review Logic Apps for request, schedule, connector, HTTP action, and identity posture that can relay activity through trusted integration workflows.","Which visible workflows can current access reuse or modify as a trusted relay path before the proof boundary moves into run history, connector payloads, or credential material?","[""logic-apps"",""permissions"",""rbac""]" +relay,implemented,Review Azure Relay namespaces and Hybrid Connections for cloud rendezvous points that can blur the path to private listeners or internal services.,"Which Relay namespaces and Hybrid Connections give current access a visible private-path rendezvous before the proof boundary moves into listener runtime, backend process, or traffic contents?","[""relay"",""permissions"",""rbac""]" diff --git a/testdata/pathmasking.golden.json b/testdata/pathmasking.golden.json new file mode 100644 index 0000000..25d94f7 --- /dev/null +++ b/testdata/pathmasking.golden.json @@ -0,0 +1,59 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "pathmasking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": null, + "subscription_id": null, + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "pathmasking", + "command_state": "implemented", + "current_behavior": "Grouped pathmasking walkthroughs. Use `ho-azure pathmasking` or `ho-azure pathmasking help` to list surfaces, then `ho-azure pathmasking \u003csurface\u003e` to run an implemented surface.", + "planned_input_modes": [ + "live" + ], + "preferred_artifact_order": [ + "loot", + "json" + ], + "selected_surface": null, + "surfaces": [ + { + "surface": "api-mgmt", + "state": "implemented", + "summary": "Review API Management services for gateway, backend, hostname, subscription, and named-value posture that can mask the true public-to-backend path.", + "operator_question": "How far can current access take me through APIM gateway, route, transform, and backend indirection before the proof boundary moves into policy bodies, live traffic, or backend ownership?", + "backing_commands": [ + "api-mgmt", + "permissions", + "rbac" + ] + }, + { + "surface": "logic-apps", + "state": "implemented", + "summary": "Review Logic Apps for request, schedule, connector, HTTP action, and identity posture that can relay activity through trusted integration workflows.", + "operator_question": "Which visible workflows can current access reuse or modify as a trusted relay path before the proof boundary moves into run history, connector payloads, or credential material?", + "backing_commands": [ + "logic-apps", + "permissions", + "rbac" + ] + }, + { + "surface": "relay", + "state": "implemented", + "summary": "Review Azure Relay namespaces and Hybrid Connections for cloud rendezvous points that can blur the path to private listeners or internal services.", + "operator_question": "Which Relay namespaces and Hybrid Connections give current access a visible private-path rendezvous before the proof boundary moves into listener runtime, backend process, or traffic contents?", + "backing_commands": [ + "relay", + "permissions", + "rbac" + ] + } + ], + "issues": [] +} diff --git a/testdata/pathmasking.golden.table.txt b/testdata/pathmasking.golden.table.txt new file mode 100644 index 0000000..17f2247 --- /dev/null +++ b/testdata/pathmasking.golden.table.txt @@ -0,0 +1,15 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[pathmasking] Walking the current identity through Azure-native relay, proxy, and workflow surfaces that can blur caller-to-target paths. +ho-azure pathmasking + +╭────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ surface │ summary │ +├────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ api-mgmt │ Review API Management services for gateway, backend, hostname, subscription, and named-value posture that can mask the true public-to-backend path. │ +│ logic-apps │ Review Logic Apps for request, schedule, connector, HTTP action, and identity posture that can relay activity through trusted integration workflows. │ +│ relay │ Review Azure Relay namespaces and Hybrid Connections for cloud rendezvous points that can blur the path to private listeners or internal services. │ +╰────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Takeaway: 3 pathmasking surface(s) available; run a surface to rank visible posture by path ambiguity and attribution-blur value. diff --git a/testdata/persistence-app-service.golden.table.txt b/testdata/persistence-app-service.golden.table.txt index 6b8ff9f..bfe83f7 100644 --- a/testdata/persistence-app-service.golden.table.txt +++ b/testdata/persistence-app-service.golden.table.txt @@ -43,9 +43,9 @@ This walkthrough shows the strongest currently visible App Service persistence p - Because the app stays deployed, reachable, and trusted until changed, this remains reusable App Service persistence even after the original session is gone. Visible App Services -app service | resource group | visible state | execution context +app service | resource group | visible state | execution context -app-empty-mi | rg-apps | Running; hostname app-empty-mi.azurewebsites.net; DOTNETCORE|8.0; run-from-package disabled; settings=2; conn=1; public Enabled | `app-empty-mi-system` with Contributor at resource group `rg-apps` +app-empty-mi | rg-apps | Running; hostname app-empty-mi.azurewebsites.net; DOTNETCORE|8.0; run-from-package disabled; settings=2; conn=1; public Enabled | `app-empty-mi-system` with Contributor at resource group `rg-apps` app-public-api | rg-apps | Running; hostname app-public-api.azurewebsites.net; NODE|20-lts; repo github.com/contoso/customer-portal, branch main, GitHub Actions, continuous integration, run-from-package enabled; settings=4; kv=2; conn=2; public Enabled | `app-public-api-system` with no Azure role-assignment rows found for its principal ID diff --git a/testdata/persistence-automation.golden.table.txt b/testdata/persistence-automation.golden.table.txt index 95e0039..62b9b09 100644 --- a/testdata/persistence-automation.golden.table.txt +++ b/testdata/persistence-automation.golden.table.txt @@ -18,12 +18,16 @@ Automation capability ╰──────────────────────────┴──────────────────────────────────────┴────────╯ This walkthrough shows the strongest currently visible Automation persistence path. The inventory below lists the other visible accounts without repeating the same narrative. - Current identity can create or modify an Azure Automation Account. + The Automation account is the Azure-side container for runbooks, schedules, webhooks, identity, and secure assets; no VM or host login is required to keep this path in Azure. - Current identity can add or edit a runbook inside an existing Azure Automation Account. + A runbook is the stored container first; it becomes useful execution only after content is added and a published version exists. - Current identity can upload or replace the code inside a runbook. + This is the runnable content layer: PowerShell or Python runbook content can call Azure APIs, reach storage or Key Vault, make outbound calls, or drive host actions through control-plane paths. - Current identity can publish runnable automation so Azure can execute it later. + Automation keeps draft and published runbook versions; publishing is the step that makes the stored content runnable in Azure. - Current identity can attach or reuse execution context for that runbook. Managed identity, stored credentials, connections, certificates, variables, or other Automation assets may provide that execution context. @@ -32,15 +36,17 @@ This walkthrough shows the strongest currently visible Automation persistence pa - Current identity can create durable triggers for the runbook, including schedules, schedule links, and webhooks. Visible schedule definitions here include `baseline-nightly: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T01:00:00Z; enabled=true`, `cert-rotation-weekly: frequency=Week; interval=1; timezone=UTC; start=2026-04-13T02:00:00Z; enabled=true; weekdays=Sunday`, and `nightly-reconcile: frequency=Day; interval=1; timezone=UTC; start=2026-04-13T04:00:00Z; enabled=true`, plus 1 more. + Schedules, job schedules, webhooks, or upstream services such as Logic Apps and Functions are the durable rerun anchors; a runbook without one is stored code but not a complete persistence path. - Current identity can repurpose an existing Azure Automation Account instead of creating a new one. + When triggered, Azure spins up a worker, loads the published runbook, executes under the selected identity or credential context, and then stops; persistence is the code, identity, and trigger remaining configured. Nearby maintenance- or schedule-themed names visible from the current environment include `Baseline-Config`, `Nightly-Reconcile`, `Reapply-Agent`, and `Lab-Maintenance`. Visible Automation Accounts -automation account | resource group | visible state | execution context +automation account | resource group | visible state | execution context aa-hybrid-prod | rg-ops | 6/7 published; schedules 4; webhooks 2; primary webhook | `aa-hybrid-prod-mi` with Contributor at resource group `rg-ops` -aa-lab-quiet | rg-lab | 1/2 published; schedules 1; webhooks 0; primary schedule | connections, variables +aa-lab-quiet | rg-lab | 1/2 published; schedules 1; webhooks 0; primary schedule | connections, variables Not collected by default diff --git a/testdata/persistence-azure-ml.golden.table.txt b/testdata/persistence-azure-ml.golden.table.txt index 75654cf..76c7544 100644 --- a/testdata/persistence-azure-ml.golden.table.txt +++ b/testdata/persistence-azure-ml.golden.table.txt @@ -24,6 +24,7 @@ This walkthrough shows the strongest currently visible Azure ML persistence path - Current identity can add or modify Azure ML jobs or pipelines that hold stored execution logic this workspace can run later. In Azure ML, persistence can live in saved notebooks, jobs, pipelines, scheduled jobs, and environment definitions. + Notebooks are interactive code surfaces, while jobs and pipelines are the scheduled or triggered execution surfaces. Those are the stored execution surfaces that can remain in the workspace even when no host is persistently compromised. - Current identity can attach or reuse execution context for this Azure ML workspace. @@ -46,11 +47,11 @@ This walkthrough shows the strongest currently visible Azure ML persistence path Nearby maintenance- or schedule-themed names visible from the current environment include `ml-nightly-train`. Visible Azure ML Workspaces -workspace | resource group | visible state | execution context +workspace | resource group | visible state | execution context ml-ops-hub | rg-ml | execution-capable; compute=ComputeCluster,ComputeInstance; jobs 2; schedules 1; endpoints 1; endpoint public Enabled; workspace public Enabled | `ua-ml-ops` with Owner at subscription scope -ml-nightly-train | rg-ml | supporting-persistence-context; schedules 1; workspace public Disabled | workspace-linked storage -ml-catalog | rg-ml | supporting-context; workspace public Enabled | workspace-linked storage +ml-nightly-train | rg-ml | supporting-persistence-context; schedules 1; workspace public Disabled | workspace-linked storage +ml-catalog | rg-ml | supporting-context; workspace public Enabled | workspace-linked storage Not collected by default diff --git a/testdata/persistence-container-apps-jobs.golden.table.txt b/testdata/persistence-container-apps-jobs.golden.table.txt index 3c33308..b065964 100644 --- a/testdata/persistence-container-apps-jobs.golden.table.txt +++ b/testdata/persistence-container-apps-jobs.golden.table.txt @@ -46,14 +46,15 @@ parallelism=1; secrets=2; registries=ghcr.io. Visible execution context here is `system-assigned identity for Container Apps job "nightly-reconcile"` with no Azure role-assignment rows found for its principal ID. That is enough to judge whether this Container Apps job already has trigger, image, execution-setting, or reuse value if stronger control is obtained later. + Higher permissions are required to complete the remaining persistence steps for this path. Visible Container Apps Jobs -container apps job | trigger | visible state | execution context +container apps job | trigger | visible state | execution context -nightly-reconcile | Schedule; schedule 0 3 * * * | environment aca-env-prod; 1 image clue(s); command clue visible; | `system-assigned identity for Container Apps job "nightly-reconcile"` - | | parallelism=1; secrets=2; registries=ghcr.io | with no Azure role-assignment rows found for its principal ID +nightly-reconcile | Schedule; schedule 0 3 * * * | environment aca-env-prod; 1 image clue(s); command clue visible; | `system-assigned identity for Container Apps job "nightly-reconcile"` + | | parallelism=1; secrets=2; registries=ghcr.io | with no Azure role-assignment rows found for its principal ID queue-drain | Event; 1 event rule(s) | environment aca-env-internal; 1 image clue(s); command clue visible; | `system-assigned identity for Container Apps job "queue-drain"` with no - | | parallelism=2; secrets=1; registries=contoso.azurecr.io | Azure role-assignment rows found for its principal ID + | | parallelism=2; secrets=1; registries=contoso.azurecr.io | Azure role-assignment rows found for its principal ID Not collected by default diff --git a/testdata/persistence-functions.golden.table.txt b/testdata/persistence-functions.golden.table.txt index 8288281..1e7f862 100644 --- a/testdata/persistence-functions.golden.table.txt +++ b/testdata/persistence-functions.golden.table.txt @@ -20,6 +20,7 @@ Azure Functions capability - Current identity can deploy or replace the function package Azure will load in this Function App. Because the current identity already controls this Function App, zip deploy, publish, or package replacement are part of the defended Functions persistence path here. Common deploy paths here include ZIP package deployment, pipeline deployment, run-from-package, or local project publish. + The Function App can exist without meaningful deployed logic; the package or project is the runnable payload Azure loads when a trigger fires. Visible deployment posture includes storage=plain-text. Visible deployment posture includes kv-refs=1. AzureWebJobsStorage is plain-text. @@ -30,7 +31,7 @@ Azure Functions capability HTTP-triggered functions are visible from management-plane metadata, including an invoke URL template and visible auth-level metadata. Timer, queue, Service Bus, or other event-driven triggers are visible from bindings, but they are not the same as a directly callable public entrypoint. The remaining gap is data-plane and runtime-side validation the current management-plane collector does not perform. - That includes function keys or caller auth actually in hand, upstream Service Bus or storage access, and any runtime-side restriction beyond the visible trigger metadata. + That includes function keys or caller auth actually in hand, upstream Service Bus, queue, storage, or binding access, and any runtime-side restriction beyond the visible trigger metadata. - Current identity can change app settings, identity attachment, and deployment configuration for this Function App. This is where runtime behavior, package behavior, and connection material get shaped for this Function App. @@ -49,7 +50,7 @@ Azure Functions capability Reusing an existing Function App can blend in better than standing up a brand-new serverless entrypoint. Visible Function Apps -function app | resource group | visible state | execution context +function app | resource group | visible state | execution context func-orders | rg-apps | Running; hostname visible; PYTHON|3.11; functions=~4; storage=plain-text; kv-refs=1; public Enabled; triggers=HTTP, timer, Service Bus | `ua-orders` with Owner at subscription scope diff --git a/testdata/persistence-logic-apps.golden.csv b/testdata/persistence-logic-apps.golden.csv index c4d5819..35d8584 100644 --- a/testdata/persistence-logic-apps.golden.csv +++ b/testdata/persistence-logic-apps.golden.csv @@ -1,4 +1,4 @@ id,logic_app,resource_group,location,create_or_modify_workflow,edit_workflow_definition,attach_or_reuse_exec_ctx,define_or_modify_trigger,enable_workflow,add_or_repurpose_downstream_actions,current_identity_context,execution_context_options,classification,platform,workflow_kind,state,trigger_types,externally_callable_request_trigger,recurrence_summary,identity_type,strongest_visible_execution_context,downstream_action_kinds,still_unmapped,summary,related_ids -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy,la-inbound-redeploy,rg-workflow,centralus,yes,yes,yes,yes,yes,yes,Current foothold `azurefox-lab-sp` already holds Owner at subscription scope.,"[""managed identity""]",persistence-capable,Consumption,,Enabled,"[""request""]",true,,SystemAssigned,"The strongest visible execution context here is the Logic App identity `la-inbound-redeploy-identity`, which already holds Contributor at resource group rg-workflow.","[""automation"",""external-http""]","[""the current command does not print full workflow definitions, connector secret material, or connection credential values, so operator intent is not inferred from hidden workflow content here"",""the current command does not print callback URLs, access signatures, or other trigger secret material behind the visible trigger posture"",""the current command does not invoke request triggers, validate upstream caller auth, or prove runtime-side trigger success"",""the current command does not resolve exact downstream payloads or high-value target impact without deeper workflow and connector inspection""]","Current identity can set up Logic App 'la-inbound-redeploy' as durable workflow persistence, and the strongest visible execution context already carries Azure control.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy,la-inbound-redeploy,rg-workflow,centralus,yes,yes,yes,yes,yes,yes,Current foothold `azurefox-lab-sp` already holds Owner at subscription scope.,"[""managed identity""]",persistence-capable,Consumption,,Enabled,"[""request""]",true,,SystemAssigned,"The strongest visible execution context here is the Logic App identity `la-inbound-redeploy-identity`, which already holds Contributor at resource group rg-workflow.","[""automation"",""external-http""]","[""the current command does not print full workflow definitions, connector secret material, or connection credential values, so operator intent is not inferred from hidden workflow content here"",""the current command does not print callback URLs, access signatures, or other trigger secret material behind the visible trigger posture"",""the current command does not invoke request triggers, validate upstream caller auth, or prove runtime-side trigger success"",""the current command does not resolve exact downstream payloads or high-value target impact without deeper workflow and connector inspection""]","Current identity can set up Logic App 'la-inbound-redeploy' as durable workflow persistence, and the strongest visible execution context already carries Azure control.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod""]" /subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync,la-nightly-sync,rg-workflow,centralus,yes,yes,yes,yes,yes,yes,Current foothold `azurefox-lab-sp` already holds Owner at subscription scope.,"[""connector-backed actions""]",persistence-capable,Consumption,,Enabled,"[""recurrence""]",false,Day/1,,,"[""storage"",""connector""]","[""the current command does not print full workflow definitions, connector secret material, or connection credential values, so operator intent is not inferred from hidden workflow content here"",""the current command does not print callback URLs, access signatures, or other trigger secret material behind the visible trigger posture"",""the current command does not invoke request triggers, validate upstream caller auth, or prove runtime-side trigger success"",""the current command does not resolve exact downstream payloads or high-value target impact without deeper workflow and connector inspection""]",Current identity can set up Logic App 'la-nightly-sync' as durable workflow persistence from current RBAC evidence.,"[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync""]" -/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router,la-event-router,rg-workflow,eastus,yes,yes,yes,yes,yes,yes,Current foothold `azurefox-lab-sp` already holds Owner at subscription scope.,"[""managed identity""]",execution-capable-only,Consumption,,Enabled,"[""api-connection""]",false,,UserAssigned,"The strongest visible execution context here is the Logic App identity `ua-workflow-router`, which already holds Contributor at resource group rg-workflow.","[""function"",""messaging""]","[""the current command does not print full workflow definitions, connector secret material, or connection credential values, so operator intent is not inferred from hidden workflow content here"",""the current command does not print callback URLs, access signatures, or other trigger secret material behind the visible trigger posture"",""the current command does not invoke request triggers, validate upstream caller auth, or prove runtime-side trigger success"",""the current command does not resolve exact downstream payloads or high-value target impact without deeper workflow and connector inspection"",""the current command does not prove whether a later definition change would turn this workflow into durable request or recurrence-backed re-entry""]","Current identity can build or repurpose Logic App 'la-event-router', but the current workflow definition does not yet show a durable request or recurrence trigger.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router,la-event-router,rg-workflow,eastus,yes,yes,yes,yes,yes,yes,Current foothold `azurefox-lab-sp` already holds Owner at subscription scope.,"[""managed identity""]",execution-capable-only,Consumption,,Enabled,"[""api-connection""]",false,,UserAssigned,"The strongest visible execution context here is the Logic App identity `ua-workflow-router`, which already holds Contributor at resource group rg-workflow.","[""function"",""messaging""]","[""the current command does not print full workflow definitions, connector secret material, or connection credential values, so operator intent is not inferred from hidden workflow content here"",""the current command does not print callback URLs, access signatures, or other trigger secret material behind the visible trigger posture"",""the current command does not invoke request triggers, validate upstream caller auth, or prove runtime-side trigger success"",""the current command does not resolve exact downstream payloads or high-value target impact without deeper workflow and connector inspection"",""the current command does not prove whether a later definition change would turn this workflow into durable request or recurrence-backed re-entry""]","Current identity can build or repurpose Logic App 'la-event-router', but the current workflow definition does not yet show a durable request or recurrence trigger.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]" diff --git a/testdata/persistence-logic-apps.golden.json b/testdata/persistence-logic-apps.golden.json index 98a8926..5b9e794 100644 --- a/testdata/persistence-logic-apps.golden.json +++ b/testdata/persistence-logic-apps.golden.json @@ -111,7 +111,8 @@ "summary": "Current identity can set up Logic App 'la-inbound-redeploy' as durable workflow persistence, and the strongest visible execution context already carries Azure control.", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy", - "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system" + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" ] }, { @@ -283,7 +284,8 @@ "summary": "Current identity can build or repurpose Logic App 'la-event-router', but the current workflow definition does not yet show a durable request or recurrence trigger.", "related_ids": [ "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router", - "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router" + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" ] } ], diff --git a/testdata/persistence-logic-apps.golden.table.txt b/testdata/persistence-logic-apps.golden.table.txt index a03dd61..9c97bd8 100644 --- a/testdata/persistence-logic-apps.golden.table.txt +++ b/testdata/persistence-logic-apps.golden.table.txt @@ -16,30 +16,40 @@ Logic Apps capability ╰─────────────────────────────────────┴───────────────────────────────────────┴────────╯ This walkthrough shows the strongest currently visible Logic App persistence path. The inventory below lists the other visible workflows without repeating the same narrative. - Current identity can create a new Logic App or modify this existing workflow. + A Logic App is a workflow resource stored in Azure: the trigger starts it, and the actions decide what it does next. - Current identity can change the stored workflow definition Azure will execute here. + In Logic Apps, the payload is the stored workflow definition and action chain Azure will execute later. + Consumption-style workflows are managed directly from the workflow definition; Standard Logic Apps behave more like a host with workflows, app settings, and package or deployment paths inside it. - Current identity can attach or reuse execution context for this Logic App. Managed identity or connector-backed actions may provide that execution context. - In Logic Apps, the payload is the stored workflow definition and action chain Azure will execute later. + That identity or connection is the power layer: it determines which Azure services, secrets, storage paths, external endpoints, or other automation the workflow can reach. Current foothold `azurefox-lab-sp` already holds Owner at subscription scope. The strongest visible execution context here is the Logic App identity `la-inbound-redeploy-identity`, which already holds Contributor at resource group rg-workflow. - Current identity can define or modify request, recurrence, or event trigger posture for this Logic App. + Visible trigger types here include `request`. + The visible request trigger makes this workflow externally callable if the callback URL or caller path is usable; this command does not print trigger secret material. - Current identity can enable the workflow so Azure will listen for the trigger and run it later. + Once saved and enabled, Azure listens for the trigger and starts the workflow when the trigger fires; no user needs to stay logged in. - Current identity can add or repurpose the downstream action paths this Logic App will carry out after the trigger fires. + Logic Apps do not need a traditional script to be useful; the action graph is the execution logic. + Visible downstream action kinds here include `automation` and `external-http`. + Actions can call Azure APIs, send HTTP requests, read or write storage, invoke other automation, or branch through connector-backed workflows when those mechanics are present. - Current identity can repurpose an existing Logic App instead of creating a new one. + Persistence here is the stored workflow, reachable trigger, and valid identity or connector context remaining in Azure so the path can be reused later. Nearby maintenance- or schedule-themed names visible from the current environment include `la-nightly-sync`. Visible Logic Apps -logic app | resource group | visible state | execution context +logic app | resource group | visible state | execution context la-inbound-redeploy | rg-workflow | persistence-capable; request(external) | `la-inbound-redeploy-identity` with Contributor at resource group `rg-workflow` -la-nightly-sync | rg-workflow | persistence-capable; recurrence Day/1 | connector-backed actions -la-event-router | rg-workflow | execution-capable-only; api-connection | `ua-workflow-router` with Contributor at resource group `rg-workflow` +la-nightly-sync | rg-workflow | persistence-capable; recurrence Day/1 | connector-backed actions +la-event-router | rg-workflow | execution-capable-only; api-connection | `ua-workflow-router` with Contributor at resource group `rg-workflow` Not collected by default diff --git a/testdata/persistence-vm-extensions.golden.table.txt b/testdata/persistence-vm-extensions.golden.table.txt index df04ad1..f60d624 100644 --- a/testdata/persistence-vm-extensions.golden.table.txt +++ b/testdata/persistence-vm-extensions.golden.table.txt @@ -46,27 +46,28 @@ forceUpdateTag=2026-04-23T120000Z; provisioning=Succeeded; instance=ProvisioningState/succeeded. Visible execution context here is `ua-app` with Owner at subscription scope. That is enough to judge whether this VM Extension already has handler, source, settings, rerun, or reuse value if stronger control is obtained later. + Higher permissions are required to complete the remaining persistence steps for this path. Nearby maintenance- or schedule-themed names visible from the current environment include `dependency-agent` and `maintenance-script`. Visible VM Extensions -extension | target | visible state | execution context +extension | target | visible state | execution context -config-bootstrap | VM=vm-web-01; identities=1 | Microsoft.Compute/CustomScriptExtension 1.10; | `ua-app` with Owner at subscription scope - | | file-hosts=raw.githubusercontent.com, storageacct.blob.core.windows.net; | - | | fileUris=2; command clue `public commandToExecute visible; | - | | executable=powershell; arguments redacted`; public=commandToExecute, | - | | fileUris; protected=yes; suppress-failures=no; | - | | forceUpdateTag=2026-04-23T120000Z; provisioning=Succeeded; | - | | instance=ProvisioningState/succeeded | +config-bootstrap | VM=vm-web-01; identities=1 | Microsoft.Compute/CustomScriptExtension 1.10; | `ua-app` with Owner at subscription scope + | | file-hosts=raw.githubusercontent.com, storageacct.blob.core.windows.net; | + | | fileUris=2; command clue `public commandToExecute visible; | + | | executable=powershell; arguments redacted`; public=commandToExecute, | + | | fileUris; protected=yes; suppress-failures=no; | + | | forceUpdateTag=2026-04-23T120000Z; provisioning=Succeeded; | + | | instance=ProvisioningState/succeeded | maintenance-script | VMSS=vmss-batch | Microsoft.Azure.Extensions/CustomScript 2.1; | Custom Script-style handler, reachable source clue, public command clue, - | | file-hosts=scripts.contoso.internal; fileUris=1; command clue `public | public settings visible, Key Vault-referenced protected settings, rerun - | | commandToExecute visible; executable=maintenance.sh; arguments | clues visible - | | redacted`; public=commandToExecute, fileUris; kv-protected=yes; | - | | suppress-failures=no; forceUpdateTag=roll-20260423; | - | | provisioning=Succeeded | -dependency-agent | VM=vm-web-01; identities=1 | Microsoft.Azure.Monitoring.DependencyAgent/DependencyAgentLinux 9.10; | `ua-app` with Owner at subscription scope - | | suppress-failures=no; provisioning=Succeeded; | - | | instance=ProvisioningState/succeeded | + | | file-hosts=scripts.contoso.internal; fileUris=1; command clue `public | public settings visible, Key Vault-referenced protected settings, rerun + | | commandToExecute visible; executable=maintenance.sh; arguments | clues visible + | | redacted`; public=commandToExecute, fileUris; kv-protected=yes; | + | | suppress-failures=no; forceUpdateTag=roll-20260423; | + | | provisioning=Succeeded | +dependency-agent | VM=vm-web-01; identities=1 | Microsoft.Azure.Monitoring.DependencyAgent/DependencyAgentLinux 9.10; | `ua-app` with Owner at subscription scope + | | suppress-failures=no; provisioning=Succeeded; | + | | instance=ProvisioningState/succeeded | Not collected by default diff --git a/testdata/persistence-webjobs.golden.table.txt b/testdata/persistence-webjobs.golden.table.txt index b296956..6855545 100644 --- a/testdata/persistence-webjobs.golden.table.txt +++ b/testdata/persistence-webjobs.golden.table.txt @@ -41,7 +41,7 @@ This walkthrough shows the strongest currently visible WebJobs persistence path. Nearby maintenance- or sync-themed WebJob names visible from the current environment include `nightly-reconcile`. Visible WebJobs -webjob | parent app | visible state | execution context +webjob | parent app | visible state | execution context queue-worker | app-public-api | continuous; status Running; run command visible; parent hostname app-public-api.azurewebsites.net | `app-public-api-system` with no Azure role-assignment rows found for its principal ID nightly-reconcile | app-public-api | scheduled; status Success; latest trigger Schedule; schedule 0 0 * * * *; run command visible; parent hostname app-public-api.azurewebsites.net | `app-public-api-system` with no Azure role-assignment rows found for its principal ID diff --git a/testdata/relay.golden.csv b/testdata/relay.golden.csv new file mode 100644 index 0000000..2fa68f5 --- /dev/null +++ b/testdata/relay.golden.csv @@ -0,0 +1,2 @@ +id,namespace,resource_group,location,sku_name,provisioning_state,service_bus_endpoint,hybrid_connection_count,authorization_rule_count,hybrid_connections,listeners,app_service_attachments,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod,relay-hybrid-prod,rg-integration,eastus,Standard,Succeeded,https://relay-hybrid-prod.servicebus.windows.net:443/,1,2,"[""onprem-orders""]",1,"[""onprem-orders-\u003eapp-public-api""]","Relay namespace ""relay-hybrid-prod"" exposes 1 hybrid connection and 2 authorization rule(s), giving Azure a visible cloud rendezvous point for private-path communication.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod/hybridConnections/onprem-orders"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api/hybridConnectionNamespaces/relay-hybrid-prod/relays/onprem-orders"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api""]" diff --git a/testdata/relay.golden.json b/testdata/relay.golden.json new file mode 100644 index 0000000..38263ca --- /dev/null +++ b/testdata/relay.golden.json @@ -0,0 +1,50 @@ +{ + "findings": [], + "issues": [], + "metadata": { + "command": "relay", + "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 + }, + "namespaces": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod", + "namespace": "relay-hybrid-prod", + "resource_group": "rg-integration", + "location": "eastus", + "sku_name": "Standard", + "provisioning_state": "Succeeded", + "service_bus_endpoint": "https://relay-hybrid-prod.servicebus.windows.net:443/", + "metric_id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod", + "hybrid_connection_count": 1, + "authorization_rule_count": 2, + "hybrid_connections": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod/hybridConnections/onprem-orders", + "hybrid_connection": "onprem-orders", + "requires_client_authorization": true, + "listener_count": 1, + "app_service_attachments": [ + "app-public-api" + ], + "summary": "Hybrid Connection \"onprem-orders\" is visible under relay namespace \"relay-hybrid-prod\" with App Service attachment(s): app-public-api.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod/hybridConnections/onprem-orders", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api/hybridConnectionNamespaces/relay-hybrid-prod/relays/onprem-orders", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + ] + } + ], + "summary": "Relay namespace \"relay-hybrid-prod\" exposes 1 hybrid connection and 2 authorization rule(s), giving Azure a visible cloud rendezvous point for private-path communication.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-integration/providers/Microsoft.Relay/namespaces/relay-hybrid-prod/hybridConnections/onprem-orders", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api/hybridConnectionNamespaces/relay-hybrid-prod/relays/onprem-orders", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/app-public-api" + ] + } + ] +} diff --git a/testdata/relay.golden.table.txt b/testdata/relay.golden.table.txt new file mode 100644 index 0000000..12c60d6 --- /dev/null +++ b/testdata/relay.golden.table.txt @@ -0,0 +1,19 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[relay] Reviewing Azure Relay namespaces and Hybrid Connections for private-path rendezvous posture. +table view is compact by design; the JSON artifact keeps the fuller visible field set +ho-azure relay + +╭───────────────────┬────────────────┬────────────────────┬────────────┬───────────┬───────────────────────────────┬───────────────────────────────────────────────────────╮ +│ namespace │ resource group │ hybrid connections │ auth rules │ listeners │ app attachments │ endpoint │ +├───────────────────┼────────────────┼────────────────────┼────────────┼───────────┼───────────────────────────────┼───────────────────────────────────────────────────────┤ +│ relay-hybrid-prod │ rg-integration │ 1 │ 2 │ 1 │ onprem-orders->app-public-api │ https://relay-hybrid-prod.servicebus.windows.net:443/ │ +╰───────────────────┴────────────────┴────────────────────┴────────────┴───────────┴───────────────────────────────┴───────────────────────────────────────────────────────╯ + +Takeaway: 1 Relay namespace(s) visible; review Hybrid Connections for cloud rendezvous and private-path masking posture. + +Not collected by default: +- authorization keys: recon safety; authorization rules are counted, but key material is not retrieved or printed +- listener runtime state: proof boundary; listener counts do not prove a current listener process, host, or session +- backend process and traffic contents: proof boundary; Relay posture does not identify the private backend process or inspect traffic payloads diff --git a/testdata/resourcehijacking-api-mgmt.golden.csv b/testdata/resourcehijacking-api-mgmt.golden.csv new file mode 100644 index 0000000..6bf5d5b --- /dev/null +++ b/testdata/resourcehijacking-api-mgmt.golden.csv @@ -0,0 +1,2 @@ +id,api_management_service,resource_group,location,takeover_rank,takeover_reason,gateway_hostnames,backend_hostnames,api_count,active_subscription_count,current_identity,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01,apim-edge-01,rg-apps,eastus,5,"trusted gateway, backend target, and consumer/subscription posture are all visible; current identity has visible APIM backend or policy write control","[""apim-edge-01.azure-api.net"",""api.contoso.com""]","[""orders-internal.contoso.local""]",2,2,APIM backend/policy write,"service ""apim-edge-01"" ranks 5/5 for APIM resource-hijack posture; 2 gateway hostname(s); 1 backend hostname(s); current identity can modify APIM backend or policy posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01"",""99990000-0000-0000-0000-000000000001"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01""]" diff --git a/testdata/resourcehijacking-api-mgmt.golden.json b/testdata/resourcehijacking-api-mgmt.golden.json new file mode 100644 index 0000000..4ad7d80 --- /dev/null +++ b/testdata/resourcehijacking-api-mgmt.golden.json @@ -0,0 +1,143 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "resourcehijacking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "resourcehijacking", + "surface": "api-mgmt", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review API Management services for gateway, backend, subscription, named-value, and identity posture that can redirect a trusted API surface.", + "backing_commands": [ + "api-mgmt", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01", + "api_management_service": "apim-edge-01", + "resource_group": "rg-apps", + "location": "eastus", + "takeover_rank": 5, + "takeover_reason": "trusted gateway, backend target, and consumer/subscription posture are all visible; current identity has visible APIM backend or policy write control", + "capability_steps": [ + { + "action": "select trusted gateway", + "api_surface": "Microsoft.ApiManagement/service", + "status": "visible posture only", + "downstream_effect": "Keeps clients on the existing APIM hostname and API surface.", + "boundary": "Gateway posture does not prove traffic volume." + }, + { + "action": "identify backend control point", + "api_surface": "Microsoft.ApiManagement/service/backends", + "status": "visible posture only", + "downstream_effect": "Shows where APIM can forward requests behind the stable front door.", + "boundary": "Backend hostnames do not prove ownership or runtime reachability." + }, + { + "action": "change backend or routing policy", + "api_surface": "APIM backend or policy write", + "status": "yes", + "downstream_effect": "Can redirect selected API traffic while the published APIM surface remains healthy.", + "boundary": "This command does not collect policy XML bodies by default." + }, + { + "action": "preserve subscriptions and named values", + "api_surface": "APIM subscriptions and named values", + "status": "yes", + "downstream_effect": "Existing consumers, subscription gates, and stored config can keep the route looking operational.", + "boundary": "Named-value values and secrets are not printed." + }, + { + "action": "blend as API operations change", + "api_surface": "APIM service configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include backend migration, failover, version routing, and blue/green release work.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "APIM backend/policy write", + "summary": "Current foothold `azurefox-lab-sp` has visible APIM backend or policy write control." + }, + "current_state": { + "state": "Succeeded", + "public_network_access": "Enabled", + "virtual_network_type": "External", + "gateway_hostnames": [ + "apim-edge-01.azure-api.net", + "api.contoso.com" + ], + "backend_hostnames": [ + "orders-internal.contoso.local" + ], + "api_count": 2, + "subscription_count": 3, + "active_subscription_count": 2, + "backend_count": 1, + "policy_count": 2, + "policy_control_types": [ + "backend-routing", + "conditional-routing", + "header-auth", + "request-rewrite" + ], + "named_value_secret_count": 1, + "named_value_key_vault_count": 1, + "workload_identity_type": "SystemAssigned", + "posture": "2 gateway hostname(s); 1 backend hostname(s); policy controls: backend-routing, conditional-routing, header-auth, request-rewrite; 2 active subscription(s); secret or Key Vault named-value posture" + }, + "not_collected_by_default": [ + { + "name": "policy XML bodies", + "classification": "recon safety", + "reason": "The flat helper parses safe policy control types, but does not print raw policy XML or named-value expansions." + }, + { + "name": "named-value values", + "classification": "recon safety", + "reason": "Default output reports secret and Key Vault named-value counts without printing stored values." + }, + { + "name": "live request flow", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove traffic was routed, captured, or modified." + }, + { + "name": "backend ownership", + "classification": "proof boundary", + "reason": "A backend hostname does not prove who controls that endpoint or whether it is reachable." + }, + { + "name": "activity history", + "classification": "API/noise", + "reason": "Broad APIM history pulls are not needed for default posture and should be a narrow follow-up only when timing or actor proof matters." + } + ], + "summary": "service \"apim-edge-01\" ranks 5/5 for APIM resource-hijack posture; 2 gateway hostname(s); 1 backend hostname(s); current identity can modify APIM backend or policy posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.ApiManagement/service/apim-edge-01", + "99990000-0000-0000-0000-000000000001", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Network/publicIPAddresses/pip-apim-edge-01" + ] + } + ], + "issues": [] +} diff --git a/testdata/resourcehijacking-api-mgmt.golden.table.txt b/testdata/resourcehijacking-api-mgmt.golden.table.txt new file mode 100644 index 0000000..9ebcc79 --- /dev/null +++ b/testdata/resourcehijacking-api-mgmt.golden.table.txt @@ -0,0 +1,37 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[resourcehijacking] APIM resource hijacking means the trusted API gateway can keep answering while backend or routing posture changes behind it. +APIM resourcehijacking capability + +╭─────────────────────────────────────────┬──────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├─────────────────────────────────────────┼──────────────────────────────────────────┼──────────────────────┤ +│ select trusted gateway │ Microsoft.ApiManagement/service │ visible posture only │ +│ identify backend control point │ Microsoft.ApiManagement/service/backends │ visible posture only │ +│ change backend or routing policy │ APIM backend or policy write │ yes │ +│ preserve subscriptions and named values │ APIM subscriptions and named values │ yes │ +│ blend as API operations change │ APIM service configuration │ visible posture only │ +╰─────────────────────────────────────────┴──────────────────────────────────────────┴──────────────────────╯ + +Operator read +service "apim-edge-01" ranks 5/5 for APIM resource-hijack posture; 2 gateway hostname(s); 1 backend hostname(s); current identity can modify APIM backend or policy posture from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible APIM backend or policy write control. +Downstream effect: trusted gateway, backend target, and consumer/subscription posture are all visible; current identity has visible APIM backend or policy write control +First boundary: this is APIM management-plane posture, not policy-body proof, live traffic proof, or backend ownership proof. +Posture: 2 gateway hostname(s); 1 backend hostname(s); policy controls: backend-routing, conditional-routing, header-auth, request-rewrite; 2 active subscription(s); secret or Key Vault named-value posture. + +Visible APIM Services +service | rank | gateways | backends | active subscriptions | current identity + +apim-edge-01 | 5/5 | apim-edge-01.azure-api.net, api.contoso.com | orders-internal.contoso.local | 2 | APIM backend/policy write + + +Not collected by default +item | classification | reason + +policy XML bodies | recon safety | The flat helper parses safe policy control types, but does not print raw policy XML or named-value expansions. +named-value values | recon safety | Default output reports secret and Key Vault named-value counts without printing stored values. +live request flow | proof boundary | Management-plane posture cannot prove traffic was routed, captured, or modified. +backend ownership | proof boundary | A backend hostname does not prove who controls that endpoint or whether it is reachable. +activity history | API/noise | Broad APIM history pulls are not needed for default posture and should be a narrow follow-up only when timing or actor proof matters. diff --git a/testdata/resourcehijacking-automation.golden.csv b/testdata/resourcehijacking-automation.golden.csv new file mode 100644 index 0000000..775e29c --- /dev/null +++ b/testdata/resourcehijacking-automation.golden.csv @@ -0,0 +1,3 @@ +id,automation_account,resource_group,location,takeover_rank,takeover_reason,published_runbook_count,published_runbook_names,job_schedule_count,webhook_count,hybrid_worker_group_count,identity_type,current_identity,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod,aa-hybrid-prod,rg-ops,eastus,5,"published runbook posture, trigger posture, and automation identity are visible; current identity has visible Automation account or runbook write control",6,"[""Baseline-Config"",""Nightly-Reconcile"",""Redeploy-App"",""Reapply-Agent"",""Sync-Secrets"",""Rotate-Certs""]",5,2,1,SystemAssigned,automation write,"account ""aa-hybrid-prod"" ranks 5/5 for Automation resource-hijack posture; 6 published runbook(s); 5 job schedule(s); 2 webhook(s); current identity can modify Automation posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet,aa-lab-quiet,rg-lab,centralus,3,published runbook and trigger posture are visible; current identity has visible Automation account or runbook write control,1,"[""Lab-Maintenance""]",1,0,0,,automation write,"account ""aa-lab-quiet"" ranks 3/5 for Automation resource-hijack posture; 1 published runbook(s); 1 job schedule(s); current identity can modify Automation posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet""]" diff --git a/testdata/resourcehijacking-automation.golden.json b/testdata/resourcehijacking-automation.golden.json new file mode 100644 index 0000000..7e6f129 --- /dev/null +++ b/testdata/resourcehijacking-automation.golden.json @@ -0,0 +1,271 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "resourcehijacking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "resourcehijacking", + "surface": "automation", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review Azure Automation accounts for published runbook, schedule, webhook, hybrid worker, secure asset, and identity posture that can repurpose trusted operations automation.", + "backing_commands": [ + "automation", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod", + "automation_account": "aa-hybrid-prod", + "resource_group": "rg-ops", + "location": "eastus", + "takeover_rank": 5, + "takeover_reason": "published runbook posture, trigger posture, and automation identity are visible; current identity has visible Automation account or runbook write control", + "capability_steps": [ + { + "action": "select trusted automation account", + "api_surface": "Microsoft.Automation/automationAccounts", + "status": "visible posture only", + "downstream_effect": "Keeps the existing operations automation account, modules, assets, and expected maintenance context in place.", + "boundary": "Account posture does not prove any job ran." + }, + { + "action": "edit published runbook", + "api_surface": "Microsoft.Automation/automationAccounts/runbooks", + "status": "yes", + "downstream_effect": "Can change script logic that operators already expect Azure Automation to run.", + "boundary": "Default output does not print runbook script content." + }, + { + "action": "reuse schedule or webhook trigger", + "api_surface": "job schedules and webhooks", + "status": "yes", + "downstream_effect": "Can preserve the existing invocation path while changing what the runbook does.", + "boundary": "Trigger posture does not prove invocation." + }, + { + "action": "reuse automation identity or worker context", + "api_surface": "automation account identity and hybrid workers", + "status": "yes", + "downstream_effect": "Can run altered automation through already-integrated identity, worker, or secure-asset context.", + "boundary": "Host state, job output, and secure asset values are not collected." + }, + { + "action": "blend as maintenance automation", + "api_surface": "automation account configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include patching, remediation, cleanup, and scheduled maintenance script updates.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "automation write", + "summary": "Current foothold `azurefox-lab-sp` has visible Automation account or runbook write control." + }, + "current_state": { + "state": "Ok", + "identity_type": "SystemAssigned", + "published_runbook_count": 6, + "published_runbook_names": [ + "Baseline-Config", + "Nightly-Reconcile", + "Redeploy-App", + "Reapply-Agent", + "Sync-Secrets", + "Rotate-Certs" + ], + "runbook_types": [ + "PowerShell", + "Python3" + ], + "runbook_command_clues": [], + "runbook_resource_clues": [], + "schedule_count": 4, + "job_schedule_count": 5, + "webhook_count": 2, + "hybrid_worker_group_count": 1, + "primary_start_mode": "webhook", + "primary_runbook_name": "Redeploy-App", + "schedule_runbook_names": [ + "Baseline-Config", + "Nightly-Reconcile" + ], + "webhook_runbook_names": [ + "Redeploy-App", + "Reapply-Agent" + ], + "consequence_types": [ + "run-recurring-execution", + "reintroduce-config", + "consume-secret-backed-deployment-material" + ], + "posture": "6 published runbook(s); runbook types PowerShell, Python3; schedule or webhook trigger posture; managed identity posture; hybrid worker posture; consequence types run-recurring-execution, reintroduce-config, consume-secret-backed-deployment-material" + }, + "not_collected_by_default": [ + { + "name": "runbook script content", + "classification": "recon safety", + "reason": "The live helper reports safe runbook type and trigger posture by default; content-derived command/resource clues require a narrower review path and raw script bodies are not printed." + }, + { + "name": "secure asset values", + "classification": "recon safety", + "reason": "Automation credentials, certificates, connections, and encrypted variables are not safe default output." + }, + { + "name": "job output and status history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove a changed runbook executed or what it produced." + }, + { + "name": "hybrid worker host state", + "classification": "proof boundary", + "reason": "Automation account posture cannot prove guest-side host impact without worker or host evidence." + }, + { + "name": "activity history", + "classification": "API/noise", + "reason": "Broad Automation history pulls are not needed for default posture and should be a narrow follow-up for timing or actor proof." + } + ], + "summary": "account \"aa-hybrid-prod\" ranks 5/5 for Automation resource-hijack posture; 6 published runbook(s); 5 job schedule(s); 2 webhook(s); current identity can modify Automation posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod/identities/system" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet", + "automation_account": "aa-lab-quiet", + "resource_group": "rg-lab", + "location": "centralus", + "takeover_rank": 3, + "takeover_reason": "published runbook and trigger posture are visible; current identity has visible Automation account or runbook write control", + "capability_steps": [ + { + "action": "select trusted automation account", + "api_surface": "Microsoft.Automation/automationAccounts", + "status": "visible posture only", + "downstream_effect": "Keeps the existing operations automation account, modules, assets, and expected maintenance context in place.", + "boundary": "Account posture does not prove any job ran." + }, + { + "action": "edit published runbook", + "api_surface": "Microsoft.Automation/automationAccounts/runbooks", + "status": "yes", + "downstream_effect": "Can change script logic that operators already expect Azure Automation to run.", + "boundary": "Default output does not print runbook script content." + }, + { + "action": "reuse schedule or webhook trigger", + "api_surface": "job schedules and webhooks", + "status": "yes", + "downstream_effect": "Can preserve the existing invocation path while changing what the runbook does.", + "boundary": "Trigger posture does not prove invocation." + }, + { + "action": "reuse automation identity or worker context", + "api_surface": "automation account identity and hybrid workers", + "status": "yes", + "downstream_effect": "Can run altered automation through already-integrated identity, worker, or secure-asset context.", + "boundary": "Host state, job output, and secure asset values are not collected." + }, + { + "action": "blend as maintenance automation", + "api_surface": "automation account configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include patching, remediation, cleanup, and scheduled maintenance script updates.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "automation write", + "summary": "Current foothold `azurefox-lab-sp` has visible Automation account or runbook write control." + }, + "current_state": { + "state": "Ok", + "published_runbook_count": 1, + "published_runbook_names": [ + "Lab-Maintenance" + ], + "runbook_types": [ + "PowerShell" + ], + "runbook_command_clues": [], + "runbook_resource_clues": [], + "schedule_count": 1, + "job_schedule_count": 1, + "webhook_count": 0, + "hybrid_worker_group_count": 0, + "primary_start_mode": "schedule", + "primary_runbook_name": "Lab-Maintenance", + "schedule_runbook_names": [ + "Lab-Maintenance" + ], + "webhook_runbook_names": [], + "consequence_types": [ + "run-recurring-execution", + "reintroduce-config", + "consume-secret-backed-deployment-material" + ], + "posture": "1 published runbook(s); runbook types PowerShell; schedule or webhook trigger posture; consequence types run-recurring-execution, reintroduce-config, consume-secret-backed-deployment-material" + }, + "not_collected_by_default": [ + { + "name": "runbook script content", + "classification": "recon safety", + "reason": "The live helper reports safe runbook type and trigger posture by default; content-derived command/resource clues require a narrower review path and raw script bodies are not printed." + }, + { + "name": "secure asset values", + "classification": "recon safety", + "reason": "Automation credentials, certificates, connections, and encrypted variables are not safe default output." + }, + { + "name": "job output and status history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove a changed runbook executed or what it produced." + }, + { + "name": "hybrid worker host state", + "classification": "proof boundary", + "reason": "Automation account posture cannot prove guest-side host impact without worker or host evidence." + }, + { + "name": "activity history", + "classification": "API/noise", + "reason": "Broad Automation history pulls are not needed for default posture and should be a narrow follow-up for timing or actor proof." + } + ], + "summary": "account \"aa-lab-quiet\" ranks 3/5 for Automation resource-hijack posture; 1 published runbook(s); 1 job schedule(s); current identity can modify Automation posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-lab/providers/Microsoft.Automation/automationAccounts/aa-lab-quiet" + ] + } + ], + "issues": [] +} diff --git a/testdata/resourcehijacking-automation.golden.table.txt b/testdata/resourcehijacking-automation.golden.table.txt new file mode 100644 index 0000000..9cf485c --- /dev/null +++ b/testdata/resourcehijacking-automation.golden.table.txt @@ -0,0 +1,39 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[resourcehijacking] Automation resource hijacking means trusted runbooks, schedules, webhooks, identities, or worker context can be repurposed as ordinary operations automation. +Automation resourcehijacking capability + +╭─────────────────────────────────────────────┬──────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├─────────────────────────────────────────────┼──────────────────────────────────────────────────┼──────────────────────┤ +│ select trusted automation account │ Microsoft.Automation/automationAccounts │ visible posture only │ +│ edit published runbook │ Microsoft.Automation/automationAccounts/runbooks │ yes │ +│ reuse schedule or webhook trigger │ job schedules and webhooks │ yes │ +│ reuse automation identity or worker context │ automation account identity and hybrid workers │ yes │ +│ blend as maintenance automation │ automation account configuration │ visible posture only │ +╰─────────────────────────────────────────────┴──────────────────────────────────────────────────┴──────────────────────╯ +This walkthrough shows the strongest currently visible Automation takeover path. The inventory below lists the other visible accounts without repeating the same narrative. + +Operator read +account "aa-hybrid-prod" ranks 5/5 for Automation resource-hijack posture; 6 published runbook(s); 5 job schedule(s); 2 webhook(s); current identity can modify Automation posture from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible Automation account or runbook write control. +Downstream effect: published runbook posture, trigger posture, and automation identity are visible; current identity has visible Automation account or runbook write control +First boundary: this is Automation management-plane posture, not runbook script proof, job-output proof, or hybrid worker host proof. +Posture: 6 published runbook(s); runbook types PowerShell, Python3; schedule or webhook trigger posture; managed identity posture; hybrid worker posture; consequence types run-recurring-execution, reintroduce-config, consume-secret-backed-deployment-material. + +Visible Automation Accounts +account | rank | runbooks | job schedules | webhooks | current identity + +aa-hybrid-prod | 5/5 | 6 | 5 | 2 | automation write +aa-lab-quiet | 3/5 | 1 | 1 | 0 | automation write + + +Not collected by default +item | classification | reason + +runbook script content | recon safety | The live helper reports safe runbook type and trigger posture by default; content-derived command/resource clues require a narrower review path and raw script bodies are not printed. +secure asset values | recon safety | Automation credentials, certificates, connections, and encrypted variables are not safe default output. +job output and status history | proof boundary | Management-plane posture cannot prove a changed runbook executed or what it produced. +hybrid worker host state | proof boundary | Automation account posture cannot prove guest-side host impact without worker or host evidence. +activity history | API/noise | Broad Automation history pulls are not needed for default posture and should be a narrow follow-up for timing or actor proof. diff --git a/testdata/resourcehijacking-logic-apps.golden.csv b/testdata/resourcehijacking-logic-apps.golden.csv new file mode 100644 index 0000000..775620e --- /dev/null +++ b/testdata/resourcehijacking-logic-apps.golden.csv @@ -0,0 +1,4 @@ +id,logic_app,resource_group,location,takeover_rank,takeover_reason,trigger_types,externally_callable_request_trigger,recurrence_summary,downstream_action_kinds,identity_type,current_identity,summary,related_ids +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy,la-inbound-redeploy,rg-workflow,centralus,5,"external trigger, downstream action posture, and workflow identity are visible; current identity has visible Logic App workflow write control","[""request""]",true,,"[""automation"",""external-http""]",SystemAssigned,workflow write,"workflow ""la-inbound-redeploy"" ranks 5/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router,la-event-router,rg-workflow,eastus,4,"trigger, downstream action posture, and workflow identity are visible; current identity has visible Logic App workflow write control","[""api-connection""]",false,,"[""function"",""messaging""]",UserAssigned,workflow write,"workflow ""la-event-router"" ranks 4/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router"",""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders""]" +/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync,la-nightly-sync,rg-workflow,centralus,3,trigger and downstream action posture are visible; current identity has visible Logic App workflow write control,"[""recurrence""]",false,Day/1,"[""storage"",""connector""]",,workflow write,"workflow ""la-nightly-sync"" ranks 3/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC.","[""/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync""]" diff --git a/testdata/resourcehijacking-logic-apps.golden.json b/testdata/resourcehijacking-logic-apps.golden.json new file mode 100644 index 0000000..d65df9c --- /dev/null +++ b/testdata/resourcehijacking-logic-apps.golden.json @@ -0,0 +1,367 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "resourcehijacking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": "11111111-1111-1111-1111-111111111111", + "subscription_id": "22222222-2222-2222-2222-222222222222", + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "resourcehijacking", + "surface": "logic-apps", + "input_mode": "live", + "command_state": "implemented", + "summary": "Review Logic Apps for workflow definition, trigger, downstream action, connector, and identity posture that can repurpose trusted automation.", + "backing_commands": [ + "logic-apps", + "permissions", + "rbac" + ], + "targets": [ + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy", + "logic_app": "la-inbound-redeploy", + "resource_group": "rg-workflow", + "location": "centralus", + "takeover_rank": 5, + "takeover_reason": "external trigger, downstream action posture, and workflow identity are visible; current identity has visible Logic App workflow write control", + "capability_steps": [ + { + "action": "select trusted workflow", + "api_surface": "Microsoft.Logic/workflows", + "status": "visible posture only", + "downstream_effect": "Keeps the existing automation resource, trigger path, and operational context in place.", + "boundary": "Workflow posture does not prove the workflow ran." + }, + { + "action": "edit workflow definition", + "api_surface": "workflow definition", + "status": "yes", + "downstream_effect": "Can add, remove, or repurpose actions while the trusted Logic App remains the same resource.", + "boundary": "Default output does not print full workflow definition bodies." + }, + { + "action": "repurpose trigger", + "api_surface": "request, recurrence, api-connection, or event trigger", + "status": "yes", + "downstream_effect": "Can keep an existing inbound, scheduled, or connector trigger while changing what happens after it fires.", + "boundary": "Trigger posture does not prove trigger invocation." + }, + { + "action": "reuse connector or identity context", + "api_surface": "workflow identity and connection references", + "status": "yes", + "downstream_effect": "Can run altered workflow logic through already-integrated connectors or managed identity context.", + "boundary": "Connector credential values and secret material are not collected." + }, + { + "action": "blend as integration maintenance", + "api_surface": "workflow configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include connector refresh, integration repair, retry handling, or workflow modernization.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "workflow write", + "summary": "Current foothold `azurefox-lab-sp` has visible Logic App workflow write control." + }, + "current_state": { + "platform": "Consumption", + "state": "Enabled", + "trigger_types": [ + "request" + ], + "externally_callable_request_trigger": true, + "downstream_action_kinds": [ + "automation", + "external-http" + ], + "connector_references": [], + "parameter_names": [ + "automationAccountName", + "runbookName" + ], + "downstream_resource_references": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" + ], + "identity_type": "SystemAssigned", + "identity_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system" + ], + "posture": "externally callable request trigger; trigger types request; downstream actions automation, external-http; 1 downstream resource reference(s); managed identity posture" + }, + "not_collected_by_default": [ + { + "name": "full workflow definition body", + "classification": "collector issue", + "reason": "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default." + }, + { + "name": "connector credential values", + "classification": "recon safety", + "reason": "Connector secrets and connection credential material are not safe default output." + }, + { + "name": "run history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove the modified workflow ran or completed downstream actions." + }, + { + "name": "data handled by actions", + "classification": "proof boundary", + "reason": "The command does not inspect connector or action payload content." + }, + { + "name": "activity history", + "classification": "API/noise", + "reason": "Broad workflow change history is not needed for default posture and should be a narrow follow-up for timing or actor proof." + } + ], + "summary": "workflow \"la-inbound-redeploy\" ranks 5/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-inbound-redeploy/identities/system", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-ops/providers/Microsoft.Automation/automationAccounts/aa-hybrid-prod" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router", + "logic_app": "la-event-router", + "resource_group": "rg-workflow", + "location": "eastus", + "takeover_rank": 4, + "takeover_reason": "trigger, downstream action posture, and workflow identity are visible; current identity has visible Logic App workflow write control", + "capability_steps": [ + { + "action": "select trusted workflow", + "api_surface": "Microsoft.Logic/workflows", + "status": "visible posture only", + "downstream_effect": "Keeps the existing automation resource, trigger path, and operational context in place.", + "boundary": "Workflow posture does not prove the workflow ran." + }, + { + "action": "edit workflow definition", + "api_surface": "workflow definition", + "status": "yes", + "downstream_effect": "Can add, remove, or repurpose actions while the trusted Logic App remains the same resource.", + "boundary": "Default output does not print full workflow definition bodies." + }, + { + "action": "repurpose trigger", + "api_surface": "request, recurrence, api-connection, or event trigger", + "status": "yes", + "downstream_effect": "Can keep an existing inbound, scheduled, or connector trigger while changing what happens after it fires.", + "boundary": "Trigger posture does not prove trigger invocation." + }, + { + "action": "reuse connector or identity context", + "api_surface": "workflow identity and connection references", + "status": "yes", + "downstream_effect": "Can run altered workflow logic through already-integrated connectors or managed identity context.", + "boundary": "Connector credential values and secret material are not collected." + }, + { + "action": "blend as integration maintenance", + "api_surface": "workflow configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include connector refresh, integration repair, retry handling, or workflow modernization.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "workflow write", + "summary": "Current foothold `azurefox-lab-sp` has visible Logic App workflow write control." + }, + "current_state": { + "platform": "Consumption", + "state": "Enabled", + "trigger_types": [ + "api-connection" + ], + "externally_callable_request_trigger": false, + "downstream_action_kinds": [ + "function", + "messaging" + ], + "connector_references": [ + "eventgrid" + ], + "parameter_names": [], + "downstream_resource_references": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ], + "identity_type": "UserAssigned", + "identity_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router" + ], + "posture": "trigger types api-connection; downstream actions function, messaging; connector references eventgrid; 1 downstream resource reference(s); managed identity posture" + }, + "not_collected_by_default": [ + { + "name": "full workflow definition body", + "classification": "collector issue", + "reason": "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default." + }, + { + "name": "connector credential values", + "classification": "recon safety", + "reason": "Connector secrets and connection credential material are not safe default output." + }, + { + "name": "run history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove the modified workflow ran or completed downstream actions." + }, + { + "name": "data handled by actions", + "classification": "proof boundary", + "reason": "The command does not inspect connector or action payload content." + }, + { + "name": "activity history", + "classification": "API/noise", + "reason": "Broad workflow change history is not needed for default posture and should be a narrow follow-up for timing or actor proof." + } + ], + "summary": "workflow \"la-event-router\" ranks 4/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-event-router", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-identity/providers/Microsoft.ManagedIdentity/userAssignedIdentities/ua-workflow-router", + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-apps/providers/Microsoft.Web/sites/func-orders" + ] + }, + { + "id": "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync", + "logic_app": "la-nightly-sync", + "resource_group": "rg-workflow", + "location": "centralus", + "takeover_rank": 3, + "takeover_reason": "trigger and downstream action posture are visible; current identity has visible Logic App workflow write control", + "capability_steps": [ + { + "action": "select trusted workflow", + "api_surface": "Microsoft.Logic/workflows", + "status": "visible posture only", + "downstream_effect": "Keeps the existing automation resource, trigger path, and operational context in place.", + "boundary": "Workflow posture does not prove the workflow ran." + }, + { + "action": "edit workflow definition", + "api_surface": "workflow definition", + "status": "yes", + "downstream_effect": "Can add, remove, or repurpose actions while the trusted Logic App remains the same resource.", + "boundary": "Default output does not print full workflow definition bodies." + }, + { + "action": "repurpose trigger", + "api_surface": "request, recurrence, api-connection, or event trigger", + "status": "yes", + "downstream_effect": "Can keep an existing inbound, scheduled, or connector trigger while changing what happens after it fires.", + "boundary": "Trigger posture does not prove trigger invocation." + }, + { + "action": "reuse connector or identity context", + "api_surface": "workflow identity and connection references", + "status": "yes", + "downstream_effect": "Can run altered workflow logic through already-integrated connectors or managed identity context.", + "boundary": "Connector credential values and secret material are not collected." + }, + { + "action": "blend as integration maintenance", + "api_surface": "workflow configuration", + "status": "visible posture only", + "downstream_effect": "Normal cover stories include connector refresh, integration repair, retry handling, or workflow modernization.", + "boundary": "Cover story is not an intent claim." + } + ], + "current_identity_context": { + "name": "azurefox-lab-sp", + "kind": "current-foothold", + "principal_id": "33333333-3333-3333-3333-333333333333", + "role_names": [ + "Owner" + ], + "scope_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222" + ], + "control_label": "workflow write", + "summary": "Current foothold `azurefox-lab-sp` has visible Logic App workflow write control." + }, + "current_state": { + "platform": "Consumption", + "state": "Enabled", + "trigger_types": [ + "recurrence" + ], + "externally_callable_request_trigger": false, + "recurrence_summary": "Day/1", + "downstream_action_kinds": [ + "storage", + "connector" + ], + "connector_references": [ + "azureblob" + ], + "parameter_names": [ + "storageAccountName" + ], + "downstream_resource_references": [], + "identity_ids": [], + "posture": "recurrence trigger Day/1; trigger types recurrence; downstream actions storage, connector; connector references azureblob" + }, + "not_collected_by_default": [ + { + "name": "full workflow definition body", + "classification": "collector issue", + "reason": "The helper reports trigger and downstream action posture but does not print complete workflow JSON by default." + }, + { + "name": "connector credential values", + "classification": "recon safety", + "reason": "Connector secrets and connection credential material are not safe default output." + }, + { + "name": "run history", + "classification": "proof boundary", + "reason": "Management-plane posture cannot prove the modified workflow ran or completed downstream actions." + }, + { + "name": "data handled by actions", + "classification": "proof boundary", + "reason": "The command does not inspect connector or action payload content." + }, + { + "name": "activity history", + "classification": "API/noise", + "reason": "Broad workflow change history is not needed for default posture and should be a narrow follow-up for timing or actor proof." + } + ], + "summary": "workflow \"la-nightly-sync\" ranks 3/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC.", + "related_ids": [ + "/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/rg-workflow/providers/Microsoft.Logic/workflows/la-nightly-sync" + ] + } + ], + "issues": [] +} diff --git a/testdata/resourcehijacking-logic-apps.golden.table.txt b/testdata/resourcehijacking-logic-apps.golden.table.txt new file mode 100644 index 0000000..fd76625 --- /dev/null +++ b/testdata/resourcehijacking-logic-apps.golden.table.txt @@ -0,0 +1,40 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[resourcehijacking] Logic Apps resource hijacking means a trusted workflow, trigger, connector path, or identity context can be repurposed while the automation resource remains familiar. +Logic Apps resourcehijacking capability + +╭─────────────────────────────────────┬───────────────────────────────────────────────────────┬──────────────────────╮ +│ action │ api surface │ status │ +├─────────────────────────────────────┼───────────────────────────────────────────────────────┼──────────────────────┤ +│ select trusted workflow │ Microsoft.Logic/workflows │ visible posture only │ +│ edit workflow definition │ workflow definition │ yes │ +│ repurpose trigger │ request, recurrence, api-connection, or event trigger │ yes │ +│ reuse connector or identity context │ workflow identity and connection references │ yes │ +│ blend as integration maintenance │ workflow configuration │ visible posture only │ +╰─────────────────────────────────────┴───────────────────────────────────────────────────────┴──────────────────────╯ +This walkthrough shows the strongest currently visible Logic App takeover path. The inventory below lists the other visible workflows without repeating the same narrative. + +Operator read +workflow "la-inbound-redeploy" ranks 5/5 for Logic App resource-hijack posture; 1 trigger type(s); 2 downstream action kind(s); current identity can modify workflow posture from visible RBAC. +Current identity: Current foothold `azurefox-lab-sp` has visible Logic App workflow write control. +Downstream effect: external trigger, downstream action posture, and workflow identity are visible; current identity has visible Logic App workflow write control +First boundary: this is Logic App management-plane posture, not run-history proof, connector data proof, or secret-material proof. +Posture: externally callable request trigger; trigger types request; downstream actions automation, external-http; 1 downstream resource reference(s); managed identity posture. + +Visible Logic Apps +workflow | rank | triggers | actions | identity | current identity + +la-inbound-redeploy | 5/5 | request | automation, external-http | SystemAssigned | workflow write +la-event-router | 4/5 | api-connection | function, messaging | UserAssigned | workflow write +la-nightly-sync | 3/5 | recurrence | storage, connector | | workflow write + + +Not collected by default +item | classification | reason + +full workflow definition body | collector issue | The helper reports trigger and downstream action posture but does not print complete workflow JSON by default. +connector credential values | recon safety | Connector secrets and connection credential material are not safe default output. +run history | proof boundary | Management-plane posture cannot prove the modified workflow ran or completed downstream actions. +data handled by actions | proof boundary | The command does not inspect connector or action payload content. +activity history | API/noise | Broad workflow change history is not needed for default posture and should be a narrow follow-up for timing or actor proof. diff --git a/testdata/resourcehijacking.golden.csv b/testdata/resourcehijacking.golden.csv new file mode 100644 index 0000000..92adca2 --- /dev/null +++ b/testdata/resourcehijacking.golden.csv @@ -0,0 +1,4 @@ +surface,state,summary,operator_question,backing_commands +api-mgmt,implemented,"Review API Management services for gateway, backend, subscription, named-value, and identity posture that can redirect a trusted API surface.","How far can current access take me through APIM backend and routing-control levers before the proof boundary moves into policy bodies, traffic logs, or backend ownership?","[""api-mgmt"",""permissions"",""rbac""]" +automation,implemented,"Review Azure Automation accounts for published runbook, schedule, webhook, hybrid worker, secure asset, and identity posture that can repurpose trusted operations automation.","How far can current access take me through Automation runbook, trigger, execution context, and account-control levers before the proof boundary moves into script content, job output, or runtime host state?","[""automation"",""permissions"",""rbac""]" +logic-apps,implemented,"Review Logic Apps for workflow definition, trigger, downstream action, connector, and identity posture that can repurpose trusted automation.","How far can current access take me through Logic App trigger, workflow, action, and identity levers before the proof boundary moves into run history, connector data, or secret material?","[""logic-apps"",""permissions"",""rbac""]" diff --git a/testdata/resourcehijacking.golden.json b/testdata/resourcehijacking.golden.json new file mode 100644 index 0000000..2720515 --- /dev/null +++ b/testdata/resourcehijacking.golden.json @@ -0,0 +1,59 @@ +{ + "metadata": { + "schema_version": "1.4.0", + "command": "resourcehijacking", + "generated_at": "2026-04-13T12:00:00Z", + "tenant_id": null, + "subscription_id": null, + "devops_organization": null, + "token_source": null, + "auth_mode": null + }, + "grouped_command_name": "resourcehijacking", + "command_state": "implemented", + "current_behavior": "Grouped resourcehijacking walkthroughs. Use `ho-azure resourcehijacking` or `ho-azure resourcehijacking help` to list surfaces, then `ho-azure resourcehijacking \u003csurface\u003e` to run an implemented surface.", + "planned_input_modes": [ + "live" + ], + "preferred_artifact_order": [ + "loot", + "json" + ], + "selected_surface": null, + "surfaces": [ + { + "surface": "api-mgmt", + "state": "implemented", + "summary": "Review API Management services for gateway, backend, subscription, named-value, and identity posture that can redirect a trusted API surface.", + "operator_question": "How far can current access take me through APIM backend and routing-control levers before the proof boundary moves into policy bodies, traffic logs, or backend ownership?", + "backing_commands": [ + "api-mgmt", + "permissions", + "rbac" + ] + }, + { + "surface": "automation", + "state": "implemented", + "summary": "Review Azure Automation accounts for published runbook, schedule, webhook, hybrid worker, secure asset, and identity posture that can repurpose trusted operations automation.", + "operator_question": "How far can current access take me through Automation runbook, trigger, execution context, and account-control levers before the proof boundary moves into script content, job output, or runtime host state?", + "backing_commands": [ + "automation", + "permissions", + "rbac" + ] + }, + { + "surface": "logic-apps", + "state": "implemented", + "summary": "Review Logic Apps for workflow definition, trigger, downstream action, connector, and identity posture that can repurpose trusted automation.", + "operator_question": "How far can current access take me through Logic App trigger, workflow, action, and identity levers before the proof boundary moves into run history, connector data, or secret material?", + "backing_commands": [ + "logic-apps", + "permissions", + "rbac" + ] + } + ], + "issues": [] +} diff --git a/testdata/resourcehijacking.golden.table.txt b/testdata/resourcehijacking.golden.table.txt new file mode 100644 index 0000000..69060cb --- /dev/null +++ b/testdata/resourcehijacking.golden.table.txt @@ -0,0 +1,15 @@ +HO-Azure :: attack-path-focused Azure recon +context :: tenant=auto subscription=auto output=table + +[resourcehijacking] Walking the current identity through existing Azure resources that can be commandeered, redirected, replaced, or repurposed. +ho-azure resourcehijacking + +╭────────────┬────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ surface │ summary │ +├────────────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ api-mgmt │ Review API Management services for gateway, backend, subscription, named-value, and identity posture that can redirect a trusted API surface. │ +│ automation │ Review Azure Automation accounts for published runbook, schedule, webhook, hybrid worker, secure asset, and identity posture that can repurpose trusted operations automation. │ +│ logic-apps │ Review Logic Apps for workflow definition, trigger, downstream action, connector, and identity posture that can repurpose trusted automation. │ +╰────────────┴────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + +Takeaway: 3 resourcehijacking surface(s) available; run a surface to rank visible posture by takeover value.