Skip to content

Commit b0bfa43

Browse files
Suresh MandalapuSuresh Mandalapu
authored andcommitted
fixes
1 parent 61d05e5 commit b0bfa43

7 files changed

Lines changed: 121 additions & 54 deletions

File tree

cleancloud/providers/azure/scan.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class SubscriptionScanResult:
5959
status: str # "success" | "failed"
6060
findings: List[Finding] = field(default_factory=list)
6161
skipped_rules: List[dict] = field(default_factory=list)
62+
rules_failed: int = 0
6263
error: Optional[str] = None
6364

6465
@property
@@ -255,14 +256,15 @@ def scan_azure_subscriptions(
255256
sub_id = futures[future]
256257
sub_name = sub_name_map.get(sub_id, sub_id)
257258
try:
258-
sub_findings, sub_skipped = future.result()
259+
sub_findings, sub_skipped, sub_rules_failed = future.result()
259260
results.append(
260261
SubscriptionScanResult(
261262
subscription_id=sub_id,
262263
subscription_name=sub_name,
263264
status="success",
264265
findings=sub_findings,
265266
skipped_rules=sub_skipped,
267+
rules_failed=sub_rules_failed,
266268
)
267269
)
268270
except Exception as e:
@@ -288,7 +290,7 @@ def _scan_azure_subscription(
288290
credential,
289291
region_filter: Optional[str],
290292
rules: Optional[List[Callable]] = None,
291-
) -> Tuple[List[Finding], List[dict]]:
293+
) -> Tuple[List[Finding], List[dict], int]:
292294
findings: List[Finding] = []
293295
skipped_rules: List[dict] = []
294296
rules_succeeded = 0
@@ -370,4 +372,4 @@ def _scan_azure_subscription(
370372
f"This indicates a serious configuration or permissions issue."
371373
)
372374

373-
return findings, skipped_rules
375+
return findings, skipped_rules, rules_failed

cleancloud/scan/command.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,9 @@ def scan(
729729
summary["total_rules"] = len(azure_rules_to_run)
730730
summary["subscription_selection_mode"] = subscription_selection_mode
731731
summary["subscriptions_scanned"] = subscriptions_scanned
732+
total_rules_failed = sum(r.rules_failed for r in azure_sub_results)
733+
if total_rules_failed > 0:
734+
summary["rules_failed"] = total_rules_failed
732735
failed_subs = [r for r in azure_sub_results if r.status == "failed"]
733736
if failed_subs:
734737
summary["subscriptions_failed"] = [
@@ -745,6 +748,7 @@ def scan(
745748
"name": r.subscription_name,
746749
"status": r.status,
747750
"findings": len(r.findings),
751+
"rules_failed": r.rules_failed,
748752
"estimated_monthly_cost_usd": round(r.estimated_monthly_cost, 2),
749753
}
750754
for r in sorted(azure_sub_results, key=lambda r: r.subscription_name)
@@ -776,13 +780,17 @@ def scan(
776780
}
777781
for r in sorted(gcp_project_results, key=lambda r: r.project_name)
778782
]
779-
# Build rules_evaluated: {rule_id: finding_count} for all rules that ran
783+
# Build rules_evaluated: {rule_id: finding_count} for all rules that ran.
784+
# Unwrap functools.partial objects so parameterized rules still appear in the display.
780785
if provider == "aws":
781-
_active_rule_map = {v: k for k, v in aws_rule_map.items() if v in aws_rules_to_run}
786+
_active_funcs = {getattr(r, "func", r) for r in aws_rules_to_run}
787+
_active_rule_map = {v: k for k, v in aws_rule_map.items() if v in _active_funcs}
782788
elif provider == "azure":
783-
_active_rule_map = {v: k for k, v in azure_rule_map.items() if v in azure_rules_to_run}
789+
_active_funcs = {getattr(r, "func", r) for r in azure_rules_to_run}
790+
_active_rule_map = {v: k for k, v in azure_rule_map.items() if v in _active_funcs}
784791
else:
785-
_active_rule_map = {v: k for k, v in gcp_rule_map.items() if v in gcp_rules_to_run}
792+
_active_funcs = {getattr(r, "func", r) for r in gcp_rules_to_run}
793+
_active_rule_map = {v: k for k, v in gcp_rule_map.items() if v in _active_funcs}
786794
_findings_by_rule = Counter(f.rule_id for f in findings)
787795
summary["rules_evaluated"] = {
788796
rule_id: _findings_by_rule.get(rule_id, 0)

docs/configuration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,13 @@ See [rules.md](rules.md) for the full list of rule IDs and their supported param
177177
| `idle_days` | `azure.ml.compute_instance.idle` | 14 | Days since last control-plane activity before flagging |
178178
| `idle_days` | `azure.sql.database.idle` | 14 | Days of no connections before flagging |
179179
| `idle_days` | `azure.app_service.idle` | 14 | Days of zero requests before flagging |
180+
| `days_unused` | `azure.container_registry.unused` | 90 | Days with zero successful pulls and pushes before flagging |
180181
| `max_age_days` | `aws.ec2.ami.old` | 180 | Age in days before flagging |
181182
| `max_age_days` | `aws.ebs.snapshot.old` | 90 | Age in days before flagging |
182183
| `max_age_days` | `aws.rds.snapshot.old` | 90 | Age in days before flagging |
183184
| `max_age_days` | `aws.ec2.eni.detached` | 60 | Age in days before flagging |
184185
| `max_age_days` | `aws.ec2.instance.stopped` | 30 | Days stopped before flagging |
186+
| `max_age_days` | `azure.compute.snapshot.old` | 90 | Age in days for the higher-confidence snapshot review band |
185187
| `max_age_days` | `gcp.compute.snapshot.old` | 90 | Age in days before flagging |
186188
| `max_age_days` | `gcp.compute.vm.stopped` | 30 | Days stopped before flagging |
187189
| `days_unattached` | `aws.ec2.elastic_ip.unattached` | 30 | Days unattached before flagging |

docs/example-outputs.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -751,19 +751,20 @@ Found 7 hygiene issues:
751751
- days_idle_threshold: 14
752752
- subscription: Production
753753
754-
6. [AZURE] Unused Container Registry (90+ Days No Pulls)
754+
6. [AZURE] Unused Container Registry (90+ Days No Pulls or Pushes)
755755
Risk : Low
756756
Confidence : High
757757
Resource : azure.container_registry → /subscriptions/.../registries/acr-old-project
758758
Region : eastus
759759
Rule : azure.container_registry.unused
760-
Reason : Container registry has zero pull activity for 90+ days
760+
Reason : SuccessfulPullCount and SuccessfulPushCount both evaluated to ZERO over a 90-day window
761761
Detected : 2026-02-08T14:45:18+00:00
762762
Details:
763-
- registry_name: acr-old-project
764-
- sku: Standard
765-
- days_unused_threshold: 90
766-
- subscription: Staging
763+
- registry_name: acr-old-project
764+
- sku: Standard
765+
- created_at: 2025-08-01T00:00:00+00:00
766+
- days_unused_threshold: 90
767+
- subscription: Staging
767768
768769
7. [AZURE] Untagged Resource
769770
Risk : Low
@@ -1272,31 +1273,32 @@ Scanned at: 2026-02-08T14:45:19+00:00
12721273
"resource_type": "azure.container_registry",
12731274
"resource_id": "/subscriptions/f9e8d7c6-b5a4-3210-fedc-ba0987654321/resourceGroups/rg-staging/providers/Microsoft.ContainerRegistry/registries/acr-old-project",
12741275
"region": "eastus",
1275-
"title": "Unused Container Registry (90+ Days No Pulls)",
1276-
"summary": "Container Registry 'acr-old-project' (Standard) has had no image pulls for 90+ days.",
1277-
"reason": "Container registry has zero pull activity for 90+ days",
1276+
"title": "Unused Container Registry (90+ Days No Pulls or Pushes)",
1277+
"summary": "Container Registry 'acr-old-project' (Standard) has had no successful pulls or pushes for 90+ days.",
1278+
"reason": "SuccessfulPullCount and SuccessfulPushCount both evaluated to ZERO over a 90-day window",
12781279
"risk": "low",
12791280
"confidence": "high",
12801281
"detected_at": "2026-02-08T14:45:18+00:00",
12811282
"details": {
12821283
"registry_name": "acr-old-project",
12831284
"sku": "Standard",
12841285
"location": "eastus",
1286+
"created_at": "2025-08-01T00:00:00+00:00",
12851287
"days_unused_threshold": 90
12861288
},
12871289
"estimated_monthly_cost_usd": 20.0,
12881290
"evidence": {
12891291
"signals_used": [
1290-
"Zero successful image pulls for 90 days (Azure Monitor: SuccessfulPullCount)",
1291-
"Zero successful image pushes for 90 days (Azure Monitor: SuccessfulPushCount)",
1292-
"No push or pull activity detected across the entire 90-day window",
1292+
"Registry creation date satisfies properties.creationDate <= window_start",
1293+
"SuccessfulPullCount and SuccessfulPushCount both evaluated to ZERO for the 90-day window",
12931294
"Registry SKU: Standard",
12941295
"ACR Standard tier costs ~$20/month plus storage"
12951296
],
12961297
"signals_not_checked": [
1297-
"Geo-replication pull activity in other regions",
1298-
"Planned reactivation or migration",
1299-
"Images referenced by stopped but not deleted workloads"
1298+
"Planned reactivation or migration intent",
1299+
"Images referenced by stopped or undeployed workloads",
1300+
"Failed pull or login attempts not treated as active use",
1301+
"Storage charges not included in estimated base monthly cost"
13001302
],
13011303
"time_window": "90 days"
13021304
}

docs/rules.md

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,15 @@ Every finding includes a confidence level:
6363
|---|---|---|
6464
| `azure.vm.stopped_not_deallocated` | Compute | Stopped but not deallocated VMs (full charges) |
6565
| `azure.compute.disk.unattached` | Storage | Managed disks not attached to any VM |
66-
| `azure.compute.snapshot.old` | Storage | Snapshots older than 30–90 days |
66+
| `azure.compute.snapshot.old` | Storage | Old managed snapshots as conservative review candidates |
6767
| `azure.network.public_ip.unused` | Network | Public IPs not attached to any interface |
6868
| `azure.load_balancer.no_backends` | Network | Standard LBs with zero backend members |
6969
| `azure.application_gateway.no_backends` | Network | App Gateways with zero backend targets |
7070
| `azure.virtual_network_gateway.idle` | Network | VPN/ExpressRoute Gateways with no connections |
7171
| `azure.app_service_plan.empty` | Platform | Paid App Service Plans with zero apps |
7272
| `azure.app_service.idle` | Platform | App Services with zero HTTP requests 14+ days |
7373
| `azure.sql.database.idle` | Platform | Azure SQL databases with zero connections 14+ days |
74-
| `azure.container_registry.unused` | Platform | Container registries with no pulls 90+ days |
74+
| `azure.container_registry.unused` | Platform | Container registries with zero successful pulls and pushes 90+ days |
7575
| `azure.resource.untagged` | Governance | Disks and snapshots with zero tags |
7676
| `azure.aml.compute.idle` | AI/ML | AML compute clusters with min_node_count > 0 and no active nodes 14+ days *(opt-in: `--category ai`)* |
7777
| `azure.ml.compute_instance.idle` | AI/ML | Azure ML Compute Instances Running with no control-plane activity 14+ days *(opt-in: `--category ai`)* |
@@ -1266,35 +1266,45 @@ for disk in disks.list():
12661266

12671267
**Rule ID:** `azure.compute.snapshot.old`
12681268

1269-
**What it detects:** Snapshots older than configured thresholds
1269+
**What it detects:** Old managed snapshots that meet the conservative review threshold (default: 30 days) and are surfaced as review candidates only
12701270

12711271
**Confidence:**
12721272

12731273
Confidence thresholds and signal weighting are documented in [confidence.md](confidence.md).
12741274

1275-
- **MEDIUM:** Age ≥ 30 days (conservative for all ages — age alone is a moderate signal)
1275+
- **LOW:** Age ≥ 30 days and < `max_age_days` (default 90) — conservative review candidate
1276+
- **MEDIUM:** Age ≥ `max_age_days` (default 90) — very old snapshot, higher review priority
12761277
- Not flagged: < 30 days
1278+
- `HIGH` is never used — age alone cannot establish HIGH confidence
1279+
1280+
**Configurable params:**
1281+
- `max_age_days` (default: `90`) — age threshold for the MEDIUM confidence band
12771282

12781283
**Detection logic:**
12791284
```python
12801285
for snapshot in snapshots.list():
1286+
if not snapshot.id or snapshot.provisioning_state != "Succeeded":
1287+
continue
1288+
if snapshot.completion_percent is not None and snapshot.completion_percent < 100:
1289+
continue
12811290
age_days = (now - snapshot.time_created).days
1282-
if age_days >= 90:
1283-
confidence = "MEDIUM" # conservative even at high age
1284-
elif age_days >= 30:
1285-
confidence = "MEDIUM"
1286-
else:
1287-
continue # too new to flag
1291+
if age_days < 30:
1292+
continue
1293+
confidence = "MEDIUM" if age_days >= max_age_days else "LOW"
12881294
```
12891295

1296+
**Cost model:** `estimated_monthly_cost_usd` is always `None`. Azure bills snapshots on **used size**, not `diskSizeGB`, so no per-snapshot cost estimate is possible from the API response alone.
1297+
12901298
**Limitations:**
1291-
- Does NOT check if snapshot is referenced by images
1292-
- Conservative to avoid false positives
1299+
- Age alone does not prove a snapshot is unused, orphaned, or safe to delete
1300+
- Does not check backup ownership, DR retention intent, or application restore references
1301+
- If Azure surfaces `completionPercent`, incomplete background-copy snapshots are skipped conservatively
1302+
- Conservative by design — flags review candidates only
12931303

12941304
**Common causes:**
1295-
- Snapshots from backup jobs
1305+
- Snapshots from backup jobs retained beyond their useful life
12961306
- Over-retention without lifecycle policies
1297-
- Snapshots from deleted disks
1307+
- Snapshots from deleted or migrated disks
12981308

12991309
**Required permission:** `Microsoft.Compute/snapshots/read`
13001310

@@ -1658,7 +1668,7 @@ Cost assumes one instance. Scaled-out plans (multiple instances) will cost propo
16581668

16591669
**Rule ID:** `azure.container_registry.unused`
16601670

1661-
**What it detects:** Container registries with zero image pulls for 90+ days (default, configurable)
1671+
**What it detects:** Container registries with zero **successful** pulls and pushes for 90+ days (default, configurable), after the registry is old enough to cover the full inactivity window
16621672

16631673
**Confidence:**
16641674

@@ -1676,15 +1686,19 @@ Confidence thresholds and signal weighting are documented in [confidence.md](con
16761686
**Detection logic:**
16771687
```python
16781688
for registry in registries.list():
1679-
if registry.provisioning_state == "Succeeded":
1680-
pulls = monitor.metrics("SuccessfulPullCount", period=days_unused)
1681-
pushes = monitor.metrics("SuccessfulPushCount", period=days_unused)
1682-
if pulls == 0 and pushes == 0:
1683-
confidence = "HIGH"
1684-
risk = "LOW"
1689+
if registry.provisioning_state != "Succeeded":
1690+
continue
1691+
if registry.creation_date is None or registry.creation_date > window_start:
1692+
continue
1693+
1694+
pulls = evaluate_metric("SuccessfulPullCount", interval="PT1H")
1695+
pushes = evaluate_metric("SuccessfulPushCount", interval="PT1H")
1696+
if pulls == "ZERO" and pushes == "ZERO":
1697+
confidence = "HIGH"
1698+
risk = "LOW"
16851699
```
16861700

1687-
Registries with active push activity (e.g. CI pipelines writing images) but zero pulls are **not** flagged — they are in active use.
1701+
Registries with active push activity (for example CI pipelines writing images) but zero pulls are **not** flagged. Registries with sparse, failed, or low-coverage metrics are skipped rather than emitted.
16881702

16891703
**Common causes:**
16901704
- Workloads migrated to another registry (e.g., Docker Hub → ACR → GHCR)
@@ -1696,7 +1710,9 @@ Registries with active push activity (e.g. CI pipelines writing images) but zero
16961710
- Standard: ~$20/month + storage
16971711
- Premium: ~$50/month + storage
16981712

1699-
These are floor estimates. ACR also charges per GB of stored images (~$0.003/GB-day). For registries with large image layers, storage can exceed the base fee — actual cost may be significantly higher.
1713+
Unknown or future SKU labels are still evaluated for inactivity, but `estimated_monthly_cost_usd` is left unset when the SKU is not one of `Basic`, `Standard`, or `Premium`.
1714+
1715+
These are base monthly registry fees only. Storage charges and related Azure costs are not included.
17001716

17011717
**Required permissions:**
17021718
- `Microsoft.ContainerRegistry/registries/read`

tests/cleancloud/providers/azure/test_azure_scan_graceful_degradation.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ def _http_404_rule(subscription_id, credential, region_filter=None):
5353

5454

5555
def test_permission_error_skips_azure_rule():
56-
"""PermissionError from an Azure rule goes to skipped_rules."""
56+
"""PermissionError from an Azure rule goes to skipped_rules, not rules_failed."""
5757
with patch(
5858
"cleancloud.providers.azure.scan.AZURE_RULES",
5959
[_good_rule, _permission_error_rule],
6060
):
61-
findings, skipped_rules = _scan_azure_subscription(
61+
findings, skipped_rules, rules_failed = _scan_azure_subscription(
6262
subscription_id="sub-123",
6363
subscription_name="test-sub",
6464
credential=MagicMock(),
@@ -69,6 +69,7 @@ def test_permission_error_skips_azure_rule():
6969
assert len(skipped_rules) == 1
7070
assert skipped_rules[0]["rule"] == "_permission_error_rule"
7171
assert "Microsoft.Compute/disks/read" in skipped_rules[0]["missing_permissions"]
72+
assert rules_failed == 0
7273

7374

7475
def test_http_403_skips_azure_rule():
@@ -77,7 +78,7 @@ def test_http_403_skips_azure_rule():
7778
"cleancloud.providers.azure.scan.AZURE_RULES",
7879
[_good_rule, _http_403_rule],
7980
):
80-
findings, skipped_rules = _scan_azure_subscription(
81+
findings, skipped_rules, rules_failed = _scan_azure_subscription(
8182
subscription_id="sub-123",
8283
subscription_name="test-sub",
8384
credential=MagicMock(),
@@ -88,33 +89,34 @@ def test_http_403_skips_azure_rule():
8889
assert len(skipped_rules) == 1
8990
assert skipped_rules[0]["rule"] == "_http_403_rule"
9091
assert "403" in skipped_rules[0]["missing_permissions"]
92+
assert rules_failed == 0
9193

9294

9395
def test_http_non_403_is_still_a_failure():
94-
"""HttpResponseError with non-403 status is a rule failure, not a skip."""
96+
"""HttpResponseError with non-403 status increments rules_failed, not skipped_rules."""
9597
with patch(
9698
"cleancloud.providers.azure.scan.AZURE_RULES",
9799
[_good_rule, _http_404_rule],
98100
):
99-
findings, skipped_rules = _scan_azure_subscription(
101+
findings, skipped_rules, rules_failed = _scan_azure_subscription(
100102
subscription_id="sub-123",
101103
subscription_name="test-sub",
102104
credential=MagicMock(),
103105
region_filter=None,
104106
)
105107

106108
assert len(findings) == 1
107-
# 404 is not a permission skip
108109
assert len(skipped_rules) == 0
110+
assert rules_failed == 1
109111

110112

111113
def test_all_permission_errors_no_exception():
112-
"""All Azure rules failing with PermissionError returns ([], all_skipped) without raising."""
114+
"""All Azure rules failing with PermissionError returns empty findings without raising."""
113115
with patch(
114116
"cleancloud.providers.azure.scan.AZURE_RULES",
115117
[_permission_error_rule, _http_403_rule],
116118
):
117-
findings, skipped_rules = _scan_azure_subscription(
119+
findings, skipped_rules, rules_failed = _scan_azure_subscription(
118120
subscription_id="sub-123",
119121
subscription_name="test-sub",
120122
credential=MagicMock(),
@@ -123,3 +125,4 @@ def test_all_permission_errors_no_exception():
123125

124126
assert findings == []
125127
assert len(skipped_rules) == 2
128+
assert rules_failed == 0

0 commit comments

Comments
 (0)