Skip to content

Commit 2c323bf

Browse files
authored
Azure multi subscription scanning feature (#112)
1 parent f287b41 commit 2c323bf

15 files changed

Lines changed: 741 additions & 162 deletions

File tree

.github/workflows/main-validation.yml

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ jobs:
155155
tenant-id: ${{ secrets.AZURE_TENANT_ID }} # Azure AD tenant
156156
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
157157
enable-AzPSSession: false
158-
allow-no-subscription: false
158+
allow-no-subscriptions: false
159159

160160
- name: Test Azure doctor (strict)
161161
run: |
@@ -174,13 +174,55 @@ jobs:
174174
name: azure-integration-main
175175
path: test-results.json
176176

177+
integration-test-azure-multi-subscription:
178+
name: Integration Test - Azure Multi-Subscription (Required)
179+
runs-on: ubuntu-latest
180+
needs: validate
181+
environment: cleancloud-test
182+
183+
steps:
184+
- uses: actions/checkout@v4
185+
186+
- name: Set up Python
187+
uses: actions/setup-python@v5
188+
with:
189+
python-version: "3.11"
190+
191+
- name: Install CleanCloud
192+
run: |
193+
python -m pip install --upgrade pip
194+
pip install -e ".[dev]"
195+
196+
- name: Azure Login via OIDC (no subscription pin — scans all)
197+
uses: azure/login@v2
198+
with:
199+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
200+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
201+
enable-AzPSSession: false
202+
allow-no-subscriptions: true
203+
204+
- name: Test Azure multi-subscription scan
205+
run: |
206+
set -e
207+
cleancloud scan \
208+
--provider azure \
209+
--output json \
210+
--output-file multi-subscription-results.json
211+
212+
- name: Upload test results
213+
if: always()
214+
uses: actions/upload-artifact@v4
215+
with:
216+
name: azure-multi-subscription-main
217+
path: multi-subscription-results.json
218+
177219
# =========================
178220
# NOTIFY ON FAILURE
179221
# =========================
180222
notify-failure:
181223
name: Create Issue on Failure
182224
runs-on: ubuntu-latest
183-
needs: [validate, integration-test-aws, integration-test-aws-multi-account, integration-test-azure]
225+
needs: [validate, integration-test-aws, integration-test-aws-multi-account, integration-test-azure, integration-test-azure-multi-subscription]
184226
if: failure()
185227

186228
steps:

.github/workflows/pr-checks.yml

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ jobs:
7575
tenant-id: ${{ secrets.AZURE_TENANT_ID }} # Azure AD tenant
7676
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
7777
enable-AzPSSession: false
78-
allow-no-subscription: false
78+
allow-no-subscriptions: false
7979

8080
- name: Run tests
8181
run: |
@@ -236,7 +236,7 @@ jobs:
236236
tenant-id: ${{ secrets.AZURE_TENANT_ID }} # Azure AD tenant
237237
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
238238
enable-AzPSSession: false
239-
allow-no-subscription: false
239+
allow-no-subscriptions: false
240240

241241
- name: Test Azure doctor command
242242
run: |
@@ -255,3 +255,44 @@ jobs:
255255
with:
256256
name: azure-integration-test-results
257257
path: test-results.json
258+
259+
integration-test-azure-multi-subscription:
260+
name: Integration Test - Azure Multi-Subscription
261+
runs-on: ubuntu-latest
262+
continue-on-error: true
263+
environment: cleancloud-test
264+
265+
steps:
266+
- uses: actions/checkout@v4
267+
268+
- name: Set up Python
269+
uses: actions/setup-python@v5
270+
with:
271+
python-version: "3.11"
272+
273+
- name: Install CleanCloud
274+
run: |
275+
python -m pip install --upgrade pip
276+
pip install -e ".[dev]"
277+
278+
- name: Azure Login via OIDC (no subscription pin — scans all)
279+
uses: azure/login@v2
280+
with:
281+
client-id: ${{ secrets.AZURE_CLIENT_ID }}
282+
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
283+
enable-AzPSSession: false
284+
allow-no-subscriptions: true
285+
286+
- name: Test Azure multi-subscription scan
287+
run: |
288+
cleancloud scan \
289+
--provider azure \
290+
--output json \
291+
--output-file multi-subscription-results.json
292+
293+
- name: Upload test results
294+
if: always()
295+
uses: actions/upload-artifact@v4
296+
with:
297+
name: azure-multi-subscription-pr-results
298+
path: multi-subscription-results.json

README.fr.md

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ Hygiène cloud en lecture seule pour les environnements réglementés & souverai
2323
CleanCloud scanne votre environnement cloud et rapporte ce qui gaspille de l'argent. Exécutez-le une fois pour un audit ponctuel, planifiez-le, ou intégrez-le en CI/CD pour bloquer les builds sur des violations de politique.
2424

2525
- **20 règles de détection haut signal :** volumes orphelins, bases de données inactives, load balancers vides, et plus
26-
- **Gaspillage mensuel estimé :** par finding et en agrégat
27-
- **Scan multi-comptes :** scannez des AWS Organizations entières en quelques minutes — fichier de config, IDs inline, ou auto-découverte via `--org`
26+
- **Gaspillage mensuel estimé :** par finding et en agrégat, détaillé par compte et abonnement
27+
- **Scan multi-comptes (AWS) :** scannez des AWS Organizations entières en quelques minutes — fichier de config, IDs inline, ou auto-découverte via `--org`
28+
- **Scan multi-abonnements (Azure) :** scannez tous les abonnements Azure en parallèle avec une seule identité — auto-découverte via Management Group ou tous les accessibles — détail des coûts par abonnement inclus
2829
- **Application de politique CI/CD (opt-in) :** `--fail-on-confidence HIGH` ou `--fail-on-cost 100` gate votre pipeline
2930
- **Formats de sortie multiples :** lisible, JSON, CSV, et markdown (à coller dans vos PRs GitHub ou Slack)
3031
- **Lecture seule par conception :** aucune suppression, aucune modification de tags, aucune mutation — jamais
@@ -99,6 +100,10 @@ Régions scannées : us-east-1, us-west-2, eu-west-1
99100
--concurrency N Comptes en parallèle (défaut : 3)
100101
--timeout SECONDS Timeout total du scan en secondes (défaut : 3600)
101102
103+
# Multi-abonnements — Azure uniquement (optionnel)
104+
--management-group ID Scanner tous les abonnements d'un Management Group
105+
--subscription ID Scanner un seul abonnement (défaut : tous les accessibles)
106+
102107
# Sortie (optionnel)
103108
--output human|json|csv|markdown Format de sortie (défaut : human)
104109
--output-file FILE Écrit la sortie dans un fichier
@@ -123,8 +128,18 @@ cleancloud demo # visualisez des findings sans aucun credential cloud
123128
```bash
124129
docker pull getcleancloud/cleancloud
125130
docker run --rm getcleancloud/cleancloud demo
131+
132+
# Avec credentials AWS (Docker n'hérite pas de ~/.aws automatiquement)
133+
docker run --rm \
134+
-e AWS_ACCESS_KEY_ID \
135+
-e AWS_SECRET_ACCESS_KEY \
136+
-e AWS_SESSION_TOKEN \
137+
-e AWS_REGION=us-east-1 \
138+
getcleancloud/cleancloud scan --provider aws --all-regions
126139
```
127140

141+
> En CI/CD, `aws-actions/configure-aws-credentials` définit les variables `AWS_*` sur le runner — passez-les avec `-e VAR_NAME` et elles sont transmises au conteneur automatiquement. Voir [Guide CI/CD →](docs/ci.md#using-the-docker-image)
142+
128143
Prêt à scanner votre vrai environnement ? Authentifiez-vous d'abord, puis lancez :
129144

130145
```bash
@@ -385,12 +400,54 @@ Guide complet (politique IAM, trust policy, templates IaC) : [Configuration mult
385400

386401
---
387402

403+
## Scan multi-abonnements (Azure)
404+
405+
Conçu pour les entreprises gérant de grands tenants Azure. Scannez chaque abonnement en parallèle avec une seule identité — findings agrégés dans un rapport unique avec détail des coûts par abonnement.
406+
407+
```bash
408+
# Scanner tous les abonnements accessibles (défaut)
409+
cleancloud scan --provider azure
410+
411+
# Auto-découverte via Management Group
412+
cleancloud scan --provider azure --management-group <MANAGEMENT_GROUP_ID>
413+
414+
# Liste explicite
415+
cleancloud scan --provider azure --subscription <SUB_1> --subscription <SUB_2>
416+
```
417+
418+
**Permissions requises :**
419+
420+
| Périmètre | Rôle |
421+
|---|---|
422+
| Chaque abonnement | Reader (intégré) |
423+
| Management Group (si `--management-group`) | Reader + `Microsoft.Management/managementGroups/read` |
424+
425+
Assignez Reader au niveau du Management Group — il hérite automatiquement à tous les abonnements en dessous :
426+
427+
```bash
428+
az role assignment create \
429+
--assignee <SERVICE_PRINCIPAL_CLIENT_ID> \
430+
--role Reader \
431+
--scope /providers/Microsoft.Management/managementGroups/<MANAGEMENT_GROUP_ID>
432+
```
433+
434+
**Fonctionnement :**
435+
436+
- **Modèle d'identité plat** — un seul service principal, Reader au niveau du Management Group. Pas d'assumption de rôle inter-abonnements, pas de complexité hub-and-spoke.
437+
- **Trois modes de découverte** — tous les accessibles (défaut), `--management-group` pour l'auto-découverte, `--subscription` pour un contrôle explicite.
438+
- **Parallèle avec isolation** — chaque abonnement s'exécute dans son propre thread. Un abonnement en échec (permission refusée, timeout) n'affecte jamais les autres.
439+
- **Gestion gracieuse des permissions** — les règles échouant avec 403 sont signalées comme ignorées (avec la permission manquante nommée), pas comme des échecs de scan.
440+
- **Détail des coûts par abonnement** — la sortie indique le gaspillage mensuel estimé par abonnement pour identifier précisément lequel est problématique.
441+
442+
Guide complet (RBAC, Workload Identity, Management Group) : [Configuration multi-abonnements Azure →](docs/azure.md#multi-subscription-scanning)
443+
444+
---
445+
388446
## Feuille de route
389447

390448
- Règles AWS supplémentaires (cycle de vie S3, instances EC2 arrêtées)
391449
- Policy-as-code dans `cleancloud.yaml` (`fail_on_confidence`, `fail_on_cost` en config)
392450
- Filtrage de règles (flag `--rules`)
393-
- Scan Azure Management Groups (multi-abonnements au niveau org)
394451

395452
---
396453

README.md

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ Read-only cloud hygiene for regulated & sovereign environments.
2323
CleanCloud scans your cloud environment and reports what's wasting money. Run it once for a quick audit, schedule it, or wire it into CI/CD to fail builds on policy violations.
2424

2525
- **20 high-signal detection rules:** orphaned volumes, idle databases, empty load balancers, and more
26-
- **Estimated monthly waste:** per finding and aggregate
27-
- **Multi-account scanning:** scan entire AWS Organizations in minutes — config file, inline IDs, or auto-discovery via `--org`
26+
- **Estimated monthly waste:** per finding and aggregate, broken down per account and subscription
27+
- **Multi-account scanning (AWS):** scan entire AWS Organizations in minutes — config file, inline IDs, or auto-discovery via `--org`
28+
- **Multi-subscription scanning (Azure):** scan all Azure subscriptions in parallel with one identity — auto-discovery via Management Group or all accessible — per-subscription cost breakdown included
2829
- **CI-native enforcement (opt-in):** `--fail-on-confidence HIGH` or `--fail-on-cost 100` gates your pipeline
2930
- **Multiple output formats:** human-readable, JSON, CSV, and markdown (paste into GitHub PRs or Slack)
3031
- **Read-only by design:** no deletions, no tag changes, no mutations — ever
@@ -92,8 +93,18 @@ cleancloud demo # see sample findings without any cloud credentials
9293
```bash
9394
docker pull getcleancloud/cleancloud
9495
docker run --rm getcleancloud/cleancloud demo
96+
97+
# With AWS credentials (Docker doesn't inherit local ~/.aws automatically)
98+
docker run --rm \
99+
-e AWS_ACCESS_KEY_ID \
100+
-e AWS_SECRET_ACCESS_KEY \
101+
-e AWS_SESSION_TOKEN \
102+
-e AWS_REGION=us-east-1 \
103+
getcleancloud/cleancloud scan --provider aws --all-regions
95104
```
96105

106+
> In CI/CD, `aws-actions/configure-aws-credentials` sets `AWS_*` env vars on the runner — pass them with `-e VAR_NAME` and they forward into the container automatically. See [CI/CD guide →](docs/ci.md#using-the-docker-image)
107+
97108
When you're ready to scan your real environment, authenticate first — then run:
98109

99110
```bash
@@ -124,6 +135,10 @@ Not sure if your credentials have the right permissions? Run `cleancloud doctor
124135
--concurrency N Parallel accounts (default: 3)
125136
--timeout SECONDS Total scan timeout in seconds (default: 3600)
126137
138+
# Multi-subscription — Azure only (optional)
139+
--management-group ID Scan all subscriptions under a Management Group
140+
--subscription ID Scan a single subscription (default: all accessible)
141+
127142
# Output (optional)
128143
--output human|json|csv|markdown Output format (default: human)
129144
--output-file FILE Write output to file instead of stdout
@@ -387,12 +402,54 @@ Full setup guide (IAM policy, trust policy, IaC templates): [AWS multi-account s
387402

388403
---
389404

405+
## Multi-Subscription Scanning (Azure)
406+
407+
Built for enterprises running large Azure tenants. Scan every subscription in parallel with one identity — findings aggregated into one report with a per-subscription cost breakdown.
408+
409+
```bash
410+
# Scan all subscriptions the service principal can access (default)
411+
cleancloud scan --provider azure
412+
413+
# Auto-discover via Management Group
414+
cleancloud scan --provider azure --management-group <MANAGEMENT_GROUP_ID>
415+
416+
# Explicit list
417+
cleancloud scan --provider azure --subscription <SUB_1> --subscription <SUB_2>
418+
```
419+
420+
**Permissions required:**
421+
422+
| Scope | Role |
423+
|---|---|
424+
| Each subscription | Reader (built-in) |
425+
| Management Group (if using `--management-group`) | Reader + `Microsoft.Management/managementGroups/read` |
426+
427+
Assign Reader at the Management Group level and it inherits to all subscriptions underneath — no per-subscription role assignment needed:
428+
429+
```bash
430+
az role assignment create \
431+
--assignee <SERVICE_PRINCIPAL_CLIENT_ID> \
432+
--role Reader \
433+
--scope /providers/Microsoft.Management/managementGroups/<MANAGEMENT_GROUP_ID>
434+
```
435+
436+
**How it works:**
437+
438+
- **Flat identity model** — one service principal, Reader at Management Group level. No cross-subscription role assumption, no hub-and-spoke complexity.
439+
- **Three discovery modes** — all accessible (default), `--management-group` for auto-discovery, `--subscription` for explicit control.
440+
- **Parallel with isolation** — each subscription runs in its own thread. One subscription failing (permission denied, timeout) never affects the others.
441+
- **Graceful permission handling** — rules that fail with 403 are reported as skipped (with the missing permission named), not as scan failures.
442+
- **Per-subscription cost breakdown** — output shows estimated monthly waste per subscription so you can see exactly which subscription is dirty.
443+
444+
Full setup guide (RBAC, Workload Identity, Management Group): [Azure multi-subscription setup →](docs/azure.md#multi-subscription-scanning)
445+
446+
---
447+
390448
## Roadmap
391449

392450
- Additional AWS rules (S3 lifecycle, stopped EC2 instances)
393451
- Policy-as-code in `cleancloud.yaml` (`fail_on_confidence`, `fail_on_cost` in config)
394452
- Rule filtering (`--rules` flag)
395-
- Azure Management Group scanning (multi-subscription org-level)
396453

397454
---
398455

cleancloud/output/summary.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,17 +78,22 @@ def _print_summary(summary: dict, region_selection_mode: str = None, multi_accou
7878
# Use provider-aware label
7979
provider = summary.get("provider", "aws")
8080
if provider == "azure":
81+
subscriptions_scanned = summary.get("subscriptions_scanned", [])
8182
label = "Subscriptions scanned"
83+
regions_str = ", ".join(subscriptions_scanned) if subscriptions_scanned else regions_str
8284
else:
8385
label = "Regions scanned"
8486

8587
click.echo(f"\n{label}: {regions_str}", nl=False)
8688

8789
# Selection mode annotations
8890
if provider == "azure":
89-
if region_selection_mode == "all":
91+
mode = summary.get("subscription_selection_mode", "")
92+
if mode == "all":
9093
click.echo(" (all accessible)")
91-
elif region_selection_mode == "explicit":
94+
elif mode == "management-group":
95+
click.echo(" (management group)")
96+
elif mode == "explicit":
9297
click.echo(" (explicit)")
9398
else:
9499
click.echo()
@@ -179,6 +184,29 @@ def _print_summary(summary: dict, region_selection_mode: str = None, multi_accou
179184
for r in timed_out:
180185
click.echo(f" [timeout] {r.account_name} ({r.account_id})")
181186

187+
# Azure multi-subscription breakdown
188+
per_sub = summary.get("per_subscription")
189+
if per_sub:
190+
failed_subs = summary.get("subscriptions_failed", [])
191+
click.echo()
192+
click.echo(f"Subscriptions scanned: {len(per_sub) - len(failed_subs)}")
193+
if failed_subs:
194+
click.echo(f"Subscriptions failed: {len(failed_subs)}")
195+
click.echo()
196+
click.echo("Per-subscription breakdown:")
197+
for r in per_sub:
198+
cost = r.get("estimated_monthly_cost_usd", 0)
199+
cost_str = f" ~${cost:,.0f}/month" if cost else ""
200+
status = "" if r["status"] == "success" else f" [{r['status']}]"
201+
click.echo(
202+
f" {r['name']:<30} ({r['id']}):" f" {r['findings']} findings{cost_str}{status}"
203+
)
204+
if failed_subs:
205+
click.echo()
206+
click.echo("Failed subscriptions:")
207+
for r in failed_subs:
208+
click.echo(f" [failed] {r['name']} ({r['id']}): {r.get('error', '')}")
209+
182210
# Success message
183211
if summary["total_findings"] == 0:
184212
click.echo()

0 commit comments

Comments
 (0)