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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ ho-azure permissions
| Grouped Command | Live Families |
| --- | --- |
| `chains`<br>Grouped path views that pull the strongest Azure pivot stories to the top. | `credential-path`<br>Turns exposed secret and token clues into the downstream target most likely to widen access.<br><br>`deployment-path`<br>Surfaces the build, pipeline, and automation paths most likely to let an attacker change Azure next.<br><br>`escalation-path`<br>Highlights the clearest visible route from the current foothold to stronger Azure control.<br><br>`compute-control`<br>Finds workloads that can already mint identity-backed access and pivot into broader control. |
| `persistence`<br>Service-specific persistence walkthroughs that stay focused on what the current identity can do end to end. | `automation`<br>Walks the current identity through Azure Automation account control, runbook changes, execution context, triggers, and the current state already in place.<br><br>`logic-apps`<br>Walks the current identity through Logic Apps workflow control, trigger posture, execution context, and durable workflow reuse paths. |
| `persistence`<br>Service-specific persistence walkthroughs that stay focused on what the current identity can do end to end. | `app-service`<br>Walks the current identity through App Service deployment, configuration, code replacement, and reachable reuse posture.<br><br>`automation`<br>Walks the current identity through Azure Automation account control, runbook changes, execution context, triggers, and the current state already in place.<br><br>`azure-ml`<br>Walks the current identity through Azure ML reusable compute, jobs, schedules, endpoints, and identity-backed runtime context.<br><br>`container-apps-jobs`<br>Walks the current identity through Container Apps Jobs stored definitions, trigger mode, image/command clues, execution settings, identity, and rerun posture.<br><br>`functions`<br>Walks the current identity through Function App code, identity, config, and trigger reuse posture.<br><br>`logic-apps`<br>Walks the current identity through Logic Apps workflow control, trigger posture, execution context, and durable workflow reuse paths.<br><br>`vm-extensions`<br>Walks the current identity through Azure-side VM Extension attachment, script or command source, settings posture, VM agent delivery, and rerun paths.<br><br>`webjobs`<br>Walks the current identity through App Service WebJobs background code, mode, inherited app context, and rerun paths. |

### Flat Commands

Expand All @@ -119,7 +119,7 @@ ho-azure permissions
| `resource` | `automation`, `devops`, `acr`, `api-mgmt`, `databases`, `resource-trusts` |
| `storage` | `storage` |
| `network` | `application-gateway`, `nics`, `dns`, `endpoints`, `network-effective`, `network-ports` |
| `compute` | `workloads`, `app-services`, `functions`, `container-apps`, `container-instances`, `aks`, `vms`, `vmss`, `snapshots-disks` |
| `compute` | `workloads`, `app-services`, `functions`, `container-apps`, `container-apps-jobs`, `container-instances`, `aks`, `vms`, `vm-extensions`, `vmss`, `snapshots-disks` |

## Need A Test Lab?

Expand Down Expand Up @@ -344,7 +344,7 @@ Current section mappings:
- `resource`: `automation`, `devops`, `acr`, `api-mgmt`, `databases`, `resource-trusts`
- `storage`: `storage`
- `network`: `application-gateway`, `nics`, `dns`, `endpoints`, `network-effective`, `network-ports`
- `compute`: `workloads`, `app-services`, `functions`, `container-apps`, `container-instances`, `aks`, `vms`, `vmss`, `snapshots-disks`
- `compute`: `workloads`, `app-services`, `functions`, `container-apps`, `container-apps-jobs`, `container-instances`, `aks`, `vms`, `vm-extensions`, `vmss`, `snapshots-disks`
- `core`: `inventory`
- `orchestration`: `chains`, `persistence`

Expand All @@ -357,8 +357,14 @@ Current `chains` families:

Current `persistence` surfaces:

- `app-service`
- `automation`
- `azure-ml`
- `container-apps-jobs`
- `functions`
- `logic-apps`
- `vm-extensions`
- `webjobs`

## Help

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ func implementedArtifactCases() []artifactCase {
explicitArtifactCase("persistence-automation", []string{"persistence", "automation", "--output", "json"}, "persistence"),
explicitArtifactCase("persistence-app-service", []string{"persistence", "app-service", "--output", "json"}, "persistence"),
explicitArtifactCase("persistence-azure-ml", []string{"persistence", "azure-ml", "--output", "json"}, "persistence"),
explicitArtifactCase("persistence-container-apps-jobs", []string{"persistence", "container-apps-jobs", "--output", "json"}, "persistence"),
explicitArtifactCase("persistence-vm-extensions", []string{"persistence", "vm-extensions", "--output", "json"}, "persistence"),
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"),
Expand Down
8 changes: 6 additions & 2 deletions internal/commands/chains_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,10 +351,14 @@ func buildDevopsDeploymentRecord(
supporting []models.ArmDeploymentSummary,
) (models.ChainPathRecord, bool) {
spec := deploymentTargetSpecs[familyName]
exactTargets, confirmationBasis := devopsExactTargets(pipeline, familyName, targetsByFamily[familyName])
visibleTargets := targetsByFamily[familyName]
exactTargets, confirmationBasis := devopsExactTargets(pipeline, familyName, visibleTargets)
selectedTargets := exactTargets
if len(selectedTargets) == 0 && targetVisibilityIssue == nil {
selectedTargets = append([]deploymentTarget{}, targetsByFamily[familyName]...)
selectedTargets = append([]deploymentTarget{}, visibleTargets...)
}
if len(selectedTargets) == 0 && len(visibleTargets) == 0 && targetVisibilityIssue == nil && len(pipeline.TargetClues) > 0 {
targetVisibilityIssue = models.StringPtr("current " + spec.Label + " inventory returned no visible targets for the pipeline clue")
}
targetResolution := deploymentTargetResolution(selectedTargets, confirmationBasis, targetVisibilityIssue, pipeline.MissingTargetMapping)
if targetResolution == "" {
Expand Down
55 changes: 55 additions & 0 deletions internal/commands/chains_deployment_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package commands

import (
"strings"
"testing"

"harrierops-azure/internal/models"
)

func TestBuildDevopsDeploymentRecordKeepsPipelineTargetClueWhenTargetInventoryIsEmpty(t *testing.T) {
pipeline := models.DevopsPipelineAsset{
ID: "https://dev.azure.com/contoso/project/_build?definitionId=3",
Name: "contoso-proof-targeted",
ProjectName: "project",
AzureServiceConnectionPrincipalIDs: []string{"sp-object-id"},
TargetClues: []string{"App Service", "App Service: app-public-api-6402b6"},
ExecutionModes: []string{"auto-trigger"},
TrustedInputRefs: []string{"repository:azure-repos:contoso-proof@refs/heads/main"},
PrimaryTrustedInputRef: "repository:azure-repos:contoso-proof@refs/heads/main",
PrimaryInjectionSurface: "repo-content",
CurrentOperatorCanContributeSource: boolPtr(true),
CurrentOperatorCanEdit: boolPtr(true),
CurrentOperatorInjectionSurfaceTypes: []string{"repo-content", "definition-edit"},
ConsequenceTypes: []string{"redeploy-workload", "reintroduce-config"},
}

record, ok := buildDevopsDeploymentRecord(
pipeline,
"app-services",
map[string][]deploymentTarget{"app-services": {}},
nil,
nil,
map[string]models.PermissionRow{},
map[string][]models.RoleTrustSummary{},
map[string][]models.RoleTrustSummary{},
map[string]models.KeyVaultAsset{},
nil,
)

if !ok {
t.Fatal("buildDevopsDeploymentRecord() ok = false, want row preserved")
}
if record.TargetResolution != "visibility blocked" {
t.Fatalf("target_resolution = %q, want visibility blocked", record.TargetResolution)
}
if record.LikelyAzureImpact == nil || *record.LikelyAzureImpact != "Azure footprint not yet mapped; visible app service clues only" {
t.Fatalf("likely_azure_impact = %v, want clue-only impact", record.LikelyAzureImpact)
}
if record.TargetVisibility == nil || !strings.Contains(*record.TargetVisibility, "no visible targets") {
t.Fatalf("target_visibility = %#v, want empty-inventory explanation", record.TargetVisibility)
}
if record.Actionability == nil || *record.Actionability != "currently actionable" {
t.Fatalf("actionability = %v, want currently actionable", record.Actionability)
}
}
52 changes: 52 additions & 0 deletions internal/commands/container_apps_jobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package commands

import (
"context"
"time"

"harrierops-azure/internal/models"
"harrierops-azure/internal/providers"
)

func containerAppsJobsHandler(provider providers.Provider, now func() time.Time) Handler {
return func(ctx context.Context, request Request) (any, error) {
facts, err := provider.ContainerAppsJobs(ctx, request.Tenant, request.Subscription)
if err != nil {
return nil, err
}

jobs := sortedByLess(facts.ContainerAppsJobs, containerAppsJobLess)

return models.ContainerAppsJobsOutput{
ContainerAppsJobs: jobs,
Findings: []models.Finding{},
Issues: facts.Issues,
Metadata: runtimeCommandMetadata("container-apps-jobs", now, facts.TenantID, facts.SubscriptionID),
}, nil
}
}

func containerAppsJobLess(left models.ContainerAppsJobAsset, right models.ContainerAppsJobAsset) bool {
leftScheduled := stringPtrValue(left.ScheduleExpression) != ""
rightScheduled := stringPtrValue(right.ScheduleExpression) != ""
if leftScheduled != rightScheduled {
return leftScheduled
}

leftEvent := len(left.EventRules) > 0
rightEvent := len(right.EventRules) > 0
if leftEvent != rightEvent {
return leftEvent
}

leftIdentity := stringPtrValue(left.WorkloadIdentityType) != ""
rightIdentity := stringPtrValue(right.WorkloadIdentityType) != ""
if leftIdentity != rightIdentity {
return leftIdentity
}

if left.Name != right.Name {
return left.Name < right.Name
}
return left.ID < right.ID
}
5 changes: 2 additions & 3 deletions internal/commands/logic_apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,13 @@ func TestBuildPersistenceWebJobsOutputResolvesInheritedExecutionContext(t *testi
t.Fatalf("expected nightly-reconcile webjob in output")
}

func TestPersistenceAppServiceStillUnmappedPointsToWebJobsSurface(t *testing.T) {
func TestPersistenceAppServiceStillUnmappedDoesNotDuplicateWebJobsSurfaceBoundary(t *testing.T) {
items := persistenceAppServiceStillUnmapped()
for _, item := range items {
if strings.Contains(item, "`persistence webjobs`") {
return
t.Fatalf("expected WebJobs boundary to stay in table walkthrough rather than Not collected by default, got %#v", items)
}
}
t.Fatalf("expected App Service still-unmapped guidance to point at persistence webjobs, got %#v", items)
}

func TestPersistenceLogicAppSummaryDoesNotOverclaimDisabledWorkflowAsDurable(t *testing.T) {
Expand Down
23 changes: 14 additions & 9 deletions internal/commands/persistence.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ var (
type persistenceSurfaceBuilder func(context.Context, providers.Provider, func() time.Time, Request, contracts.PersistenceSurfaceContract) (any, error)

var persistenceSurfaceBuilders = map[string]persistenceSurfaceBuilder{
"automation": buildPersistenceAutomationOutput,
"app-service": buildPersistenceAppServiceOutput,
"azure-ml": buildPersistenceAzureMLOutput,
"functions": buildPersistenceFunctionsOutput,
"logic-apps": buildPersistenceLogicAppsOutput,
"webjobs": buildPersistenceWebJobsOutput,
"automation": buildPersistenceAutomationOutput,
"app-service": buildPersistenceAppServiceOutput,
"azure-ml": buildPersistenceAzureMLOutput,
"container-apps-jobs": buildPersistenceContainerAppsJobsOutput,
"functions": buildPersistenceFunctionsOutput,
"logic-apps": buildPersistenceLogicAppsOutput,
"vm-extensions": buildPersistenceVMExtensionsOutput,
"webjobs": buildPersistenceWebJobsOutput,
}

func persistenceHandler(provider providers.Provider, now func() time.Time) Handler {
Expand Down Expand Up @@ -156,6 +158,7 @@ func buildPersistenceAutomationOutput(
PublishedRunbookCount: account.PublishedRunbookCount,
PublishedRunbookNames: append([]string{}, account.PublishedRunbookNames...),
ScheduleCount: account.ScheduleCount,
ScheduleDefinitions: append([]string{}, account.ScheduleDefinitions...),
JobScheduleCount: account.JobScheduleCount,
WebhookCount: account.WebhookCount,
HybridWorkerGroupCount: account.HybridWorkerGroupCount,
Expand Down Expand Up @@ -319,12 +322,14 @@ func persistenceRoleSummary(roleNames []string, scopeIDs []string) string {

func persistenceAutomationStillUnmapped(account models.AutomationAccountAsset) []string {
items := []string{
"the exact runbook code, content source, or operator intent behind this Automation surface",
"the current command does not retrieve runbook code bodies, runbook package contents, or content source payloads, so operator intent is not inferred from Automation content here",
}
if account.MissingTargetMapping {
items = append(items, "the exact downstream resources, workflows, or credentials this automation path would modify")
items = append(items, "the current command does not resolve the exact downstream resources, workflows, or credentials this automation path would modify without reading or executing runbook logic")
}
items = append(items, "the full schedule cadence, webhook URI, or trigger usefulness beyond the currently modeled metadata")
items = append(items,
"the current command does not print webhook URI material, run job history, or runtime-side trigger success, so it does not prove a trigger is reachable or useful at execution time",
)
return dedupeStrings(items)
}

Expand Down
1 change: 0 additions & 1 deletion internal/commands/persistence_app_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,6 @@ func persistenceAppServiceStillUnmapped() []string {
"the current command does not retrieve deployed application packages, repository contents, or source bundles, so operator intent is not inferred from code here",
"the current command does not replay HTTP traffic or inspect runtime-side request handling, so conclusions stop at visible management-plane exposure posture",
"the current command does not infer downstream API, data, or automation impact from runtime stack or setting names alone",
"this App Service view stops at the main web host; use `persistence webjobs` when you need App Service WebJobs background-execution depth",
}
}

Expand Down
2 changes: 1 addition & 1 deletion internal/commands/persistence_azure_ml.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ var persistenceAzureMLSteps = []persistenceAzureMLStepDefinition{
{Action: "create or modify workspace", APISurface: "Microsoft.MachineLearningServices/workspaces"},
{Action: "attach or reuse compute", APISurface: "computes"},
{Action: "add or modify jobs or pipelines", APISurface: "jobs / pipelines"},
{Action: "create or modify schedule", APISurface: "schedules"},
{Action: "attach or reuse exec ctx", APISurface: "workspace identity"},
{Action: "create or modify schedule", APISurface: "schedules"},
{Action: "expose or reuse endpoint", APISurface: "onlineEndpoints"},
}

Expand Down
Loading
Loading