mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-02 01:15:31 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbf27b1e2c | ||
|
|
293a416e0d | ||
|
|
6be6f558c1 | ||
|
|
acbfdfa4b2 | ||
|
|
7bd13cdf39 | ||
|
|
34cbacc105 | ||
|
|
12f1a4f3bb | ||
|
|
923ee82830 | ||
|
|
a5e5217e60 | ||
|
|
707b3b484e | ||
|
|
348808b37f | ||
|
|
15557b9f6c | ||
|
|
a692351f93 | ||
|
|
ed97fcd059 | ||
|
|
b9060bebb0 | ||
|
|
e8ab331dff | ||
|
|
d9e451489d | ||
|
|
b00c76acf2 | ||
|
|
46e55a3351 | ||
|
|
103f71c215 | ||
|
|
310a32e391 | ||
|
|
402b33516b | ||
|
|
5e2ed6a37c | ||
|
|
8ef231faae | ||
|
|
304fa25f25 | ||
|
|
ffeaeb760e | ||
|
|
876702fd22 | ||
|
|
326def4a17 | ||
|
|
aa284b8b24 | ||
|
|
ace36fe76e | ||
|
|
3a2960fd09 | ||
|
|
6b0a139361 | ||
|
|
429f807f8c | ||
|
|
56535e5b24 | ||
|
|
4af9bc99bf | ||
|
|
0589721505 | ||
|
|
f93cbbdb38 | ||
|
|
89d2a14468 | ||
|
|
51c5fda202 | ||
|
|
bfd8218032 | ||
|
|
e4268b9893 | ||
|
|
acb88bee8b | ||
|
|
0ac647f22a | ||
|
|
e40b18eb99 | ||
|
|
43ad2f705c | ||
|
|
15c7101e0b | ||
|
|
a328f69fed | ||
|
|
b1ebefb26b | ||
|
|
9948e4fb81 | ||
|
|
c1c1f07c20 | ||
|
|
132be2c728 | ||
|
|
a3db22defc | ||
|
|
74ad1186cf | ||
|
|
cc1197d632 | ||
|
|
ea7a93193e | ||
|
|
f7adbc2dcf | ||
|
|
41e972e088 | ||
|
|
31d9a23225 |
No files matched your search
@@ -44,35 +44,6 @@ jobs:
|
||||
ui/README.md
|
||||
ui/AGENTS.md
|
||||
|
||||
- name: Get changed source files for targeted tests
|
||||
id: changed-source
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
with:
|
||||
files: |
|
||||
ui/**/*.ts
|
||||
ui/**/*.tsx
|
||||
files_ignore: |
|
||||
ui/**/*.test.ts
|
||||
ui/**/*.test.tsx
|
||||
ui/**/*.spec.ts
|
||||
ui/**/*.spec.tsx
|
||||
ui/vitest.config.ts
|
||||
ui/vitest.setup.ts
|
||||
|
||||
- name: Check for critical path changes (run all tests)
|
||||
id: critical-changes
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
|
||||
with:
|
||||
files: |
|
||||
ui/lib/**
|
||||
ui/types/**
|
||||
ui/config/**
|
||||
ui/middleware.ts
|
||||
ui/vitest.config.ts
|
||||
ui/vitest.setup.ts
|
||||
|
||||
- name: Setup Node.js ${{ env.NODE_VERSION }}
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
@@ -112,27 +83,6 @@ jobs:
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run healthcheck
|
||||
|
||||
- name: Run unit tests (all - critical paths changed)
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed == 'true'
|
||||
run: |
|
||||
echo "Critical paths changed - running ALL unit tests"
|
||||
pnpm run test:run
|
||||
|
||||
- name: Run unit tests (related to changes only)
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files != ''
|
||||
run: |
|
||||
echo "Running tests related to changed files:"
|
||||
echo "${{ steps.changed-source.outputs.all_changed_files }}"
|
||||
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
|
||||
CHANGED_FILES=$(echo "${{ steps.changed-source.outputs.all_changed_files }}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
|
||||
pnpm exec vitest related $CHANGED_FILES --run
|
||||
|
||||
- name: Run unit tests (test files only changed)
|
||||
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files == ''
|
||||
run: |
|
||||
echo "Only test files changed - running ALL unit tests"
|
||||
pnpm run test:run
|
||||
|
||||
- name: Build application
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: pnpm run build
|
||||
@@ -24,8 +24,6 @@ Use these skills for detailed patterns on-demand:
|
||||
| `zod-4` | New API (z.email(), z.uuid()) | [SKILL.md](skills/zod-4/SKILL.md) |
|
||||
| `zustand-5` | Persist, selectors, slices | [SKILL.md](skills/zustand-5/SKILL.md) |
|
||||
| `ai-sdk-5` | UIMessage, streaming, LangChain | [SKILL.md](skills/ai-sdk-5/SKILL.md) |
|
||||
| `vitest` | Unit testing, React Testing Library | [SKILL.md](skills/vitest/SKILL.md) |
|
||||
| `tdd` | Test-Driven Development workflow | [SKILL.md](skills/tdd/SKILL.md) |
|
||||
|
||||
### Prowler-Specific Skills
|
||||
| Skill | Description | URL |
|
||||
@@ -58,8 +56,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Add changelog entry for a PR or feature | `prowler-changelog` |
|
||||
| Adding DRF pagination or permissions | `django-drf` |
|
||||
| Adding new providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| Adding services to existing providers | `prowler-provider` |
|
||||
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
|
||||
| After creating/modifying a skill | `skill-sync` |
|
||||
| App Router / Server Actions | `nextjs-15` |
|
||||
| Building AI chat features | `ai-sdk-5` |
|
||||
@@ -78,38 +76,30 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating/updating compliance frameworks | `prowler-compliance` |
|
||||
| Debug why a GitHub Actions job is failing | `prowler-ci` |
|
||||
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
|
||||
| Fixing bug | `tdd` |
|
||||
| General Prowler development questions | `prowler` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Inspect PR CI checks and gates (.github/workflows/*) | `prowler-ci` |
|
||||
| Inspect PR CI workflows (.github/workflows/*): conventional-commit, pr-check-changelog, pr-conflict-checker, labeler | `prowler-pr` |
|
||||
| Mapping checks to compliance controls | `prowler-compliance` |
|
||||
| Mocking AWS with moto in tests | `prowler-test-sdk` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Reviewing compliance framework PRs | `prowler-compliance-review` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Troubleshoot why a skill is missing from AGENTS.md auto-invoke | `skill-sync` |
|
||||
| Understand CODEOWNERS/labeler-based automation | `prowler-ci` |
|
||||
| Understand PR title conventional-commit validation | `prowler-ci` |
|
||||
| Understand changelog gate and no-changelog label behavior | `prowler-ci` |
|
||||
| Understand review ownership with CODEOWNERS | `prowler-pr` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating README.md provider statistics table | `prowler-readme-table` |
|
||||
| Updating checks, services, compliance, or categories count in README.md | `prowler-readme-table` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Updating existing checks and metadata | `prowler-sdk-check` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
| Working on MCP server tools | `prowler-mcp` |
|
||||
| Working on Prowler UI structure (actions/adapters/types/hooks) | `prowler-ui` |
|
||||
| Working on task | `tdd` |
|
||||
| Working with Prowler UI test helpers/pages | `prowler-test-ui` |
|
||||
| Working with Tailwind classes | `tailwind-4` |
|
||||
| Writing Playwright E2E tests | `playwright` |
|
||||
@@ -117,12 +107,9 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Writing Prowler SDK tests | `prowler-test-sdk` |
|
||||
| Writing Prowler UI E2E tests | `prowler-test-ui` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
| Writing React component tests | `vitest` |
|
||||
| Writing React components | `react-19` |
|
||||
| Writing TypeScript types/interfaces | `typescript` |
|
||||
| Writing Vitest tests | `vitest` |
|
||||
| Writing documentation | `prowler-docs` |
|
||||
| Writing unit tests for UI | `vitest` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,18 +24,13 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Creating ViewSets, serializers, or filters in api/ | `django-drf` |
|
||||
| Creating a git commit | `prowler-commit` |
|
||||
| Creating/modifying models, views, serializers | `prowler-api` |
|
||||
| Fixing bug | `tdd` |
|
||||
| Implementing JSON:API endpoints | `django-drf` |
|
||||
| Implementing feature | `tdd` |
|
||||
| Modifying API responses | `jsonapi` |
|
||||
| Modifying component | `tdd` |
|
||||
| Refactoring code | `tdd` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Testing RLS tenant isolation | `prowler-test-api` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Updating existing Attack Paths queries | `prowler-attack-paths-query` |
|
||||
| Working on task | `tdd` |
|
||||
| Writing Prowler API tests | `prowler-test-api` |
|
||||
| Writing Python tests with pytest | `pytest` |
|
||||
|
||||
|
||||
+3
-12
@@ -7,7 +7,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
### 🚀 Added
|
||||
|
||||
- OpenStack provider support [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003)
|
||||
- PDF report for the CSA CCM compliance framework [(#10088)](https://github.com/prowler-cloud/prowler/pull/10088)
|
||||
- Image provider support with registry credential handling, UID validation, and connection testing
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -21,12 +21,11 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Support CSA CCM 4.0 for the Alibaba Cloud provider [(#10061)](https://github.com/prowler-cloud/prowler/pull/10061)
|
||||
- Attack Paths: Mark attack Paths scan as failed when Celery task fails outside job error handling [(#10065)](https://github.com/prowler-cloud/prowler/pull/10065)
|
||||
- Attack Paths: Remove legacy per-scan `graph_database` and `is_graph_database_deleted` fields from AttackPathsScan model [(#10077)](https://github.com/prowler-cloud/prowler/pull/10077)
|
||||
- Attack Paths: Add `graph_data_ready` field to decouple query availability from scan state [(#10089)](https://github.com/prowler-cloud/prowler/pull/10089)
|
||||
- AI agent guidelines with TDD and testing skills references [(#9925)](https://github.com/prowler-cloud/prowler/pull/9925)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: Orphaned temporary Neo4j databases are now cleaned up on scan failure and provider deletion [(#10101)](https://github.com/prowler-cloud/prowler/pull/10101)
|
||||
- Skip compliance and attack surface tasks for Image and IaC providers
|
||||
- Normalize Image provider UID by stripping protocol prefix on save
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
@@ -34,14 +33,6 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
---
|
||||
|
||||
## [1.19.3] (Prowler UNRELEASED)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- GCP provider UID validation regex to allow domain prefixes [(#10078)](https://github.com/prowler-cloud/prowler/pull/10078)
|
||||
|
||||
---
|
||||
|
||||
## [1.19.2] (Prowler v5.18.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
|
||||
|
||||
ARG TRIVY_VERSION=0.66.0
|
||||
ARG TRIVY_VERSION=0.69.1
|
||||
ENV TRIVY_VERSION=${TRIVY_VERSION}
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
|
||||
Generated
+366
-12
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -24,7 +24,7 @@ dependencies = [
|
||||
"drf-spectacular-jsonapi==0.5.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@feat/PROWLER-940-stage-2-a-image-provider-api-support",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
|
||||
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"provider": "b85601a8-4b45-4194-8135-03fb980ef428",
|
||||
"scan": "01920573-aa9c-73c9-bcda-f2e35c9b19d2",
|
||||
"state": "completed",
|
||||
"graph_data_ready": true,
|
||||
"progress": 100,
|
||||
"update_tag": 1693586667,
|
||||
"task": null,
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# Generated by Django 5.1.15 on 2026-02-16 13:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0078_remove_attackpathsscan_graph_database_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="attackpathsscan",
|
||||
name="graph_data_ready",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django migration for Image provider support
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
import api.db_utils
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0078_remove_attackpathsscan_graph_database_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="provider",
|
||||
name="provider",
|
||||
field=api.db_utils.ProviderEnumField(
|
||||
choices=[
|
||||
("aws", "AWS"),
|
||||
("azure", "Azure"),
|
||||
("gcp", "GCP"),
|
||||
("kubernetes", "Kubernetes"),
|
||||
("m365", "M365"),
|
||||
("github", "GitHub"),
|
||||
("mongodbatlas", "MongoDB Atlas"),
|
||||
("iac", "IaC"),
|
||||
("oraclecloud", "Oracle Cloud Infrastructure"),
|
||||
("alibabacloud", "Alibaba Cloud"),
|
||||
("cloudflare", "Cloudflare"),
|
||||
("openstack", "OpenStack"),
|
||||
("image", "Image"),
|
||||
],
|
||||
default="aws",
|
||||
),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
"ALTER TYPE provider ADD VALUE IF NOT EXISTS 'image';",
|
||||
reverse_sql=migrations.RunSQL.noop,
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
# Separate from 0079 because psqlextra's schema editor runs AddField DDL and DML
|
||||
# on different database connections, causing a deadlock when combined with RunPython
|
||||
# in the same migration.
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from api.db_router import MainRouter
|
||||
|
||||
|
||||
def backfill_graph_data_ready(apps, schema_editor):
|
||||
"""Set graph_data_ready=True for all completed AttackPathsScan rows."""
|
||||
AttackPathsScan = apps.get_model("api", "AttackPathsScan")
|
||||
AttackPathsScan.objects.using(MainRouter.admin_db).filter(
|
||||
state="completed",
|
||||
graph_data_ready=False,
|
||||
).update(graph_data_ready=True)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("api", "0079_attackpathsscan_graph_data_ready"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(backfill_graph_data_ready, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -289,6 +289,7 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
ALIBABACLOUD = "alibabacloud", _("Alibaba Cloud")
|
||||
CLOUDFLARE = "cloudflare", _("Cloudflare")
|
||||
OPENSTACK = "openstack", _("OpenStack")
|
||||
IMAGE = "image", _("Image")
|
||||
|
||||
@staticmethod
|
||||
def validate_aws_uid(value):
|
||||
@@ -327,13 +328,10 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
|
||||
@staticmethod
|
||||
def validate_gcp_uid(value):
|
||||
# Standard format: 6-30 chars, starts with letter, lowercase + digits + hyphens
|
||||
# Legacy App Engine format: domain.com:project-id
|
||||
if not re.match(r"^([a-z][a-z0-9.-]*:)?[a-z][a-z0-9-]{5,29}$", value):
|
||||
if not re.match(r"^[a-z][a-z0-9-]{5,29}$", value):
|
||||
raise ModelValidationError(
|
||||
detail="GCP provider ID must be a valid project ID: 6 to 30 characters, start with a letter, "
|
||||
"and contain only lowercase letters, numbers, and hyphens. "
|
||||
"Legacy App Engine project IDs with a domain prefix (e.g., example.com:my-project) are also accepted.",
|
||||
detail="GCP provider ID must be 6 to 30 characters, start with a letter, and contain only lowercase "
|
||||
"letters, numbers, and hyphens.",
|
||||
code="gcp-uid",
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
@@ -423,6 +421,26 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate_image_uid(value):
|
||||
pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:\d{1,5})?(/[a-zA-Z0-9._-]+)*/?$"
|
||||
if not re.match(pattern, value):
|
||||
raise ModelValidationError(
|
||||
detail="Image provider ID must be a valid registry URL "
|
||||
"(e.g., docker.io, ghcr.io, registry.example.com:5000).",
|
||||
code="image-uid",
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
port_match = re.search(r":(\d{1,5})(?=/|$)", value)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
if not 1 <= port <= 65535:
|
||||
raise ModelValidationError(
|
||||
detail="Port number must be between 1 and 65535.",
|
||||
code="image-uid",
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
updated_at = models.DateTimeField(auto_now=True, editable=False)
|
||||
@@ -446,6 +464,8 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.provider == self.ProviderChoices.IMAGE.value and self.uid:
|
||||
self.uid = re.sub(r"^https?://", "", self.uid)
|
||||
getattr(self, f"validate_{self.provider}_uid")(self.uid)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -658,7 +678,6 @@ class AttackPathsScan(RowLevelSecurityProtectedModel):
|
||||
|
||||
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
|
||||
progress = models.IntegerField(default=0)
|
||||
graph_data_ready = models.BooleanField(default=False)
|
||||
|
||||
# Timing
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
+165
-118
@@ -296,7 +296,6 @@ paths:
|
||||
enum:
|
||||
- state
|
||||
- progress
|
||||
- graph_data_ready
|
||||
- provider
|
||||
- provider_alias
|
||||
- provider_type
|
||||
@@ -356,7 +355,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -389,7 +388,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -572,7 +571,6 @@ paths:
|
||||
enum:
|
||||
- state
|
||||
- progress
|
||||
- graph_data_ready
|
||||
- provider
|
||||
- provider_alias
|
||||
- provider_type
|
||||
@@ -633,7 +631,6 @@ paths:
|
||||
enum:
|
||||
- state
|
||||
- progress
|
||||
- graph_data_ready
|
||||
- provider
|
||||
- provider_alias
|
||||
- provider_type
|
||||
@@ -1343,7 +1340,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -1376,7 +1373,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -1937,7 +1934,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -1970,7 +1967,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -2439,7 +2436,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -2472,7 +2469,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -2939,7 +2936,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -2972,7 +2969,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -3427,7 +3424,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -3460,7 +3457,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5256,7 +5253,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5289,7 +5286,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5423,7 +5420,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5456,7 +5453,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5766,7 +5763,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5799,7 +5796,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -5967,7 +5964,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6000,7 +5997,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6398,7 +6395,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6431,7 +6428,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6564,7 +6561,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6597,7 +6594,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6754,7 +6751,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -6787,7 +6784,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -7585,7 +7582,7 @@ paths:
|
||||
name: filter[provider]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -7618,7 +7615,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -7653,7 +7650,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -7686,7 +7683,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -8344,7 +8341,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -8377,7 +8374,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -8850,7 +8847,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -8883,7 +8880,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -9169,7 +9166,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -9202,7 +9199,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -9494,7 +9491,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -9527,7 +9524,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -10353,7 +10350,7 @@ paths:
|
||||
name: filter[provider_type]
|
||||
schema:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -10386,7 +10383,7 @@ paths:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
enum:
|
||||
- alibabacloud
|
||||
- aws
|
||||
@@ -10750,72 +10747,6 @@ paths:
|
||||
description: CSV file containing the compliance report
|
||||
'404':
|
||||
description: Compliance report not found
|
||||
/api/v1/scans/{id}/csa:
|
||||
get:
|
||||
operationId: scans_csa_retrieve
|
||||
description: Download CSA Cloud Controls Matrix (CCM) v4.0 compliance report
|
||||
as a PDF file.
|
||||
summary: Retrieve CSA CCM compliance report
|
||||
parameters:
|
||||
- in: query
|
||||
name: fields[scans]
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- name
|
||||
- trigger
|
||||
- state
|
||||
- unique_resource_count
|
||||
- progress
|
||||
- duration
|
||||
- provider
|
||||
- task
|
||||
- inserted_at
|
||||
- started_at
|
||||
- completed_at
|
||||
- scheduled_at
|
||||
- next_scan_at
|
||||
- processor
|
||||
- url
|
||||
description: endpoint return only specific fields in the response on a per-type
|
||||
basis by including a fields[TYPE] query parameter.
|
||||
explode: false
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
description: A UUID string identifying this scan.
|
||||
required: true
|
||||
- in: query
|
||||
name: include
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- provider
|
||||
description: include query parameter to allow the client to customize which
|
||||
related resources should be returned.
|
||||
explode: false
|
||||
tags:
|
||||
- Scan
|
||||
security:
|
||||
- JWT or API Key: []
|
||||
responses:
|
||||
'200':
|
||||
description: PDF file containing the CSA CCM compliance report
|
||||
'202':
|
||||
description: The task is in progress
|
||||
'401':
|
||||
description: API key missing or user not Authenticated
|
||||
'403':
|
||||
description: There is a problem with credentials
|
||||
'404':
|
||||
description: The scan has no CSA CCM reports, or the CSA CCM report generation
|
||||
task has not started yet
|
||||
/api/v1/scans/{id}/ens:
|
||||
get:
|
||||
operationId: scans_ens_retrieve
|
||||
@@ -12599,16 +12530,16 @@ components:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
attribution:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AttackPathsQueryAttribution'
|
||||
nullable: true
|
||||
provider:
|
||||
type: string
|
||||
parameters:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AttackPathsQueryParameter'
|
||||
attribution:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/AttackPathsQueryAttribution'
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -12806,8 +12737,6 @@ components:
|
||||
type: integer
|
||||
maximum: 2147483647
|
||||
minimum: -2147483648
|
||||
graph_data_ready:
|
||||
type: boolean
|
||||
provider_alias:
|
||||
type: string
|
||||
readOnly: true
|
||||
@@ -17521,6 +17450,36 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: OpenStack Explicit Credentials
|
||||
properties:
|
||||
auth_url:
|
||||
type: string
|
||||
description: OpenStack Keystone authentication URL (e.g.,
|
||||
https://openstack.example.com:5000/v3).
|
||||
username:
|
||||
type: string
|
||||
description: OpenStack username for authentication.
|
||||
password:
|
||||
type: string
|
||||
description: OpenStack password for authentication.
|
||||
region_name:
|
||||
type: string
|
||||
description: OpenStack region name (e.g., RegionOne).
|
||||
identity_api_version:
|
||||
type: string
|
||||
description: Keystone API version (default: 3).
|
||||
user_domain_name:
|
||||
type: string
|
||||
description: User domain name (default: Default).
|
||||
project_domain_name:
|
||||
type: string
|
||||
description: Project domain name (default: Default).
|
||||
required:
|
||||
- auth_url
|
||||
- username
|
||||
- password
|
||||
- region_name
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret
|
||||
@@ -18535,7 +18494,7 @@ components:
|
||||
* `alibabacloud` - Alibaba Cloud
|
||||
* `cloudflare` - Cloudflare
|
||||
* `openstack` - OpenStack
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
uid:
|
||||
type: string
|
||||
title: Unique identifier for the provider, set by the provider
|
||||
@@ -18654,7 +18613,7 @@ components:
|
||||
- cloudflare
|
||||
- openstack
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
description: |-
|
||||
Type of provider to create.
|
||||
|
||||
@@ -18720,7 +18679,7 @@ components:
|
||||
- cloudflare
|
||||
- openstack
|
||||
type: string
|
||||
x-spec-enum-id: 4b8815b179aa7216
|
||||
x-spec-enum-id: 2d8d323e9cc0044b
|
||||
description: |-
|
||||
Type of provider to create.
|
||||
|
||||
@@ -19570,6 +19529,35 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: OpenStack Explicit Credentials
|
||||
properties:
|
||||
auth_url:
|
||||
type: string
|
||||
description: OpenStack Keystone authentication URL (e.g., https://openstack.example.com:5000/v3).
|
||||
username:
|
||||
type: string
|
||||
description: OpenStack username for authentication.
|
||||
password:
|
||||
type: string
|
||||
description: OpenStack password for authentication.
|
||||
region_name:
|
||||
type: string
|
||||
description: OpenStack region name (e.g., RegionOne).
|
||||
identity_api_version:
|
||||
type: string
|
||||
description: Keystone API version (default: 3).
|
||||
user_domain_name:
|
||||
type: string
|
||||
description: User domain name (default: Default).
|
||||
project_domain_name:
|
||||
type: string
|
||||
description: Project domain name (default: Default).
|
||||
required:
|
||||
- auth_url
|
||||
- username
|
||||
- password
|
||||
- region_name
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret_type
|
||||
@@ -19970,6 +19958,36 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: OpenStack Explicit Credentials
|
||||
properties:
|
||||
auth_url:
|
||||
type: string
|
||||
description: OpenStack Keystone authentication URL (e.g.,
|
||||
https://openstack.example.com:5000/v3).
|
||||
username:
|
||||
type: string
|
||||
description: OpenStack username for authentication.
|
||||
password:
|
||||
type: string
|
||||
description: OpenStack password for authentication.
|
||||
region_name:
|
||||
type: string
|
||||
description: OpenStack region name (e.g., RegionOne).
|
||||
identity_api_version:
|
||||
type: string
|
||||
description: Keystone API version (default: 3).
|
||||
user_domain_name:
|
||||
type: string
|
||||
description: User domain name (default: Default).
|
||||
project_domain_name:
|
||||
type: string
|
||||
description: Project domain name (default: Default).
|
||||
required:
|
||||
- auth_url
|
||||
- username
|
||||
- password
|
||||
- region_name
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret_type
|
||||
@@ -20381,6 +20399,35 @@ components:
|
||||
required:
|
||||
- clouds_yaml_content
|
||||
- clouds_yaml_cloud
|
||||
- type: object
|
||||
title: OpenStack Explicit Credentials
|
||||
properties:
|
||||
auth_url:
|
||||
type: string
|
||||
description: OpenStack Keystone authentication URL (e.g., https://openstack.example.com:5000/v3).
|
||||
username:
|
||||
type: string
|
||||
description: OpenStack username for authentication.
|
||||
password:
|
||||
type: string
|
||||
description: OpenStack password for authentication.
|
||||
region_name:
|
||||
type: string
|
||||
description: OpenStack region name (e.g., RegionOne).
|
||||
identity_api_version:
|
||||
type: string
|
||||
description: Keystone API version (default: 3).
|
||||
user_domain_name:
|
||||
type: string
|
||||
description: User domain name (default: Default).
|
||||
project_domain_name:
|
||||
type: string
|
||||
description: Project domain name (default: Default).
|
||||
required:
|
||||
- auth_url
|
||||
- username
|
||||
- password
|
||||
- region_name
|
||||
writeOnly: true
|
||||
required:
|
||||
- secret
|
||||
|
||||
@@ -24,6 +24,7 @@ from prowler.providers.cloudflare.cloudflare_provider import CloudflareProvider
|
||||
from prowler.providers.gcp.gcp_provider import GcpProvider
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
from prowler.providers.iac.iac_provider import IacProvider
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
|
||||
from prowler.providers.m365.m365_provider import M365Provider
|
||||
from prowler.providers.mongodbatlas.mongodbatlas_provider import MongodbatlasProvider
|
||||
@@ -122,6 +123,7 @@ class TestReturnProwlerProvider:
|
||||
(Provider.ProviderChoices.ALIBABACLOUD.value, AlibabacloudProvider),
|
||||
(Provider.ProviderChoices.CLOUDFLARE.value, CloudflareProvider),
|
||||
(Provider.ProviderChoices.OPENSTACK.value, OpenstackProvider),
|
||||
(Provider.ProviderChoices.IMAGE.value, ImageProvider),
|
||||
],
|
||||
)
|
||||
def test_return_prowler_provider(self, provider_type, expected_provider):
|
||||
@@ -188,6 +190,54 @@ class TestProwlerProviderConnectionTest:
|
||||
assert isinstance(connection.error, Provider.secret.RelatedObjectDoesNotExist)
|
||||
assert str(connection.error) == "Provider has no secret."
|
||||
|
||||
@patch(
|
||||
"prowler.providers.image.lib.registry.factory.create_registry_adapter",
|
||||
)
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_image_success(
|
||||
self, mock_return_prowler_provider, mock_create_adapter
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.IMAGE.value
|
||||
provider.uid = "ghcr.io"
|
||||
provider.secret.secret = {"registry_token": "tok"}
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_create_adapter.return_value = mock_adapter
|
||||
|
||||
connection = prowler_provider_connection_test(provider)
|
||||
|
||||
assert connection.is_connected is True
|
||||
assert connection.error is None
|
||||
mock_create_adapter.assert_called_once_with(
|
||||
registry_url="ghcr.io",
|
||||
username=None,
|
||||
password=None,
|
||||
token="tok",
|
||||
)
|
||||
mock_adapter.list_repositories.assert_called_once()
|
||||
|
||||
@patch(
|
||||
"prowler.providers.image.lib.registry.factory.create_registry_adapter",
|
||||
)
|
||||
@patch("api.utils.return_prowler_provider")
|
||||
def test_prowler_provider_connection_test_image_failure(
|
||||
self, mock_return_prowler_provider, mock_create_adapter
|
||||
):
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.IMAGE.value
|
||||
provider.uid = "ghcr.io"
|
||||
provider.secret.secret = {"registry_token": "bad-token"}
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.list_repositories.side_effect = Exception("401 Unauthorized")
|
||||
mock_create_adapter.return_value = mock_adapter
|
||||
|
||||
connection = prowler_provider_connection_test(provider)
|
||||
|
||||
assert connection.is_connected is False
|
||||
assert connection.error == "401 Unauthorized"
|
||||
|
||||
|
||||
class TestGetProwlerProviderKwargs:
|
||||
@pytest.mark.parametrize(
|
||||
@@ -336,6 +386,103 @@ class TestGetProwlerProviderKwargs:
|
||||
}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_image_provider(self):
|
||||
"""Test that Image provider gets correct kwargs with registry URL and auth."""
|
||||
provider_uid = "ghcr.io"
|
||||
secret_dict = {
|
||||
"registry_username": "user",
|
||||
"registry_password": "pass",
|
||||
}
|
||||
secret_mock = MagicMock()
|
||||
secret_mock.secret = secret_dict
|
||||
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.IMAGE.value
|
||||
provider.secret = secret_mock
|
||||
provider.uid = provider_uid
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {
|
||||
"registry": provider_uid,
|
||||
"registry_username": "user",
|
||||
"registry_password": "pass",
|
||||
}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_image_provider_with_filters(self):
|
||||
"""Test that Image provider includes scan filters."""
|
||||
provider_uid = "docker.io"
|
||||
secret_dict = {
|
||||
"registry_token": "ghp_abc123",
|
||||
"image_filter": "my-app.*",
|
||||
"tag_filter": "v[0-9]+",
|
||||
"max_images": 50,
|
||||
}
|
||||
secret_mock = MagicMock()
|
||||
secret_mock.secret = secret_dict
|
||||
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.IMAGE.value
|
||||
provider.secret = secret_mock
|
||||
provider.uid = provider_uid
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {
|
||||
"registry": provider_uid,
|
||||
"registry_token": "ghp_abc123",
|
||||
"image_filter": "my-app.*",
|
||||
"tag_filter": "v[0-9]+",
|
||||
"max_images": 50,
|
||||
}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_image_provider_no_auth(self):
|
||||
"""Test that Image provider works with empty secret for public registries."""
|
||||
provider_uid = "docker.io"
|
||||
secret_dict = {}
|
||||
secret_mock = MagicMock()
|
||||
secret_mock.secret = secret_dict
|
||||
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.IMAGE.value
|
||||
provider.secret = secret_mock
|
||||
provider.uid = provider_uid
|
||||
|
||||
result = get_prowler_provider_kwargs(provider)
|
||||
|
||||
expected_result = {"registry": provider_uid}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_image_provider_ignores_mutelist(self):
|
||||
"""Test that Image provider does NOT receive mutelist_content.
|
||||
|
||||
Image provider uses Trivy's built-in logic, so it should not
|
||||
receive mutelist_content even when a mutelist processor is configured.
|
||||
"""
|
||||
provider_uid = "ghcr.io"
|
||||
secret_dict = {"registry_token": "test_token"}
|
||||
secret_mock = MagicMock()
|
||||
secret_mock.secret = secret_dict
|
||||
|
||||
mutelist_processor = MagicMock()
|
||||
mutelist_processor.configuration = {"Mutelist": {"key": "value"}}
|
||||
|
||||
provider = MagicMock()
|
||||
provider.provider = Provider.ProviderChoices.IMAGE.value
|
||||
provider.secret = secret_mock
|
||||
provider.uid = provider_uid
|
||||
|
||||
result = get_prowler_provider_kwargs(provider, mutelist_processor)
|
||||
|
||||
assert "mutelist_content" not in result
|
||||
expected_result = {
|
||||
"registry": provider_uid,
|
||||
"registry_token": "test_token",
|
||||
}
|
||||
assert result == expected_result
|
||||
|
||||
def test_get_prowler_provider_kwargs_unsupported_provider(self):
|
||||
# Setup
|
||||
provider_uid = "provider_uid"
|
||||
|
||||
@@ -1079,11 +1079,6 @@ class TestProviderViewSet:
|
||||
[
|
||||
{"provider": "aws", "uid": "111111111111", "alias": "test"},
|
||||
{"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"uid": "example.com:my-project-123456",
|
||||
"alias": "legacy-gcp",
|
||||
},
|
||||
{
|
||||
"provider": "kubernetes",
|
||||
"uid": "kubernetes-test-123456789",
|
||||
@@ -1189,6 +1184,26 @@ class TestProviderViewSet:
|
||||
"uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"alias": "OpenStack Project",
|
||||
},
|
||||
{
|
||||
"provider": "image",
|
||||
"uid": "ghcr.io",
|
||||
"alias": "GitHub Registry",
|
||||
},
|
||||
{
|
||||
"provider": "image",
|
||||
"uid": "docker.io",
|
||||
"alias": "Docker Hub",
|
||||
},
|
||||
{
|
||||
"provider": "image",
|
||||
"uid": "123456789012.dkr.ecr.us-east-1.amazonaws.com",
|
||||
"alias": "ECR",
|
||||
},
|
||||
{
|
||||
"provider": "image",
|
||||
"uid": "https://registry.example.com:5000",
|
||||
"alias": "Private",
|
||||
},
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1208,11 +1223,6 @@ class TestProviderViewSet:
|
||||
[
|
||||
{"provider": "aws", "uid": "111111111111", "alias": "test"},
|
||||
{"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"uid": "example.com:my-project-123456",
|
||||
"alias": "legacy-gcp",
|
||||
},
|
||||
{
|
||||
"provider": "kubernetes",
|
||||
"uid": "kubernetes-test-123456789",
|
||||
@@ -1633,6 +1643,26 @@ class TestProviderViewSet:
|
||||
"min_length",
|
||||
"uid",
|
||||
),
|
||||
# Image UID validation - too short (below min_length)
|
||||
(
|
||||
{
|
||||
"provider": "image",
|
||||
"uid": "ab",
|
||||
"alias": "test",
|
||||
},
|
||||
"min_length",
|
||||
"uid",
|
||||
),
|
||||
# Image UID validation - invalid characters (space)
|
||||
(
|
||||
{
|
||||
"provider": "image",
|
||||
"uid": "not valid!",
|
||||
"alias": "test",
|
||||
},
|
||||
"image-uid",
|
||||
"uid",
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
@@ -1788,6 +1818,38 @@ class TestProviderViewSet:
|
||||
assert "Content-Location" in response.headers
|
||||
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.check_provider_connection_task.delay")
|
||||
def test_providers_connection_image(
|
||||
self,
|
||||
mock_provider_connection,
|
||||
mock_task_get,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
tasks_fixture,
|
||||
):
|
||||
prowler_task = tasks_fixture[0]
|
||||
task_mock = Mock()
|
||||
task_mock.id = prowler_task.id
|
||||
task_mock.status = "PENDING"
|
||||
mock_provider_connection.return_value = task_mock
|
||||
mock_task_get.return_value = prowler_task
|
||||
|
||||
image_provider = providers_fixture[11]
|
||||
assert image_provider.provider == "image"
|
||||
assert image_provider.connected is None
|
||||
assert image_provider.connection_last_checked_at is None
|
||||
|
||||
response = authenticated_client.post(
|
||||
reverse("provider-connection", kwargs={"pk": image_provider.id})
|
||||
)
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
mock_provider_connection.assert_called_once_with(
|
||||
provider_id=str(image_provider.id), tenant_id=ANY
|
||||
)
|
||||
assert "Content-Location" in response.headers
|
||||
assert response.headers["Content-Location"] == f"/api/v1/tasks/{task_mock.id}"
|
||||
|
||||
def test_providers_connection_invalid_provider(
|
||||
self, authenticated_client, providers_fixture
|
||||
):
|
||||
@@ -1810,17 +1872,17 @@ class TestProviderViewSet:
|
||||
),
|
||||
("alias", "aws_testing_1", 1),
|
||||
("alias.icontains", "aws", 2),
|
||||
("inserted_at", TODAY, 11),
|
||||
("inserted_at", TODAY, 12),
|
||||
(
|
||||
"inserted_at.gte",
|
||||
"2024-01-01",
|
||||
11,
|
||||
12,
|
||||
),
|
||||
("inserted_at.lte", "2024-01-01", 0),
|
||||
(
|
||||
"updated_at.gte",
|
||||
"2024-01-01",
|
||||
11,
|
||||
12,
|
||||
),
|
||||
("updated_at.lte", "2024-01-01", 0),
|
||||
]
|
||||
@@ -2436,6 +2498,39 @@ class TestProviderSecretViewSet:
|
||||
"clouds_yaml_cloud": "mycloud",
|
||||
},
|
||||
),
|
||||
# Image with Docker login
|
||||
(
|
||||
Provider.ProviderChoices.IMAGE.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"registry_username": "user",
|
||||
"registry_password": "pass",
|
||||
},
|
||||
),
|
||||
# Image with token
|
||||
(
|
||||
Provider.ProviderChoices.IMAGE.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"registry_token": "ghp_abc123",
|
||||
},
|
||||
),
|
||||
# Image with no auth + filters
|
||||
(
|
||||
Provider.ProviderChoices.IMAGE.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"image_filter": "my-app.*",
|
||||
"tag_filter": "v[0-9]+",
|
||||
"max_images": 50,
|
||||
},
|
||||
),
|
||||
# Image with empty secret (public registry)
|
||||
(
|
||||
Provider.ProviderChoices.IMAGE.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_provider_secrets_create_valid(
|
||||
@@ -3932,7 +4027,6 @@ class TestAttackPathsScanViewSet:
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
provider,
|
||||
scan=scans_fixture[0],
|
||||
graph_data_ready=True,
|
||||
)
|
||||
query_definition = AttackPathsQueryDefinition(
|
||||
id="aws-rds",
|
||||
@@ -4011,7 +4105,7 @@ class TestAttackPathsScanViewSet:
|
||||
assert attributes["nodes"] == graph_payload["nodes"]
|
||||
assert attributes["relationships"] == graph_payload["relationships"]
|
||||
|
||||
def test_run_attack_paths_query_blocks_when_graph_data_not_ready(
|
||||
def test_run_attack_paths_query_requires_completed_scan(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
@@ -4023,7 +4117,6 @@ class TestAttackPathsScanViewSet:
|
||||
provider,
|
||||
scan=scans_fixture[0],
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=False,
|
||||
)
|
||||
|
||||
response = authenticated_client.post(
|
||||
@@ -4035,113 +4128,7 @@ class TestAttackPathsScanViewSet:
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "not available" in response.json()["errors"][0]["detail"]
|
||||
|
||||
def test_run_attack_paths_query_allows_executing_scan_when_graph_data_ready(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
scans_fixture,
|
||||
create_attack_paths_scan,
|
||||
):
|
||||
provider = providers_fixture[0]
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
provider,
|
||||
scan=scans_fixture[0],
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
query_definition = AttackPathsQueryDefinition(
|
||||
id="aws-test",
|
||||
name="Test",
|
||||
short_description="Test query.",
|
||||
description="Test query",
|
||||
provider=provider.provider,
|
||||
cypher="MATCH (n) RETURN n",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.v1.views.get_query_by_id", return_value=query_definition),
|
||||
patch(
|
||||
"api.v1.views.attack_paths_views_helpers.prepare_query_parameters",
|
||||
return_value={"provider_uid": provider.uid},
|
||||
),
|
||||
patch(
|
||||
"api.v1.views.attack_paths_views_helpers.execute_attack_paths_query",
|
||||
return_value={
|
||||
"nodes": [{"id": "n1", "labels": ["AWSAccount"], "properties": {}}],
|
||||
"relationships": [],
|
||||
},
|
||||
),
|
||||
patch("api.v1.views.graph_database.clear_cache"),
|
||||
patch(
|
||||
"api.v1.views.graph_database.get_database_name", return_value="db-test"
|
||||
),
|
||||
):
|
||||
response = authenticated_client.post(
|
||||
reverse(
|
||||
"attack-paths-scans-queries-run",
|
||||
kwargs={"pk": attack_paths_scan.id},
|
||||
),
|
||||
data=self._run_payload("aws-test"),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
def test_run_attack_paths_query_allows_failed_scan_when_graph_data_ready(
|
||||
self,
|
||||
authenticated_client,
|
||||
providers_fixture,
|
||||
scans_fixture,
|
||||
create_attack_paths_scan,
|
||||
):
|
||||
provider = providers_fixture[0]
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
provider,
|
||||
scan=scans_fixture[0],
|
||||
state=StateChoices.FAILED,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
query_definition = AttackPathsQueryDefinition(
|
||||
id="aws-test",
|
||||
name="Test",
|
||||
short_description="Test query.",
|
||||
description="Test query",
|
||||
provider=provider.provider,
|
||||
cypher="MATCH (n) RETURN n",
|
||||
parameters=[],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.v1.views.get_query_by_id", return_value=query_definition),
|
||||
patch(
|
||||
"api.v1.views.attack_paths_views_helpers.prepare_query_parameters",
|
||||
return_value={"provider_uid": provider.uid},
|
||||
),
|
||||
patch(
|
||||
"api.v1.views.attack_paths_views_helpers.execute_attack_paths_query",
|
||||
return_value={
|
||||
"nodes": [{"id": "n1", "labels": ["AWSAccount"], "properties": {}}],
|
||||
"relationships": [],
|
||||
},
|
||||
),
|
||||
patch("api.v1.views.graph_database.clear_cache"),
|
||||
patch(
|
||||
"api.v1.views.graph_database.get_database_name", return_value="db-test"
|
||||
),
|
||||
):
|
||||
response = authenticated_client.post(
|
||||
reverse(
|
||||
"attack-paths-scans-queries-run",
|
||||
kwargs={"pk": attack_paths_scan.id},
|
||||
),
|
||||
data=self._run_payload("aws-test"),
|
||||
content_type=API_JSON_CONTENT_TYPE,
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert "must be completed" in response.json()["errors"][0]["detail"]
|
||||
|
||||
def test_run_attack_paths_query_unknown_query(
|
||||
self,
|
||||
@@ -4154,7 +4141,6 @@ class TestAttackPathsScanViewSet:
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
provider,
|
||||
scan=scans_fixture[0],
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
with patch("api.v1.views.get_query_by_id", return_value=None):
|
||||
@@ -4181,7 +4167,6 @@ class TestAttackPathsScanViewSet:
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
provider,
|
||||
scan=scans_fixture[0],
|
||||
graph_data_ready=True,
|
||||
)
|
||||
query_definition = AttackPathsQueryDefinition(
|
||||
id="aws-empty",
|
||||
|
||||
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
||||
from prowler.providers.gcp.gcp_provider import GcpProvider
|
||||
from prowler.providers.github.github_provider import GithubProvider
|
||||
from prowler.providers.iac.iac_provider import IacProvider
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
from prowler.providers.kubernetes.kubernetes_provider import KubernetesProvider
|
||||
from prowler.providers.m365.m365_provider import M365Provider
|
||||
from prowler.providers.mongodbatlas.mongodbatlas_provider import (
|
||||
@@ -83,6 +84,7 @@ def return_prowler_provider(
|
||||
| GcpProvider
|
||||
| GithubProvider
|
||||
| IacProvider
|
||||
| ImageProvider
|
||||
| KubernetesProvider
|
||||
| M365Provider
|
||||
| MongodbatlasProvider
|
||||
@@ -95,7 +97,7 @@ def return_prowler_provider(
|
||||
provider (Provider): The provider object containing the provider type and associated secrets.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class.
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: The corresponding provider class.
|
||||
|
||||
Raises:
|
||||
ValueError: If the provider type specified in `provider.provider` is not supported.
|
||||
@@ -159,6 +161,10 @@ def return_prowler_provider(
|
||||
from prowler.providers.openstack.openstack_provider import OpenstackProvider
|
||||
|
||||
prowler_provider = OpenstackProvider
|
||||
case Provider.ProviderChoices.IMAGE.value:
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
|
||||
prowler_provider = ImageProvider
|
||||
case _:
|
||||
raise ValueError(f"Provider type {provider.provider} not supported")
|
||||
return prowler_provider
|
||||
@@ -221,11 +227,30 @@ def get_prowler_provider_kwargs(
|
||||
# clouds.yaml is not feasible because not all auth methods include it and the
|
||||
# Keystone API is unavailable on public clouds.
|
||||
pass
|
||||
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
|
||||
prowler_provider_kwargs = {
|
||||
"registry": provider.uid,
|
||||
}
|
||||
secret = provider.secret.secret
|
||||
for key in (
|
||||
"registry_username",
|
||||
"registry_password",
|
||||
"registry_token",
|
||||
"image_filter",
|
||||
"tag_filter",
|
||||
):
|
||||
if key in secret:
|
||||
prowler_provider_kwargs[key] = secret[key]
|
||||
if "max_images" in secret:
|
||||
prowler_provider_kwargs["max_images"] = int(secret["max_images"])
|
||||
|
||||
if mutelist_processor:
|
||||
mutelist_content = mutelist_processor.configuration.get("Mutelist", {})
|
||||
# IaC provider doesn't support mutelist (uses Trivy's built-in logic)
|
||||
if mutelist_content and provider.provider != Provider.ProviderChoices.IAC.value:
|
||||
# IaC and Image providers don't support mutelist (Trivy handles its own logic)
|
||||
if mutelist_content and provider.provider not in (
|
||||
Provider.ProviderChoices.IAC.value,
|
||||
Provider.ProviderChoices.IMAGE.value,
|
||||
):
|
||||
prowler_provider_kwargs["mutelist_content"] = mutelist_content
|
||||
|
||||
return prowler_provider_kwargs
|
||||
@@ -242,6 +267,7 @@ def initialize_prowler_provider(
|
||||
| GcpProvider
|
||||
| GithubProvider
|
||||
| IacProvider
|
||||
| ImageProvider
|
||||
| KubernetesProvider
|
||||
| M365Provider
|
||||
| MongodbatlasProvider
|
||||
@@ -255,7 +281,7 @@ def initialize_prowler_provider(
|
||||
mutelist_processor (Processor): The mutelist processor object containing the mutelist configuration.
|
||||
|
||||
Returns:
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
AlibabacloudProvider | AwsProvider | AzureProvider | CloudflareProvider | GcpProvider | GithubProvider | IacProvider | ImageProvider | KubernetesProvider | M365Provider | MongodbatlasProvider | OpenstackProvider | OraclecloudProvider: An instance of the corresponding provider class
|
||||
initialized with the provider's secrets.
|
||||
"""
|
||||
prowler_provider = return_prowler_provider(provider)
|
||||
@@ -297,6 +323,20 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
|
||||
"raise_on_exception": False,
|
||||
}
|
||||
return prowler_provider.test_connection(**openstack_kwargs)
|
||||
elif provider.provider == Provider.ProviderChoices.IMAGE.value:
|
||||
from prowler.providers.image.lib.registry.factory import create_registry_adapter
|
||||
|
||||
try:
|
||||
adapter = create_registry_adapter(
|
||||
registry_url=provider.uid,
|
||||
username=prowler_provider_kwargs.get("registry_username"),
|
||||
password=prowler_provider_kwargs.get("registry_password"),
|
||||
token=prowler_provider_kwargs.get("registry_token"),
|
||||
)
|
||||
adapter.list_repositories()
|
||||
return Connection(is_connected=True, error=None)
|
||||
except Exception as e:
|
||||
return Connection(is_connected=False, error=str(e))
|
||||
else:
|
||||
return prowler_provider.test_connection(
|
||||
**prowler_provider_kwargs,
|
||||
|
||||
@@ -388,6 +388,37 @@ from rest_framework_json_api import serializers
|
||||
},
|
||||
"required": ["clouds_yaml_content", "clouds_yaml_cloud"],
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"title": "Image Registry Credentials",
|
||||
"properties": {
|
||||
"registry_username": {
|
||||
"type": "string",
|
||||
"description": "Username for Docker login authentication.",
|
||||
},
|
||||
"registry_password": {
|
||||
"type": "string",
|
||||
"description": "Password for Docker login authentication.",
|
||||
},
|
||||
"registry_token": {
|
||||
"type": "string",
|
||||
"description": "Bearer token for registry authentication.",
|
||||
},
|
||||
"image_filter": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to filter repository names during registry enumeration.",
|
||||
},
|
||||
"tag_filter": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to filter image tags during registry enumeration.",
|
||||
},
|
||||
"max_images": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "Maximum number of images to scan (0 = unlimited).",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1145,7 +1145,6 @@ class AttackPathsScanSerializer(RLSSerializer):
|
||||
"id",
|
||||
"state",
|
||||
"progress",
|
||||
"graph_data_ready",
|
||||
"provider",
|
||||
"provider_alias",
|
||||
"provider_type",
|
||||
@@ -1528,6 +1527,8 @@ class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
|
||||
)
|
||||
elif provider_type == Provider.ProviderChoices.OPENSTACK.value:
|
||||
serializer = OpenStackCloudsYamlProviderSecret(data=secret)
|
||||
elif provider_type == Provider.ProviderChoices.IMAGE.value:
|
||||
serializer = ImageProviderSecret(data=secret)
|
||||
else:
|
||||
raise serializers.ValidationError(
|
||||
{"provider": f"Provider type not supported {provider_type}"}
|
||||
@@ -1666,6 +1667,37 @@ class IacProviderSecret(serializers.Serializer):
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class ImageProviderSecret(serializers.Serializer):
|
||||
registry_username = serializers.CharField(required=False)
|
||||
registry_password = serializers.CharField(required=False)
|
||||
registry_token = serializers.CharField(required=False)
|
||||
image_filter = serializers.CharField(required=False)
|
||||
tag_filter = serializers.CharField(required=False)
|
||||
max_images = serializers.IntegerField(required=False, min_value=0)
|
||||
|
||||
def validate(self, attrs):
|
||||
has_username = attrs.get("registry_username")
|
||||
has_password = attrs.get("registry_password")
|
||||
has_token = attrs.get("registry_token")
|
||||
|
||||
if (has_username or has_password) and has_token:
|
||||
raise serializers.ValidationError(
|
||||
"You cannot provide both registry_username/registry_password and registry_token."
|
||||
)
|
||||
if has_username and not has_password:
|
||||
raise serializers.ValidationError(
|
||||
"registry_password is required when registry_username is provided."
|
||||
)
|
||||
if has_password and not has_username:
|
||||
raise serializers.ValidationError(
|
||||
"registry_username is required when registry_password is provided."
|
||||
)
|
||||
return super().validate(attrs)
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
|
||||
class OracleCloudProviderSecret(serializers.Serializer):
|
||||
user = serializers.CharField()
|
||||
fingerprint = serializers.CharField()
|
||||
|
||||
@@ -1759,25 +1759,6 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet):
|
||||
),
|
||||
},
|
||||
),
|
||||
csa=extend_schema(
|
||||
tags=["Scan"],
|
||||
summary="Retrieve CSA CCM compliance report",
|
||||
description="Download CSA Cloud Controls Matrix (CCM) v4.0 compliance report as a PDF file.",
|
||||
request=None,
|
||||
responses={
|
||||
200: OpenApiResponse(
|
||||
description="PDF file containing the CSA CCM compliance report"
|
||||
),
|
||||
202: OpenApiResponse(description="The task is in progress"),
|
||||
401: OpenApiResponse(
|
||||
description="API key missing or user not Authenticated"
|
||||
),
|
||||
403: OpenApiResponse(description="There is a problem with credentials"),
|
||||
404: OpenApiResponse(
|
||||
description="The scan has no CSA CCM reports, or the CSA CCM report generation task has not started yet"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
@method_decorator(CACHE_DECORATOR, name="list")
|
||||
@method_decorator(CACHE_DECORATOR, name="retrieve")
|
||||
@@ -1843,9 +1824,6 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
elif self.action == "nis2":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
elif self.action == "csa":
|
||||
if hasattr(self, "response_serializer_class"):
|
||||
return self.response_serializer_class
|
||||
return super().get_serializer_class()
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
@@ -2207,45 +2185,6 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
content, filename = loader
|
||||
return self._serve_file(content, filename, "application/pdf")
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get"],
|
||||
url_name="csa",
|
||||
)
|
||||
def csa(self, request, pk=None):
|
||||
scan = self.get_object()
|
||||
running_resp = self._get_task_status(scan)
|
||||
if running_resp:
|
||||
return running_resp
|
||||
|
||||
if not scan.output_location:
|
||||
return Response(
|
||||
{
|
||||
"detail": "The scan has no reports, or the CSA CCM report generation task has not started yet."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if scan.output_location.startswith("s3://"):
|
||||
bucket = env.str("DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET", "")
|
||||
key_prefix = scan.output_location.removeprefix(f"s3://{bucket}/")
|
||||
prefix = os.path.join(
|
||||
os.path.dirname(key_prefix),
|
||||
"csa",
|
||||
"*_csa_report.pdf",
|
||||
)
|
||||
loader = self._load_file(prefix, s3=True, bucket=bucket, list_objects=True)
|
||||
else:
|
||||
base = os.path.dirname(scan.output_location)
|
||||
pattern = os.path.join(base, "csa", "*_csa_report.pdf")
|
||||
loader = self._load_file(pattern, s3=False)
|
||||
|
||||
if isinstance(loader, Response):
|
||||
return loader
|
||||
|
||||
content, filename = loader
|
||||
return self._serve_file(content, filename, "application/pdf")
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
input_serializer = self.get_serializer(data=request.data)
|
||||
input_serializer.is_valid(raise_exception=True)
|
||||
@@ -2482,10 +2421,10 @@ class AttackPathsScanViewSet(BaseRLSViewSet):
|
||||
def run_attack_paths_query(self, request, pk=None):
|
||||
attack_paths_scan = self.get_object()
|
||||
|
||||
if not attack_paths_scan.graph_data_ready:
|
||||
if attack_paths_scan.state != StateChoices.COMPLETED:
|
||||
raise ValidationError(
|
||||
{
|
||||
"detail": "Attack Paths data is not available for querying - a scan must complete at least once before queries can be run"
|
||||
"detail": "The Attack Paths scan must be completed before running Attack Paths queries"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -543,6 +543,12 @@ def providers_fixture(tenants_fixture):
|
||||
alias="openstack_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
provider12 = Provider.objects.create(
|
||||
provider="image",
|
||||
uid="ghcr.io",
|
||||
alias="image_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
return (
|
||||
provider1,
|
||||
@@ -556,6 +562,7 @@ def providers_fixture(tenants_fixture):
|
||||
provider9,
|
||||
provider10,
|
||||
provider11,
|
||||
provider12,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@ from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from cartography.config import Config as CartographyConfig
|
||||
from celery.utils.log import get_task_logger
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.db_utils import rls_transaction
|
||||
from api.models import (
|
||||
AttackPathsScan as ProwlerAPIAttackPathsScan,
|
||||
@@ -13,8 +11,6 @@ from api.models import (
|
||||
)
|
||||
from tasks.jobs.attack_paths.config import is_provider_available
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
def can_provider_run_attack_paths_scan(tenant_id: str, provider_id: int) -> bool:
|
||||
with rls_transaction(tenant_id):
|
||||
@@ -32,21 +28,12 @@ def create_attack_paths_scan(
|
||||
return None
|
||||
|
||||
with rls_transaction(tenant_id):
|
||||
# Inherit graph_data_ready from the previous scan for this provider,
|
||||
# so queries remain available while the new scan runs.
|
||||
previous_data_ready = ProwlerAPIAttackPathsScan.objects.filter(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
graph_data_ready=True,
|
||||
).exists()
|
||||
|
||||
attack_paths_scan = ProwlerAPIAttackPathsScan.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
scan_id=scan_id,
|
||||
state=StateChoices.SCHEDULED,
|
||||
started_at=datetime.now(tz=timezone.utc),
|
||||
graph_data_ready=previous_data_ready,
|
||||
)
|
||||
attack_paths_scan.save()
|
||||
|
||||
@@ -129,32 +116,6 @@ def update_attack_paths_scan_progress(
|
||||
attack_paths_scan.save(update_fields=["progress"])
|
||||
|
||||
|
||||
def set_graph_data_ready(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
ready: bool,
|
||||
) -> None:
|
||||
with rls_transaction(attack_paths_scan.tenant_id):
|
||||
attack_paths_scan.graph_data_ready = ready
|
||||
attack_paths_scan.save(update_fields=["graph_data_ready"])
|
||||
|
||||
|
||||
def set_provider_graph_data_ready(
|
||||
attack_paths_scan: ProwlerAPIAttackPathsScan,
|
||||
ready: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Set `graph_data_ready` for ALL scans of the same provider.
|
||||
|
||||
Used before drop/sync so that older scan IDs cannot bypass the query gate while the graph is being replaced.
|
||||
"""
|
||||
with rls_transaction(attack_paths_scan.tenant_id):
|
||||
ProwlerAPIAttackPathsScan.objects.filter(
|
||||
tenant_id=attack_paths_scan.tenant_id,
|
||||
provider_id=attack_paths_scan.provider_id,
|
||||
).update(graph_data_ready=ready)
|
||||
attack_paths_scan.refresh_from_db(fields=["graph_data_ready"])
|
||||
|
||||
|
||||
def fail_attack_paths_scan(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
@@ -169,17 +130,6 @@ def fail_attack_paths_scan(
|
||||
StateChoices.COMPLETED,
|
||||
StateChoices.FAILED,
|
||||
):
|
||||
tmp_db_name = graph_database.get_database_name(
|
||||
attack_paths_scan.id, temporary=True
|
||||
)
|
||||
try:
|
||||
graph_database.drop_database(tmp_db_name)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to drop temp database {tmp_db_name} during failure handling"
|
||||
)
|
||||
|
||||
finish_attack_paths_scan(
|
||||
attack_paths_scan,
|
||||
StateChoices.FAILED,
|
||||
|
||||
@@ -169,7 +169,6 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
sync.create_sync_indexes(tenant_neo4j_session)
|
||||
|
||||
logger.info(f"Deleting existing provider graph in {tenant_database_name}")
|
||||
db_utils.set_provider_graph_data_ready(attack_paths_scan, False)
|
||||
graph_database.drop_subgraph(
|
||||
database=tenant_database_name,
|
||||
provider_id=str(prowler_api_provider.id),
|
||||
@@ -184,7 +183,6 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
target_database=tenant_database_name,
|
||||
provider_id=str(prowler_api_provider.id),
|
||||
)
|
||||
db_utils.set_graph_data_ready(attack_paths_scan, True)
|
||||
db_utils.update_attack_paths_scan_progress(attack_paths_scan, 99)
|
||||
|
||||
logger.info(f"Clearing Neo4j cache for database {tenant_database_name}")
|
||||
@@ -211,7 +209,6 @@ def run(tenant_id: str, scan_id: str, task_id: str) -> dict[str, Any]:
|
||||
# Handling databases changes
|
||||
try:
|
||||
graph_database.drop_database(tmp_cartography_config.neo4j_database)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Failed to drop temporary Neo4j database {tmp_cartography_config.neo4j_database} during cleanup"
|
||||
|
||||
@@ -27,42 +27,12 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with the count of deleted objects per model,
|
||||
including related models. Returns an empty dict if the provider
|
||||
was already deleted.
|
||||
including related models.
|
||||
|
||||
Raises:
|
||||
Provider.DoesNotExist: If no instance with the provided primary key exists.
|
||||
"""
|
||||
|
||||
# Get all provider related data to delete them in batches
|
||||
with rls_transaction(tenant_id):
|
||||
try:
|
||||
instance = Provider.all_objects.get(pk=pk)
|
||||
except Provider.DoesNotExist:
|
||||
logger.info(f"Provider `{pk}` already deleted, skipping")
|
||||
return {}
|
||||
|
||||
attack_paths_scan_ids = list(
|
||||
AttackPathsScan.all_objects.filter(provider=instance).values_list(
|
||||
"id", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
deletion_steps = [
|
||||
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
|
||||
("Findings", Finding.all_objects.filter(scan__provider=instance)),
|
||||
("Resources", Resource.all_objects.filter(provider=instance)),
|
||||
("Scans", Scan.all_objects.filter(provider=instance)),
|
||||
("AttackPathsScans", AttackPathsScan.all_objects.filter(provider=instance)),
|
||||
]
|
||||
|
||||
# Drop orphaned temporary Neo4j databases
|
||||
for aps_id in attack_paths_scan_ids:
|
||||
tmp_db_name = graph_database.get_database_name(aps_id, temporary=True)
|
||||
try:
|
||||
graph_database.drop_database(tmp_db_name)
|
||||
|
||||
except graph_database.GraphDatabaseQueryException:
|
||||
logger.warning(f"Failed to drop temp database {tmp_db_name}, continuing")
|
||||
|
||||
# Delete the Attack Paths' graph data related to the provider from the tenant database
|
||||
# Delete the Attack Paths' graph data related to the provider
|
||||
tenant_database_name = graph_database.get_database_name(tenant_id)
|
||||
try:
|
||||
graph_database.drop_subgraph(tenant_database_name, str(pk))
|
||||
@@ -71,7 +41,17 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
logger.error(f"Error deleting Provider graph data: {gdb_error}")
|
||||
raise
|
||||
|
||||
# Delete related data in batches
|
||||
# Get all provider related data and delete them in batches
|
||||
with rls_transaction(tenant_id):
|
||||
instance = Provider.all_objects.get(pk=pk)
|
||||
deletion_steps = [
|
||||
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
|
||||
("Findings", Finding.all_objects.filter(scan__provider=instance)),
|
||||
("Resources", Resource.all_objects.filter(provider=instance)),
|
||||
("Scans", Scan.all_objects.filter(provider=instance)),
|
||||
("AttackPathsScans", AttackPathsScan.all_objects.filter(provider=instance)),
|
||||
]
|
||||
|
||||
deletion_summary = {}
|
||||
for step_name, queryset in deletion_steps:
|
||||
try:
|
||||
@@ -81,7 +61,6 @@ def delete_provider(tenant_id: str, pk: str):
|
||||
logger.error(f"Error deleting {step_name}: {db_error}")
|
||||
raise
|
||||
|
||||
# Delete the provider instance itself
|
||||
try:
|
||||
with rls_transaction(tenant_id):
|
||||
_, provider_summary = instance.delete()
|
||||
@@ -106,9 +85,7 @@ def delete_tenant(pk: str):
|
||||
"""
|
||||
deletion_summary = {}
|
||||
|
||||
for provider in Provider.all_objects.using(MainRouter.admin_db).filter(
|
||||
tenant_id=pk
|
||||
):
|
||||
for provider in Provider.objects.using(MainRouter.admin_db).filter(tenant_id=pk):
|
||||
summary = delete_provider(pk, provider.id)
|
||||
deletion_summary.update(summary)
|
||||
|
||||
|
||||
@@ -137,6 +137,10 @@ COMPLIANCE_CLASS_MAP = {
|
||||
# IaC provider doesn't have specific compliance frameworks yet
|
||||
# Trivy handles its own compliance checks
|
||||
],
|
||||
"image": [
|
||||
# Image provider doesn't have specific compliance frameworks yet
|
||||
# Trivy handles its own compliance checks
|
||||
],
|
||||
"oraclecloud": [
|
||||
(lambda name: name.startswith("cis_"), OracleCloudCIS),
|
||||
(lambda name: name.startswith("csa_"), OracleCloudCSA),
|
||||
|
||||
@@ -6,7 +6,6 @@ from config.django.base import DJANGO_TMP_OUTPUT_DIRECTORY
|
||||
from tasks.jobs.export import _generate_compliance_output_directory, _upload_to_s3
|
||||
from tasks.jobs.reports import (
|
||||
FRAMEWORK_REGISTRY,
|
||||
CSAReportGenerator,
|
||||
ENSReportGenerator,
|
||||
NIS2ReportGenerator,
|
||||
ThreatScoreReportGenerator,
|
||||
@@ -148,49 +147,6 @@ def generate_nis2_report(
|
||||
)
|
||||
|
||||
|
||||
def generate_csa_report(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
compliance_id: str,
|
||||
output_path: str,
|
||||
provider_id: str,
|
||||
only_failed: bool = True,
|
||||
include_manual: bool = False,
|
||||
provider_obj: Provider | None = None,
|
||||
requirement_statistics: dict[str, dict[str, int]] | None = None,
|
||||
findings_cache: dict[str, list[FindingOutput]] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Generate a PDF compliance report for CSA Cloud Controls Matrix (CCM) v4.0.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant ID for Row-Level Security context.
|
||||
scan_id: ID of the scan executed by Prowler.
|
||||
compliance_id: ID of the compliance framework (e.g., "csa_ccm_4.0_aws").
|
||||
output_path: Output PDF file path.
|
||||
provider_id: Provider ID for the scan.
|
||||
only_failed: If True, only include failed requirements in detailed section.
|
||||
include_manual: If True, include manual requirements in detailed section.
|
||||
provider_obj: Pre-fetched Provider object to avoid duplicate queries.
|
||||
requirement_statistics: Pre-aggregated requirement statistics.
|
||||
findings_cache: Cache of already loaded findings to avoid duplicate queries.
|
||||
"""
|
||||
generator = CSAReportGenerator(FRAMEWORK_REGISTRY["csa_ccm"])
|
||||
|
||||
generator.generate(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
compliance_id=compliance_id,
|
||||
output_path=output_path,
|
||||
provider_id=provider_id,
|
||||
provider_obj=provider_obj,
|
||||
requirement_statistics=requirement_statistics,
|
||||
findings_cache=findings_cache,
|
||||
only_failed=only_failed,
|
||||
include_manual=include_manual,
|
||||
)
|
||||
|
||||
|
||||
def generate_compliance_reports(
|
||||
tenant_id: str,
|
||||
scan_id: str,
|
||||
@@ -198,14 +154,11 @@ def generate_compliance_reports(
|
||||
generate_threatscore: bool = True,
|
||||
generate_ens: bool = True,
|
||||
generate_nis2: bool = True,
|
||||
generate_csa: bool = True,
|
||||
only_failed_threatscore: bool = True,
|
||||
min_risk_level_threatscore: int = 4,
|
||||
include_manual_ens: bool = True,
|
||||
include_manual_nis2: bool = False,
|
||||
only_failed_nis2: bool = True,
|
||||
only_failed_csa: bool = True,
|
||||
include_manual_csa: bool = False,
|
||||
) -> dict[str, dict[str, bool | str]]:
|
||||
"""
|
||||
Generate multiple compliance reports with shared database queries.
|
||||
@@ -222,27 +175,23 @@ def generate_compliance_reports(
|
||||
generate_threatscore: Whether to generate ThreatScore report.
|
||||
generate_ens: Whether to generate ENS report.
|
||||
generate_nis2: Whether to generate NIS2 report.
|
||||
generate_csa: Whether to generate CSA CCM report.
|
||||
only_failed_threatscore: For ThreatScore, only include failed requirements.
|
||||
min_risk_level_threatscore: Minimum risk level for ThreatScore critical requirements.
|
||||
include_manual_ens: For ENS, include manual requirements.
|
||||
include_manual_nis2: For NIS2, include manual requirements.
|
||||
only_failed_nis2: For NIS2, only include failed requirements.
|
||||
only_failed_csa: For CSA CCM, only include failed requirements.
|
||||
include_manual_csa: For CSA CCM, include manual requirements.
|
||||
|
||||
Returns:
|
||||
Dictionary with results for each report type.
|
||||
"""
|
||||
logger.info(
|
||||
"Generating compliance reports for scan %s with provider %s"
|
||||
" (ThreatScore: %s, ENS: %s, NIS2: %s, CSA: %s)",
|
||||
" (ThreatScore: %s, ENS: %s, NIS2: %s)",
|
||||
scan_id,
|
||||
provider_id,
|
||||
generate_threatscore,
|
||||
generate_ens,
|
||||
generate_nis2,
|
||||
generate_csa,
|
||||
)
|
||||
|
||||
results = {}
|
||||
@@ -257,8 +206,6 @@ def generate_compliance_reports(
|
||||
results["ens"] = {"upload": False, "path": ""}
|
||||
if generate_nis2:
|
||||
results["nis2"] = {"upload": False, "path": ""}
|
||||
if generate_csa:
|
||||
results["csa"] = {"upload": False, "path": ""}
|
||||
return results
|
||||
|
||||
provider_obj = Provider.objects.get(id=provider_id)
|
||||
@@ -288,23 +235,7 @@ def generate_compliance_reports(
|
||||
results["nis2"] = {"upload": False, "path": ""}
|
||||
generate_nis2 = False
|
||||
|
||||
if generate_csa and provider_type not in [
|
||||
"aws",
|
||||
"azure",
|
||||
"gcp",
|
||||
"oraclecloud",
|
||||
"alibabacloud",
|
||||
]:
|
||||
logger.info("Provider %s not supported for CSA CCM report", provider_type)
|
||||
results["csa"] = {"upload": False, "path": ""}
|
||||
generate_csa = False
|
||||
|
||||
if (
|
||||
not generate_threatscore
|
||||
and not generate_ens
|
||||
and not generate_nis2
|
||||
and not generate_csa
|
||||
):
|
||||
if not generate_threatscore and not generate_ens and not generate_nis2:
|
||||
return results
|
||||
|
||||
# Aggregate requirement statistics once
|
||||
@@ -343,13 +274,6 @@ def generate_compliance_reports(
|
||||
scan_id,
|
||||
compliance_framework="nis2",
|
||||
)
|
||||
csa_path = _generate_compliance_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
provider_uid,
|
||||
tenant_id,
|
||||
scan_id,
|
||||
compliance_framework="csa",
|
||||
)
|
||||
out_dir = str(Path(threatscore_path).parent.parent)
|
||||
except Exception as e:
|
||||
logger.error("Error generating output directory: %s", e)
|
||||
@@ -360,8 +284,6 @@ def generate_compliance_reports(
|
||||
results["ens"] = error_dict.copy()
|
||||
if generate_nis2:
|
||||
results["nis2"] = error_dict.copy()
|
||||
if generate_csa:
|
||||
results["csa"] = error_dict.copy()
|
||||
return results
|
||||
|
||||
# Generate ThreatScore report
|
||||
@@ -534,41 +456,6 @@ def generate_compliance_reports(
|
||||
logger.error("Error generating NIS2 report: %s", e)
|
||||
results["nis2"] = {"upload": False, "path": "", "error": str(e)}
|
||||
|
||||
# Generate CSA CCM report
|
||||
if generate_csa:
|
||||
compliance_id_csa = f"csa_ccm_4.0_{provider_type}"
|
||||
pdf_path_csa = f"{csa_path}_csa_report.pdf"
|
||||
logger.info("Generating CSA CCM report with compliance %s", compliance_id_csa)
|
||||
|
||||
try:
|
||||
generate_csa_report(
|
||||
tenant_id=tenant_id,
|
||||
scan_id=scan_id,
|
||||
compliance_id=compliance_id_csa,
|
||||
output_path=pdf_path_csa,
|
||||
provider_id=provider_id,
|
||||
only_failed=only_failed_csa,
|
||||
include_manual=include_manual_csa,
|
||||
provider_obj=provider_obj,
|
||||
requirement_statistics=requirement_statistics,
|
||||
findings_cache=findings_cache,
|
||||
)
|
||||
|
||||
upload_uri_csa = _upload_to_s3(
|
||||
tenant_id, scan_id, pdf_path_csa, f"csa/{Path(pdf_path_csa).name}"
|
||||
)
|
||||
|
||||
if upload_uri_csa:
|
||||
results["csa"] = {"upload": True, "path": upload_uri_csa}
|
||||
logger.info("CSA CCM report uploaded to %s", upload_uri_csa)
|
||||
else:
|
||||
results["csa"] = {"upload": False, "path": out_dir}
|
||||
logger.warning("CSA CCM report saved locally at %s", out_dir)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating CSA CCM report: %s", e)
|
||||
results["csa"] = {"upload": False, "path": "", "error": str(e)}
|
||||
|
||||
# Clean up temporary files if all reports were uploaded successfully
|
||||
all_uploaded = all(
|
||||
result.get("upload", False)
|
||||
@@ -594,7 +481,6 @@ def generate_compliance_reports_job(
|
||||
generate_threatscore: bool = True,
|
||||
generate_ens: bool = True,
|
||||
generate_nis2: bool = True,
|
||||
generate_csa: bool = True,
|
||||
) -> dict[str, dict[str, bool | str]]:
|
||||
"""
|
||||
Celery task wrapper for generate_compliance_reports.
|
||||
@@ -606,7 +492,6 @@ def generate_compliance_reports_job(
|
||||
generate_threatscore: Whether to generate ThreatScore report.
|
||||
generate_ens: Whether to generate ENS report.
|
||||
generate_nis2: Whether to generate NIS2 report.
|
||||
generate_csa: Whether to generate CSA CCM report.
|
||||
|
||||
Returns:
|
||||
Dictionary with results for each report type.
|
||||
@@ -618,5 +503,4 @@ def generate_compliance_reports_job(
|
||||
generate_threatscore=generate_threatscore,
|
||||
generate_ens=generate_ens,
|
||||
generate_nis2=generate_nis2,
|
||||
generate_csa=generate_csa,
|
||||
)
|
||||
@@ -71,8 +71,6 @@ from .config import (
|
||||
COLOR_PROWLER_DARK_GREEN,
|
||||
COLOR_SAFE,
|
||||
COLOR_WHITE,
|
||||
CSA_CCM_SECTION_SHORT_NAMES,
|
||||
CSA_CCM_SECTIONS,
|
||||
DIMENSION_KEYS,
|
||||
DIMENSION_MAPPING,
|
||||
DIMENSION_NAMES,
|
||||
@@ -92,7 +90,6 @@ from .config import (
|
||||
)
|
||||
|
||||
# Framework-specific generators
|
||||
from .csa import CSAReportGenerator
|
||||
from .ens import ENSReportGenerator
|
||||
from .nis2 import NIS2ReportGenerator
|
||||
from .threatscore import ThreatScoreReportGenerator
|
||||
@@ -108,7 +105,6 @@ __all__ = [
|
||||
"ThreatScoreReportGenerator",
|
||||
"ENSReportGenerator",
|
||||
"NIS2ReportGenerator",
|
||||
"CSAReportGenerator",
|
||||
# Configuration
|
||||
"FrameworkConfig",
|
||||
"FRAMEWORK_REGISTRY",
|
||||
@@ -151,8 +147,6 @@ __all__ = [
|
||||
"THREATSCORE_SECTIONS",
|
||||
"NIS2_SECTIONS",
|
||||
"NIS2_SECTION_TITLES",
|
||||
"CSA_CCM_SECTIONS",
|
||||
"CSA_CCM_SECTION_SHORT_NAMES",
|
||||
# Layout constants
|
||||
"COL_WIDTH_SMALL",
|
||||
"COL_WIDTH_MEDIUM",
|
||||
|
||||
@@ -662,9 +662,6 @@ class BaseComplianceReportGenerator(ABC):
|
||||
elements.append(create_status_badge(req.status))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Hook for subclasses to add extra detail (e.g., CSA attributes)
|
||||
elements.extend(self._render_requirement_detail_extras(req, data))
|
||||
|
||||
# Findings for this requirement
|
||||
for check_id in req.checks:
|
||||
elements.append(Paragraph(f"Check: {check_id}", self.styles["h2"]))
|
||||
@@ -704,24 +701,6 @@ class BaseComplianceReportGenerator(ABC):
|
||||
|
||||
return page_text, "Powered by Prowler"
|
||||
|
||||
def _render_requirement_detail_extras(
|
||||
self, req: RequirementData, data: ComplianceData
|
||||
) -> list:
|
||||
"""Hook for subclasses to render extra content in detailed findings.
|
||||
|
||||
Called after the status badge for each requirement in the detailed
|
||||
findings section. Override in subclasses to add framework-specific
|
||||
metadata (e.g., CSA CCM attributes).
|
||||
|
||||
Args:
|
||||
req: The requirement being rendered.
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements (empty by default).
|
||||
"""
|
||||
return []
|
||||
|
||||
# =========================================================================
|
||||
# Private Helper Methods
|
||||
# =========================================================================
|
||||
|
||||
@@ -143,36 +143,6 @@ NIS2_SECTION_TITLES = {
|
||||
"12": "12. Asset Management",
|
||||
}
|
||||
|
||||
# CSA CCM sections (Cloud Controls Matrix v4.0 domains)
|
||||
CSA_CCM_SECTIONS = [
|
||||
"Application & Interface Security",
|
||||
"Audit & Assurance",
|
||||
"Business Continuity Management and Operational Resilience",
|
||||
"Change Control and Configuration Management",
|
||||
"Cryptography, Encryption & Key Management",
|
||||
"Data Security and Privacy Lifecycle Management",
|
||||
"Datacenter Security",
|
||||
"Governance, Risk and Compliance",
|
||||
"Identity & Access Management",
|
||||
"Infrastructure & Virtualization Security",
|
||||
"Interoperability & Portability",
|
||||
"Logging and Monitoring",
|
||||
"Security Incident Management, E-Discovery, & Cloud Forensics",
|
||||
"Threat & Vulnerability Management",
|
||||
"Universal Endpoint Management",
|
||||
]
|
||||
|
||||
# Short names for CSA CCM sections (used in chart labels)
|
||||
CSA_CCM_SECTION_SHORT_NAMES = {
|
||||
"Application & Interface Security": "App & Interface Security",
|
||||
"Business Continuity Management and Operational Resilience": "Business Continuity",
|
||||
"Change Control and Configuration Management": "Change Control & Config",
|
||||
"Cryptography, Encryption & Key Management": "Cryptography & Encryption",
|
||||
"Data Security and Privacy Lifecycle Management": "Data Security & Privacy",
|
||||
"Security Incident Management, E-Discovery, & Cloud Forensics": "Incident Mgmt & Forensics",
|
||||
"Infrastructure & Virtualization Security": "Infrastructure & Virtualization",
|
||||
}
|
||||
|
||||
# Table column widths
|
||||
COL_WIDTH_SMALL = 0.4 * inch
|
||||
COL_WIDTH_MEDIUM = 0.9 * inch
|
||||
@@ -291,28 +261,6 @@ FRAMEWORK_REGISTRY: dict[str, FrameworkConfig] = {
|
||||
has_niveles=False,
|
||||
has_weight=False,
|
||||
),
|
||||
"csa_ccm": FrameworkConfig(
|
||||
name="csa_ccm",
|
||||
display_name="CSA Cloud Controls Matrix (CCM)",
|
||||
logo_filename=None,
|
||||
primary_color=COLOR_BLUE,
|
||||
secondary_color=COLOR_LIGHT_BLUE,
|
||||
bg_color=COLOR_BG_BLUE,
|
||||
attribute_fields=[
|
||||
"Section",
|
||||
"CCMLite",
|
||||
"IaaS",
|
||||
"PaaS",
|
||||
"SaaS",
|
||||
"ScopeApplicability",
|
||||
],
|
||||
sections=CSA_CCM_SECTIONS,
|
||||
language="en",
|
||||
has_risk_levels=False,
|
||||
has_dimensions=False,
|
||||
has_niveles=False,
|
||||
has_weight=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -334,7 +282,5 @@ def get_framework_config(compliance_id: str) -> FrameworkConfig | None:
|
||||
return FRAMEWORK_REGISTRY["ens"]
|
||||
if "nis2" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["nis2"]
|
||||
if "csa" in compliance_lower or "ccm" in compliance_lower:
|
||||
return FRAMEWORK_REGISTRY["csa_ccm"]
|
||||
|
||||
return None
|
||||
@@ -1,474 +0,0 @@
|
||||
from collections import defaultdict
|
||||
|
||||
from celery.utils.log import get_task_logger
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.platypus import Image, PageBreak, Paragraph, Spacer, Table, TableStyle
|
||||
|
||||
from api.models import StatusChoices
|
||||
|
||||
from .base import (
|
||||
BaseComplianceReportGenerator,
|
||||
ComplianceData,
|
||||
get_requirement_metadata,
|
||||
)
|
||||
from .charts import create_horizontal_bar_chart, get_chart_color_for_percentage
|
||||
from .config import (
|
||||
COLOR_BG_BLUE,
|
||||
COLOR_BLUE,
|
||||
COLOR_BORDER_GRAY,
|
||||
COLOR_DARK_GRAY,
|
||||
COLOR_GRID_GRAY,
|
||||
COLOR_HIGH_RISK,
|
||||
COLOR_SAFE,
|
||||
COLOR_WHITE,
|
||||
CSA_CCM_SECTION_SHORT_NAMES,
|
||||
CSA_CCM_SECTIONS,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
|
||||
class CSAReportGenerator(BaseComplianceReportGenerator):
|
||||
"""
|
||||
PDF report generator for CSA Cloud Controls Matrix (CCM) v4.0.
|
||||
|
||||
This generator creates comprehensive PDF reports containing:
|
||||
- Cover page with Prowler logo
|
||||
- Executive summary with overall compliance score
|
||||
- Section analysis with horizontal bar chart
|
||||
- Section breakdown table
|
||||
- Requirements index organized by section
|
||||
- Detailed findings for failed requirements
|
||||
"""
|
||||
|
||||
def create_executive_summary(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the executive summary with compliance metrics.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Executive Summary", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Calculate statistics
|
||||
total = len(data.requirements)
|
||||
passed = sum(1 for r in data.requirements if r.status == StatusChoices.PASS)
|
||||
failed = sum(1 for r in data.requirements if r.status == StatusChoices.FAIL)
|
||||
manual = sum(1 for r in data.requirements if r.status == StatusChoices.MANUAL)
|
||||
|
||||
logger.info(
|
||||
"CSA CCM Executive Summary: total=%d, passed=%d, failed=%d, manual=%d",
|
||||
total,
|
||||
passed,
|
||||
failed,
|
||||
manual,
|
||||
)
|
||||
|
||||
# Log sample of requirements for debugging
|
||||
for req in data.requirements[:5]:
|
||||
logger.info(
|
||||
" Requirement %s: status=%s, passed_findings=%d, total_findings=%d",
|
||||
req.id,
|
||||
req.status,
|
||||
req.passed_findings,
|
||||
req.total_findings,
|
||||
)
|
||||
|
||||
# Calculate compliance excluding manual
|
||||
evaluated = passed + failed
|
||||
overall_compliance = (passed / evaluated * 100) if evaluated > 0 else 100
|
||||
|
||||
# Summary statistics table
|
||||
summary_data = [
|
||||
["Metric", "Value"],
|
||||
["Total Requirements", str(total)],
|
||||
["Passed \u2713", str(passed)],
|
||||
["Failed \u2717", str(failed)],
|
||||
["Manual \u2299", str(manual)],
|
||||
["Overall Compliance", f"{overall_compliance:.1f}%"],
|
||||
]
|
||||
|
||||
summary_table = Table(summary_data, colWidths=[3 * inch, 2 * inch])
|
||||
summary_table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 2), (0, 2), COLOR_SAFE),
|
||||
("TEXTCOLOR", (0, 2), (0, 2), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 3), (0, 3), COLOR_HIGH_RISK),
|
||||
("TEXTCOLOR", (0, 3), (0, 3), COLOR_WHITE),
|
||||
("BACKGROUND", (0, 4), (0, 4), COLOR_DARK_GRAY),
|
||||
("TEXTCOLOR", (0, 4), (0, 4), COLOR_WHITE),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("FONTNAME", (0, 0), (-1, 0), "PlusJakartaSans"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 12),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 10),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 10),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_BORDER_GRAY),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(1, 1),
|
||||
(1, -1),
|
||||
[COLOR_WHITE, COLOR_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
elements.append(summary_table)
|
||||
|
||||
return elements
|
||||
|
||||
def create_charts_section(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the charts section with section analysis.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
# Section chart
|
||||
elements.append(Paragraph("Compliance by Section", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
elements.append(
|
||||
Paragraph(
|
||||
"The following chart shows compliance percentage for each domain "
|
||||
"of the CSA Cloud Controls Matrix:",
|
||||
self.styles["normal_center"],
|
||||
)
|
||||
)
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
chart_buffer = self._create_section_chart(data)
|
||||
chart_buffer.seek(0)
|
||||
chart_image = Image(chart_buffer, width=6.5 * inch, height=5 * inch)
|
||||
elements.append(chart_image)
|
||||
elements.append(PageBreak())
|
||||
|
||||
# Section breakdown table
|
||||
elements.append(Paragraph("Section Breakdown", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
section_table = self._create_section_table(data)
|
||||
elements.append(section_table)
|
||||
|
||||
return elements
|
||||
|
||||
def create_requirements_index(self, data: ComplianceData) -> list:
|
||||
"""
|
||||
Create the requirements index organized by section.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
elements.append(Paragraph("Requirements Index", self.styles["h1"]))
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
# Organize by section
|
||||
sections = {}
|
||||
for req in data.requirements:
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
section = getattr(m, "Section", "Other")
|
||||
|
||||
if section not in sections:
|
||||
sections[section] = []
|
||||
|
||||
sections[section].append(
|
||||
{
|
||||
"id": req.id,
|
||||
"description": req.description,
|
||||
"status": req.status,
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by CSA CCM section order
|
||||
for section in CSA_CCM_SECTIONS:
|
||||
if section not in sections:
|
||||
continue
|
||||
|
||||
elements.append(Paragraph(section, self.styles["h2"]))
|
||||
|
||||
for req in sections[section]:
|
||||
status_indicator = (
|
||||
"\u2713" if req["status"] == StatusChoices.PASS else "\u2717"
|
||||
)
|
||||
if req["status"] == StatusChoices.MANUAL:
|
||||
status_indicator = "\u2299"
|
||||
|
||||
desc = (
|
||||
req["description"][:80] + "..."
|
||||
if len(req["description"]) > 80
|
||||
else req["description"]
|
||||
)
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"{status_indicator} <b>{req['id']}</b>: {desc}",
|
||||
self.styles["normal"],
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(Spacer(1, 0.1 * inch))
|
||||
|
||||
return elements
|
||||
|
||||
def _render_requirement_detail_extras(self, req, data: ComplianceData) -> list:
|
||||
"""
|
||||
Render CSA CCM attributes in the detailed findings view.
|
||||
|
||||
Shows CCMLite flag, IaaS/PaaS/SaaS applicability, and
|
||||
cross-framework references after the status badge for each requirement.
|
||||
|
||||
Args:
|
||||
req: The requirement being rendered.
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if not m:
|
||||
return []
|
||||
return self._format_requirement_attributes(m)
|
||||
|
||||
def _format_requirement_attributes(self, m) -> list:
|
||||
"""
|
||||
Format CSA CCM requirement attributes as compact PDF elements.
|
||||
|
||||
Displays CCMLite flag, IaaS/PaaS/SaaS applicability, and
|
||||
cross-framework references from ScopeApplicability.
|
||||
|
||||
Args:
|
||||
m: Requirement metadata (CSA_CCM_Requirement_Attribute).
|
||||
|
||||
Returns:
|
||||
List of ReportLab elements.
|
||||
"""
|
||||
elements = []
|
||||
|
||||
# Applicability line: CCMLite | IaaS | PaaS | SaaS
|
||||
ccm_lite = getattr(m, "CCMLite", "")
|
||||
iaas = getattr(m, "IaaS", "")
|
||||
paas = getattr(m, "PaaS", "")
|
||||
saas = getattr(m, "SaaS", "")
|
||||
|
||||
applicability_parts = []
|
||||
if ccm_lite:
|
||||
applicability_parts.append(f"CCMLite: {ccm_lite}")
|
||||
if iaas:
|
||||
applicability_parts.append(f"IaaS: {iaas}")
|
||||
if paas:
|
||||
applicability_parts.append(f"PaaS: {paas}")
|
||||
if saas:
|
||||
applicability_parts.append(f"SaaS: {saas}")
|
||||
|
||||
if applicability_parts:
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"<font color='#4A5568' size='10'>"
|
||||
f"{' | '.join(applicability_parts)}"
|
||||
f"</font>",
|
||||
self._attr_style(),
|
||||
)
|
||||
)
|
||||
|
||||
# ScopeApplicability references (compact)
|
||||
scope_list = getattr(m, "ScopeApplicability", [])
|
||||
if scope_list:
|
||||
refs = []
|
||||
for scope in scope_list:
|
||||
ref_id = scope.get("ReferenceId", "") if isinstance(scope, dict) else ""
|
||||
identifiers = (
|
||||
scope.get("Identifiers", []) if isinstance(scope, dict) else []
|
||||
)
|
||||
if ref_id and identifiers:
|
||||
ids_str = ", ".join(str(i) for i in identifiers[:4])
|
||||
if len(identifiers) > 4:
|
||||
ids_str += "..."
|
||||
refs.append(f"{ref_id}: {ids_str}")
|
||||
|
||||
if refs:
|
||||
refs_text = " | ".join(refs)
|
||||
elements.append(
|
||||
Paragraph(
|
||||
f"<font color='#718096' size='9'>{refs_text}</font>",
|
||||
self._attr_style(),
|
||||
)
|
||||
)
|
||||
|
||||
return elements
|
||||
|
||||
def _attr_style(self):
|
||||
"""
|
||||
Return a compact style for attribute text lines.
|
||||
|
||||
Returns:
|
||||
ParagraphStyle for attribute display.
|
||||
"""
|
||||
from reportlab.lib.styles import ParagraphStyle
|
||||
|
||||
return ParagraphStyle(
|
||||
"AttrLine",
|
||||
parent=self.styles["normal"],
|
||||
fontSize=10,
|
||||
spaceBefore=2,
|
||||
spaceAfter=2,
|
||||
leftIndent=30,
|
||||
leading=13,
|
||||
)
|
||||
|
||||
def _create_section_chart(self, data: ComplianceData):
|
||||
"""
|
||||
Create the section compliance chart.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
BytesIO buffer containing the chart image.
|
||||
"""
|
||||
section_scores = defaultdict(lambda: {"passed": 0, "total": 0})
|
||||
|
||||
no_metadata_count = 0
|
||||
for req in data.requirements:
|
||||
if req.status == StatusChoices.MANUAL:
|
||||
continue
|
||||
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
section = getattr(m, "Section", "Other")
|
||||
section_scores[section]["total"] += 1
|
||||
if req.status == StatusChoices.PASS:
|
||||
section_scores[section]["passed"] += 1
|
||||
else:
|
||||
no_metadata_count += 1
|
||||
|
||||
if no_metadata_count > 0:
|
||||
logger.warning(
|
||||
"CSA CCM chart: %d requirements had no metadata", no_metadata_count
|
||||
)
|
||||
|
||||
logger.info("CSA CCM section scores:")
|
||||
for section in CSA_CCM_SECTIONS:
|
||||
if section in section_scores:
|
||||
scores = section_scores[section]
|
||||
pct = (
|
||||
(scores["passed"] / scores["total"] * 100)
|
||||
if scores["total"] > 0
|
||||
else 0
|
||||
)
|
||||
logger.info(
|
||||
" %s: %d/%d (%.1f%%)",
|
||||
section,
|
||||
scores["passed"],
|
||||
scores["total"],
|
||||
pct,
|
||||
)
|
||||
|
||||
# Build labels and values in CSA CCM section order
|
||||
labels = []
|
||||
values = []
|
||||
for section in CSA_CCM_SECTIONS:
|
||||
if section in section_scores and section_scores[section]["total"] > 0:
|
||||
scores = section_scores[section]
|
||||
pct = (scores["passed"] / scores["total"]) * 100
|
||||
# Use short name if available
|
||||
label = CSA_CCM_SECTION_SHORT_NAMES.get(section, section)
|
||||
labels.append(label)
|
||||
values.append(pct)
|
||||
|
||||
return create_horizontal_bar_chart(
|
||||
labels=labels,
|
||||
values=values,
|
||||
xlabel="Compliance (%)",
|
||||
color_func=get_chart_color_for_percentage,
|
||||
)
|
||||
|
||||
def _create_section_table(self, data: ComplianceData) -> Table:
|
||||
"""
|
||||
Create the section breakdown table.
|
||||
|
||||
Args:
|
||||
data: Aggregated compliance data.
|
||||
|
||||
Returns:
|
||||
ReportLab Table element.
|
||||
"""
|
||||
section_scores = defaultdict(lambda: {"passed": 0, "failed": 0, "manual": 0})
|
||||
|
||||
for req in data.requirements:
|
||||
m = get_requirement_metadata(req.id, data.attributes_by_requirement_id)
|
||||
if m:
|
||||
section = getattr(m, "Section", "Other")
|
||||
|
||||
if req.status == StatusChoices.PASS:
|
||||
section_scores[section]["passed"] += 1
|
||||
elif req.status == StatusChoices.FAIL:
|
||||
section_scores[section]["failed"] += 1
|
||||
else:
|
||||
section_scores[section]["manual"] += 1
|
||||
|
||||
table_data = [["Section", "Passed", "Failed", "Manual", "Compliance"]]
|
||||
for section in CSA_CCM_SECTIONS:
|
||||
if section not in section_scores:
|
||||
continue
|
||||
scores = section_scores[section]
|
||||
total = scores["passed"] + scores["failed"]
|
||||
pct = (scores["passed"] / total * 100) if total > 0 else 100
|
||||
# Use short name if available
|
||||
label = CSA_CCM_SECTION_SHORT_NAMES.get(section, section)
|
||||
table_data.append(
|
||||
[
|
||||
label,
|
||||
str(scores["passed"]),
|
||||
str(scores["failed"]),
|
||||
str(scores["manual"]),
|
||||
f"{pct:.1f}%",
|
||||
]
|
||||
)
|
||||
|
||||
table = Table(
|
||||
table_data,
|
||||
colWidths=[2.4 * inch, 0.9 * inch, 0.9 * inch, 0.9 * inch, 1.2 * inch],
|
||||
)
|
||||
table.setStyle(
|
||||
TableStyle(
|
||||
[
|
||||
("BACKGROUND", (0, 0), (-1, 0), COLOR_BLUE),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), COLOR_WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "FiraCode"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 10),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, COLOR_GRID_GRAY),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
(
|
||||
"ROWBACKGROUNDS",
|
||||
(0, 1),
|
||||
(-1, -1),
|
||||
[COLOR_WHITE, COLOR_BG_BLUE],
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
return table
|
||||
@@ -114,11 +114,6 @@ def _calculate_requirements_data_from_statistics(
|
||||
requirement_status = StatusChoices.PASS
|
||||
else:
|
||||
requirement_status = StatusChoices.FAIL
|
||||
elif requirement_checks:
|
||||
# Requirement has checks but none produced findings — consistent
|
||||
# with the dashboard's scan processing which treats this as PASS
|
||||
# (no failed checks means the requirement is considered compliant).
|
||||
requirement_status = StatusChoices.PASS
|
||||
else:
|
||||
requirement_status = StatusChoices.MANUAL
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ from django_celery_beat.models import PeriodicTask
|
||||
from tasks.jobs.attack_paths import (
|
||||
attack_paths_scan,
|
||||
can_provider_run_attack_paths_scan,
|
||||
db_utils as attack_paths_db_utils,
|
||||
)
|
||||
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
|
||||
from tasks.jobs.backfill import (
|
||||
backfill_compliance_summaries,
|
||||
backfill_daily_severity_summaries,
|
||||
@@ -134,13 +134,41 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
|
||||
scan_id (str): The ID of the scan that was performed.
|
||||
provider_id (str): The primary key of the Provider instance that was scanned.
|
||||
"""
|
||||
chain(
|
||||
create_compliance_requirements_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
update_provider_compliance_scores_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
).apply_async()
|
||||
aggregate_attack_surface_task.apply_async(
|
||||
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
|
||||
with rls_transaction(tenant_id):
|
||||
provider_type = Provider.objects.get(id=provider_id).provider
|
||||
|
||||
has_compliance = provider_type not in (
|
||||
Provider.ProviderChoices.IAC.value,
|
||||
Provider.ProviderChoices.IMAGE.value,
|
||||
)
|
||||
|
||||
if has_compliance:
|
||||
chain(
|
||||
create_compliance_requirements_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
update_provider_compliance_scores_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id
|
||||
),
|
||||
).apply_async()
|
||||
aggregate_attack_surface_task.apply_async(
|
||||
kwargs={"tenant_id": tenant_id, "scan_id": scan_id}
|
||||
)
|
||||
|
||||
final_group_tasks = [
|
||||
check_integrations_task.si(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
scan_id=scan_id,
|
||||
),
|
||||
]
|
||||
if has_compliance:
|
||||
final_group_tasks.append(
|
||||
generate_compliance_reports_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id
|
||||
),
|
||||
)
|
||||
|
||||
chain(
|
||||
perform_scan_summary_task.si(tenant_id=tenant_id, scan_id=scan_id),
|
||||
group(
|
||||
@@ -149,17 +177,7 @@ def _perform_scan_complete_tasks(tenant_id: str, scan_id: str, provider_id: str)
|
||||
scan_id=scan_id, provider_id=provider_id, tenant_id=tenant_id
|
||||
),
|
||||
),
|
||||
group(
|
||||
# Use optimized task that generates both reports with shared queries
|
||||
generate_compliance_reports_task.si(
|
||||
tenant_id=tenant_id, scan_id=scan_id, provider_id=provider_id
|
||||
),
|
||||
check_integrations_task.si(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id,
|
||||
scan_id=scan_id,
|
||||
),
|
||||
),
|
||||
group(*final_group_tasks),
|
||||
).apply_async()
|
||||
|
||||
if can_provider_run_attack_paths_scan(tenant_id, provider_id):
|
||||
@@ -906,11 +924,11 @@ def jira_integration_task(
|
||||
@handle_provider_deletion
|
||||
def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id: str):
|
||||
"""
|
||||
Optimized task to generate ThreatScore, ENS, NIS2, and CSA CCM reports with shared queries.
|
||||
Optimized task to generate ThreatScore, ENS, and NIS2 reports with shared queries.
|
||||
|
||||
This task is more efficient than running separate report tasks because it reuses database queries:
|
||||
- Provider object fetched once (instead of multiple times)
|
||||
- Requirement statistics aggregated once (instead of multiple times)
|
||||
- Provider object fetched once (instead of three times)
|
||||
- Requirement statistics aggregated once (instead of three times)
|
||||
- Can reduce database load by up to 50-70%
|
||||
|
||||
Args:
|
||||
@@ -928,7 +946,6 @@ def generate_compliance_reports_task(tenant_id: str, scan_id: str, provider_id:
|
||||
generate_threatscore=True,
|
||||
generate_ens=True,
|
||||
generate_nis2=True,
|
||||
generate_csa=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ class TestAttackPathsRun:
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
|
||||
@@ -74,8 +72,6 @@ class TestAttackPathsRun:
|
||||
mock_starting,
|
||||
mock_update_progress,
|
||||
mock_finish,
|
||||
mock_set_provider_graph_data_ready,
|
||||
mock_set_graph_data_ready,
|
||||
mock_event_loop,
|
||||
mock_drop_db,
|
||||
tenants_fixture,
|
||||
@@ -163,66 +159,9 @@ class TestAttackPathsRun:
|
||||
mock_finish.assert_called_once_with(
|
||||
attack_paths_scan, StateChoices.COMPLETED, ingestion_result
|
||||
)
|
||||
mock_set_provider_graph_data_ready.assert_called_once_with(
|
||||
attack_paths_scan, False
|
||||
)
|
||||
mock_set_graph_data_ready.assert_called_once_with(attack_paths_scan, True)
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.drop_database")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.findings.analysis")
|
||||
@patch("tasks.jobs.attack_paths.scan.internet.analysis")
|
||||
@patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
|
||||
return_value="db-scan-id",
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.get_uri")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
|
||||
return_value=MagicMock(_enabled_regions=["us-east-1"]),
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
def test_run_failure_marks_scan_failed(
|
||||
self,
|
||||
mock_init_provider,
|
||||
mock_get_uri,
|
||||
mock_get_db_name,
|
||||
mock_create_db,
|
||||
mock_cartography_indexes,
|
||||
mock_cartography_analysis,
|
||||
mock_findings_indexes,
|
||||
mock_internet_analysis,
|
||||
mock_findings_analysis,
|
||||
mock_starting,
|
||||
mock_update_progress,
|
||||
mock_set_provider_graph_data_ready,
|
||||
mock_set_graph_data_ready,
|
||||
mock_finish,
|
||||
mock_drop_db,
|
||||
mock_event_loop,
|
||||
mock_stringify,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
scans_fixture,
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
@@ -246,18 +185,53 @@ class TestAttackPathsRun:
|
||||
ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
|
||||
return_value=MagicMock(_enabled_regions=["us-east-1"]),
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.get_uri"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
|
||||
return_value="db-scan-id",
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.create_database"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_session",
|
||||
return_value=session_ctx,
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"),
|
||||
patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"),
|
||||
patch("tasks.jobs.attack_paths.scan.internet.analysis"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.analysis"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress"
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.drop_database"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
|
||||
return_value=ingestion_fn,
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="ingestion boom"):
|
||||
attack_paths_run(str(tenant.id), str(scan.id), "task-456")
|
||||
@@ -267,64 +241,8 @@ class TestAttackPathsRun:
|
||||
assert failure_args[1] == StateChoices.FAILED
|
||||
assert failure_args[2] == {"global_error": "Cartography failed: ingestion boom"}
|
||||
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.drop_database",
|
||||
side_effect=ConnectionError("neo4j down"),
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.set_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.set_provider_graph_data_ready")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress")
|
||||
@patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan")
|
||||
@patch("tasks.jobs.attack_paths.scan.findings.analysis")
|
||||
@patch("tasks.jobs.attack_paths.scan.internet.analysis")
|
||||
@patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_analysis.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run")
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.create_database")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
|
||||
return_value="db-scan-id",
|
||||
)
|
||||
@patch("tasks.jobs.attack_paths.scan.graph_database.get_uri")
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
|
||||
return_value=MagicMock(_enabled_regions=["us-east-1"]),
|
||||
)
|
||||
@patch(
|
||||
"tasks.jobs.attack_paths.scan.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
)
|
||||
def test_run_failure_marks_scan_failed_even_when_drop_database_fails(
|
||||
self,
|
||||
mock_init_provider,
|
||||
mock_get_uri,
|
||||
mock_get_db_name,
|
||||
mock_create_db,
|
||||
mock_cartography_indexes,
|
||||
mock_cartography_analysis,
|
||||
mock_findings_indexes,
|
||||
mock_internet_analysis,
|
||||
mock_findings_analysis,
|
||||
mock_starting,
|
||||
mock_update_progress,
|
||||
mock_set_provider_graph_data_ready,
|
||||
mock_set_graph_data_ready,
|
||||
mock_finish,
|
||||
mock_drop_db,
|
||||
mock_event_loop,
|
||||
mock_stringify,
|
||||
tenants_fixture,
|
||||
providers_fixture,
|
||||
scans_fixture,
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
@@ -348,18 +266,56 @@ class TestAttackPathsRun:
|
||||
ingestion_fn = MagicMock(side_effect=RuntimeError("ingestion boom"))
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.initialize_prowler_provider",
|
||||
return_value=MagicMock(_enabled_regions=["us-east-1"]),
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.get_uri"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_database_name",
|
||||
return_value="db-scan-id",
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.graph_database.create_database"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.get_session",
|
||||
return_value=session_ctx,
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.cartography_create_indexes.run"),
|
||||
patch("tasks.jobs.attack_paths.scan.cartography_analysis.run"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.create_findings_indexes"),
|
||||
patch("tasks.jobs.attack_paths.scan.internet.analysis"),
|
||||
patch("tasks.jobs.attack_paths.scan.findings.analysis"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
),
|
||||
patch("tasks.jobs.attack_paths.scan.db_utils.starting_attack_paths_scan"),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.update_attack_paths_scan_progress"
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.graph_database.drop_database",
|
||||
side_effect=ConnectionError("neo4j down"),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.get_cartography_ingestion_function",
|
||||
return_value=ingestion_fn,
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.call_within_event_loop",
|
||||
side_effect=lambda fn, *a, **kw: fn(*a, **kw),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.scan.utils.stringify_exception",
|
||||
return_value="Cartography failed: ingestion boom",
|
||||
),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="ingestion boom"):
|
||||
attack_paths_run(str(tenant.id), str(scan.id), "task-789")
|
||||
@@ -441,9 +397,6 @@ class TestFailAttackPathsScan:
|
||||
"tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
) as mock_retrieve,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.graph_database.drop_database"
|
||||
) as mock_drop_db,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
@@ -451,51 +404,6 @@ class TestFailAttackPathsScan:
|
||||
fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded")
|
||||
|
||||
mock_retrieve.assert_called_once_with(str(tenant.id), str(scan.id))
|
||||
expected_tmp_db = f"db-tmp-scan-{str(attack_paths_scan.id).lower()}"
|
||||
mock_drop_db.assert_called_once_with(expected_tmp_db)
|
||||
mock_finish.assert_called_once_with(
|
||||
attack_paths_scan,
|
||||
StateChoices.FAILED,
|
||||
{"global_error": "setup exploded"},
|
||||
)
|
||||
|
||||
def test_drops_temp_database_even_when_drop_fails(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import (
|
||||
fail_attack_paths_scan,
|
||||
)
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
attack_paths_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan,
|
||||
state=StateChoices.EXECUTING,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.graph_database.drop_database",
|
||||
side_effect=Exception("Neo4j unreachable"),
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
):
|
||||
fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded")
|
||||
|
||||
mock_finish.assert_called_once_with(
|
||||
attack_paths_scan,
|
||||
StateChoices.FAILED,
|
||||
@@ -529,16 +437,12 @@ class TestFailAttackPathsScan:
|
||||
"tasks.jobs.attack_paths.db_utils.retrieve_attack_paths_scan",
|
||||
return_value=attack_paths_scan,
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.graph_database.drop_database"
|
||||
) as mock_drop_db,
|
||||
patch(
|
||||
"tasks.jobs.attack_paths.db_utils.finish_attack_paths_scan"
|
||||
) as mock_finish,
|
||||
):
|
||||
fail_attack_paths_scan(str(tenant.id), str(scan.id), "setup exploded")
|
||||
|
||||
mock_drop_db.assert_not_called()
|
||||
mock_finish.assert_not_called()
|
||||
|
||||
def test_skips_when_no_scan_found(self, tenants_fixture):
|
||||
@@ -1113,317 +1017,3 @@ class TestInternetAnalysis:
|
||||
result = internet_module.analysis(mock_session, provider, config)
|
||||
|
||||
assert result == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestAttackPathsDbUtilsGraphDataReady:
|
||||
"""Tests for db_utils functions related to graph_data_ready lifecycle."""
|
||||
|
||||
def test_create_attack_paths_scan_first_scan_defaults_to_false(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
str(tenant.id), str(scan.id), provider.id
|
||||
)
|
||||
|
||||
assert attack_paths_scan is not None
|
||||
assert attack_paths_scan.graph_data_ready is False
|
||||
|
||||
def test_create_attack_paths_scan_inherits_true_from_previous(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan,
|
||||
state=StateChoices.COMPLETED,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
new_scan = Scan.objects.create(
|
||||
name="New Scan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.AVAILABLE,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
str(tenant.id), str(new_scan.id), provider.id
|
||||
)
|
||||
|
||||
assert attack_paths_scan is not None
|
||||
assert attack_paths_scan.graph_data_ready is True
|
||||
|
||||
def test_create_attack_paths_scan_inherits_false_when_no_previous_ready(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import create_attack_paths_scan
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan,
|
||||
state=StateChoices.FAILED,
|
||||
graph_data_ready=False,
|
||||
)
|
||||
|
||||
new_scan = Scan.objects.create(
|
||||
name="New Scan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.AVAILABLE,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
attack_paths_scan = create_attack_paths_scan(
|
||||
str(tenant.id), str(new_scan.id), provider.id
|
||||
)
|
||||
|
||||
assert attack_paths_scan is not None
|
||||
assert attack_paths_scan.graph_data_ready is False
|
||||
|
||||
def test_set_graph_data_ready_updates_field(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import set_graph_data_ready
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
attack_paths_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan,
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
set_graph_data_ready(attack_paths_scan, False)
|
||||
|
||||
attack_paths_scan.refresh_from_db()
|
||||
assert attack_paths_scan.graph_data_ready is False
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
set_graph_data_ready(attack_paths_scan, True)
|
||||
|
||||
attack_paths_scan.refresh_from_db()
|
||||
assert attack_paths_scan.graph_data_ready is True
|
||||
|
||||
def test_finish_attack_paths_scan_does_not_modify_graph_data_ready(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import finish_attack_paths_scan
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
attack_paths_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan,
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
finish_attack_paths_scan(attack_paths_scan, StateChoices.COMPLETED, {})
|
||||
|
||||
attack_paths_scan.refresh_from_db()
|
||||
assert attack_paths_scan.state == StateChoices.COMPLETED
|
||||
assert attack_paths_scan.graph_data_ready is True
|
||||
|
||||
def test_finish_attack_paths_scan_preserves_graph_data_ready_on_failure(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import finish_attack_paths_scan
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
scan = scans_fixture[0]
|
||||
scan.provider = provider
|
||||
scan.save()
|
||||
|
||||
attack_paths_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan,
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
finish_attack_paths_scan(
|
||||
attack_paths_scan,
|
||||
StateChoices.FAILED,
|
||||
{"global_error": "boom"},
|
||||
)
|
||||
|
||||
attack_paths_scan.refresh_from_db()
|
||||
assert attack_paths_scan.state == StateChoices.FAILED
|
||||
assert attack_paths_scan.graph_data_ready is True
|
||||
|
||||
def test_set_provider_graph_data_ready_updates_all_scans_for_provider(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider = providers_fixture[0]
|
||||
provider.provider = Provider.ProviderChoices.AWS
|
||||
provider.save()
|
||||
|
||||
scan_a = scans_fixture[0]
|
||||
scan_a.provider = provider
|
||||
scan_a.save()
|
||||
|
||||
scan_b = Scan.objects.create(
|
||||
name="Second Scan",
|
||||
provider=provider,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.AVAILABLE,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
old_ap_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan_a,
|
||||
state=StateChoices.COMPLETED,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
new_ap_scan = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider,
|
||||
scan=scan_b,
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
set_provider_graph_data_ready(new_ap_scan, False)
|
||||
|
||||
old_ap_scan.refresh_from_db()
|
||||
new_ap_scan.refresh_from_db()
|
||||
assert old_ap_scan.graph_data_ready is False
|
||||
assert new_ap_scan.graph_data_ready is False
|
||||
|
||||
def test_set_provider_graph_data_ready_does_not_affect_other_providers(
|
||||
self, tenants_fixture, providers_fixture, scans_fixture
|
||||
):
|
||||
from tasks.jobs.attack_paths.db_utils import set_provider_graph_data_ready
|
||||
|
||||
tenant = tenants_fixture[0]
|
||||
provider_a = providers_fixture[0]
|
||||
provider_a.provider = Provider.ProviderChoices.AWS
|
||||
provider_a.save()
|
||||
|
||||
provider_b = providers_fixture[1]
|
||||
provider_b.provider = Provider.ProviderChoices.AWS
|
||||
provider_b.save()
|
||||
|
||||
scan_a = scans_fixture[0]
|
||||
scan_a.provider = provider_a
|
||||
scan_a.save()
|
||||
|
||||
scan_b = Scan.objects.create(
|
||||
name="Scan for provider B",
|
||||
provider=provider_b,
|
||||
trigger=Scan.TriggerChoices.MANUAL,
|
||||
state=StateChoices.COMPLETED,
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
ap_scan_a = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider_a,
|
||||
scan=scan_a,
|
||||
state=StateChoices.EXECUTING,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
ap_scan_b = AttackPathsScan.objects.create(
|
||||
tenant_id=tenant.id,
|
||||
provider=provider_b,
|
||||
scan=scan_b,
|
||||
state=StateChoices.COMPLETED,
|
||||
graph_data_ready=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"tasks.jobs.attack_paths.db_utils.rls_transaction",
|
||||
new=lambda *args, **kwargs: nullcontext(),
|
||||
):
|
||||
set_provider_graph_data_ready(ap_scan_a, False)
|
||||
|
||||
ap_scan_a.refresh_from_db()
|
||||
ap_scan_b.refresh_from_db()
|
||||
assert ap_scan_a.graph_data_ready is False
|
||||
assert ap_scan_b.graph_data_ready is True
|
||||
@@ -4,7 +4,6 @@ import pytest
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from api.attack_paths import database as graph_database
|
||||
from api.models import Provider, Tenant
|
||||
from tasks.jobs.deletion import delete_provider, delete_tenant
|
||||
|
||||
@@ -48,61 +47,14 @@ class TestDeleteProvider:
|
||||
tenant_id = str(tenants_fixture[0].id)
|
||||
non_existent_pk = "babf6796-cfcc-4fd3-9dcf-88d012247645"
|
||||
|
||||
result = delete_provider(tenant_id, non_existent_pk)
|
||||
with pytest.raises(ObjectDoesNotExist):
|
||||
delete_provider(tenant_id, non_existent_pk)
|
||||
|
||||
assert result == {}
|
||||
mock_get_database_name.assert_not_called()
|
||||
mock_drop_subgraph.assert_not_called()
|
||||
|
||||
def test_delete_provider_drops_temp_attack_paths_databases(
|
||||
self, providers_fixture, create_attack_paths_scan
|
||||
):
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
|
||||
aps1 = create_attack_paths_scan(instance)
|
||||
aps2 = create_attack_paths_scan(instance)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database",
|
||||
) as mock_drop_database,
|
||||
):
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert result
|
||||
expected_tmp_calls = [
|
||||
call(f"db-tmp-scan-{str(aps1.id).lower()}"),
|
||||
call(f"db-tmp-scan-{str(aps2.id).lower()}"),
|
||||
]
|
||||
mock_drop_database.assert_has_calls(expected_tmp_calls, any_order=True)
|
||||
|
||||
def test_delete_provider_continues_when_temp_db_drop_fails(
|
||||
self, providers_fixture, create_attack_paths_scan
|
||||
):
|
||||
instance = providers_fixture[0]
|
||||
tenant_id = str(instance.tenant_id)
|
||||
|
||||
create_attack_paths_scan(instance)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_database",
|
||||
side_effect=graph_database.GraphDatabaseQueryException(
|
||||
"Neo4j unreachable"
|
||||
),
|
||||
),
|
||||
):
|
||||
result = delete_provider(tenant_id, instance.id)
|
||||
|
||||
assert result
|
||||
assert not Provider.all_objects.filter(pk=instance.id).exists()
|
||||
mock_get_database_name.assert_called_once_with(tenant_id)
|
||||
mock_drop_subgraph.assert_called_once_with(
|
||||
"tenant-db",
|
||||
non_existent_pk,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@@ -190,56 +142,3 @@ class TestDeleteTenant:
|
||||
mock_get_database_name.assert_called_once_with(tenant.id)
|
||||
mock_drop_subgraph.assert_not_called()
|
||||
mock_drop_database.assert_called_once_with("tenant-db")
|
||||
|
||||
def test_delete_tenant_includes_soft_deleted_providers(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
provider = Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="999999999999",
|
||||
alias="soft_deleted_provider",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
# Soft-delete the provider so ActiveProviderManager would skip it
|
||||
Provider.all_objects.filter(pk=provider.id).update(is_deleted=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph"
|
||||
) as mock_drop_subgraph,
|
||||
patch("tasks.jobs.deletion.graph_database.drop_database"),
|
||||
):
|
||||
delete_tenant(tenant.id)
|
||||
|
||||
mock_drop_subgraph.assert_any_call("tenant-db", str(provider.id))
|
||||
|
||||
def test_delete_tenant_handles_concurrently_deleted_provider(self, tenants_fixture):
|
||||
tenant = tenants_fixture[0]
|
||||
Provider.objects.create(
|
||||
provider="aws",
|
||||
uid="111111111111",
|
||||
alias="vanishing_provider",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
def drop_subgraph_side_effect(_db_name, provider_id):
|
||||
# Simulate concurrent deletion by another process
|
||||
Provider.all_objects.filter(pk=provider_id).delete()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.get_database_name",
|
||||
return_value="tenant-db",
|
||||
),
|
||||
patch(
|
||||
"tasks.jobs.deletion.graph_database.drop_subgraph",
|
||||
side_effect=drop_subgraph_side_effect,
|
||||
),
|
||||
patch("tasks.jobs.deletion.graph_database.drop_database"),
|
||||
):
|
||||
deletion_summary = delete_tenant(tenant.id)
|
||||
|
||||
assert deletion_summary is not None
|
||||
File diff suppressed because it is too large.
Load diff
@@ -144,10 +144,6 @@ services:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
entrypoint:
|
||||
- "/home/prowler/docker-entrypoint.sh"
|
||||
- "worker"
|
||||
@@ -170,10 +166,6 @@ services:
|
||||
condition: service_healthy
|
||||
neo4j:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
entrypoint:
|
||||
- "../docker-entrypoint.sh"
|
||||
- "beat"
|
||||
|
||||
@@ -117,10 +117,6 @@ services:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
entrypoint:
|
||||
- "/home/prowler/docker-entrypoint.sh"
|
||||
- "worker"
|
||||
@@ -135,10 +131,6 @@ services:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
entrypoint:
|
||||
- "../docker-entrypoint.sh"
|
||||
- "beat"
|
||||
|
||||
@@ -49,13 +49,15 @@ AWS_PROFILE=prowler-profile
|
||||
|
||||
- If you are scanning multiple AWS accounts, you may need to add multiple profiles to your AWS config. Note that this workaround is mainly for local testing; for production or multi-account setups, follow the [CloudFormation Template guide](https://github.com/prowler-cloud/prowler/issues/7745) and ensure the correct IAM roles and permissions are set up in each account.
|
||||
|
||||
### Scans Complete but Reports Are Missing or Compliance Data Is Empty (`Too many open files` Error)
|
||||
### Scans complete but reports are missing or compliance data is empty (`Too many open files` error)
|
||||
|
||||
When running Prowler App via Docker Compose, scans may complete successfully but reports are not available for download, compliance data shows as empty, or 404 errors appear when trying to access scan reports. Checking the `worker` container logs may reveal errors like `[Errno 24] Too many open files`.
|
||||
When running Prowler App via Docker Compose, you may encounter situations where scans complete successfully but reports are not available for download, compliance data shows as empty, or you see 404 errors when trying to access scan reports. Checking the `worker` container logs may reveal errors like `[Errno 24] Too many open files`.
|
||||
|
||||
This issue occurs because the default file descriptor limits in Docker containers are too low for Prowler's operations. The default `docker-compose.yml` already includes `ulimits` configuration with `nofile` set to `65536` for the `worker` and `worker-beat` services to prevent this issue.
|
||||
This issue occurs because the default file descriptor limits in Docker containers are too low for Prowler's operations.
|
||||
|
||||
If a custom `docker-compose.yml` is being used or the default configuration has been modified, ensure the `ulimits` configuration is present in both the `worker` and `worker-beat` services:
|
||||
**Solution:**
|
||||
|
||||
Add `ulimits` configuration to the `worker` and `worker-beat` services in your `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -74,13 +76,17 @@ services:
|
||||
# ... rest of service configuration
|
||||
```
|
||||
|
||||
After making these changes, restart the Docker Compose stack:
|
||||
After making these changes, restart your Docker Compose stack:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
<Note>
|
||||
We are evaluating adding these values to the default `docker-compose.yml` to avoid this issue in future releases.
|
||||
</Note>
|
||||
|
||||
### API Container Fails to Start with JWT Key Permission Error
|
||||
|
||||
See [GitHub Issue #8897](https://github.com/prowler-cloud/prowler/issues/8897) for more details.
|
||||
|
||||
@@ -1,447 +1,231 @@
|
||||
---
|
||||
title: "GitHub Authentication in Prowler"
|
||||
title: 'GitHub Authentication in Prowler'
|
||||
---
|
||||
|
||||
Prowler for GitHub offers multiple authentication types across Prowler Cloud and Prowler CLI.
|
||||
Prowler supports multiple methods to [authenticate with GitHub](https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api). These include:
|
||||
|
||||
## Common Setup
|
||||
- [Personal Access Token (PAT)](/user-guide/providers/github/authentication#personal-access-token-pat)
|
||||
- [OAuth App Token](/user-guide/providers/github/authentication#oauth-app-token)
|
||||
- [GitHub App Credentials](/user-guide/providers/github/authentication#github-app-credentials)
|
||||
|
||||
### Authentication Methods Overview
|
||||
This flexibility enables scanning and analysis of GitHub accounts, including repositories, organizations, and applications, using the method that best suits the use case.
|
||||
|
||||
Prowler offers three authentication methods. Fine-Grained Personal Access Tokens are recommended for most use cases.
|
||||
|
||||
| Method | Best For | Key Benefit |
|
||||
|--------|----------|-------------|
|
||||
| [**Fine-Grained Personal Access Token**](#fine-grained-personal-access-token-recommended) | Individual users, quick setup | Simple, user-scoped access |
|
||||
| [**GitHub App**](#github-app-credentials) | Organizations, automation, CI/CD | Organization-scoped, no personal account dependency |
|
||||
| [**OAuth App Token**](#oauth-app-token) | Delegated user authorization | User-consented access flows |
|
||||
|
||||
<Note>
|
||||
**Which should I choose?**
|
||||
|
||||
- **Personal scanning or quick setup**: Use Fine-Grained PAT
|
||||
- **Organization-wide scanning or CI/CD pipelines**: Use GitHub App (recommended for production)
|
||||
- **Building apps with user authorization**: Use OAuth App
|
||||
</Note>
|
||||
## Personal Access Token (PAT)
|
||||
|
||||
Personal Access Tokens provide the simplest GitHub authentication method, but it can only access resources owned by a single user or organization.
|
||||
|
||||
<Warning>
|
||||
**Classic Personal Access Tokens**
|
||||
**Classic Tokens Deprecated**
|
||||
|
||||
GitHub has deprecated classic Personal Access Tokens. Use Fine-Grained Tokens instead - they provide granular permission control and better security.
|
||||
GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained Personal Access Tokens. We recommend using fine-grained tokens as they provide better security through more granular permissions and resource-specific access control.
|
||||
|
||||
</Warning>
|
||||
#### **Option 1: Create a Fine-Grained Personal Access Token (Recommended)**
|
||||
|
||||
### Required Permissions
|
||||
1. **Navigate to GitHub Settings**
|
||||
- Open [GitHub](https://github.com) and sign in
|
||||
- Click the profile picture in the top right corner
|
||||
- Select "Settings" from the dropdown menu
|
||||
|
||||
Required permissions depend on the scan scope: user repositories, organization repositories, or both.
|
||||
2. **Access Developer Settings**
|
||||
- Scroll down the left sidebar
|
||||
- Click "Developer settings"
|
||||
|
||||
#### Repository Permissions
|
||||
3. **Generate Fine-Grained Token**
|
||||
- Click "Personal access tokens"
|
||||
- Select "Fine-grained tokens"
|
||||
- Click "Generate new token"
|
||||
|
||||
Required for scanning repository security settings:
|
||||
4. **Configure Token Settings**
|
||||
- **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner")
|
||||
- **Resource owner**: Select the account that owns the resources to scan — either a personal account or a specific organization
|
||||
- **Expiration**: Set an appropriate expiration date (recommended: 90 days or less)
|
||||
- **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Branch protection, security settings | All branch protection checks, secret scanning status |
|
||||
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
|
||||
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
|
||||
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
|
||||
<Note>
|
||||
**Public repositories**
|
||||
|
||||
<Note>
|
||||
**Pull requests permission is optional.** It's only needed if you want to audit PR-specific settings beyond what branch protection provides.
|
||||
</Note>
|
||||
Even if you select 'Only select repositories', the token will have access to the public repositories that you own or are a member of.
|
||||
|
||||
#### Organization Permissions
|
||||
</Note>
|
||||
5. **Configure Token Permissions**
|
||||
To enable Prowler functionality, configure the following permissions:
|
||||
|
||||
Required for scanning organization-level security settings:
|
||||
- **Repository permissions:**
|
||||
- **Administration**: Read-only access
|
||||
- **Contents**: Read-only access
|
||||
- **Metadata**: Read-only access
|
||||
- **Pull requests**: Read-only access
|
||||
|
||||
<Note>
|
||||
**For Fine-Grained PATs:** Organization permissions only appear when the **Resource Owner** is set to an organization (not your personal account).
|
||||
- **Organization permissions** (available when an organization is selected as Resource Owner):
|
||||
- **Administration**: Read-only access
|
||||
- **Members**: Read-only access
|
||||
|
||||
**For GitHub Apps:** Organization permissions are configured during app creation and apply to all organizations where the app is installed.
|
||||
</Note>
|
||||
- **Account permissions** (available when a personal account is selected as Resource Owner):
|
||||
- **Email addresses**: Read-only access
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
|
||||
| **Members** | Read | Member access reviews | Organization membership auditing |
|
||||
6. **Copy and Store the Token**
|
||||
- Copy the generated token immediately (GitHub displays tokens only once)
|
||||
- Store tokens securely using environment variables
|
||||
|
||||
#### Account Permissions (Fine-Grained PAT only)
|
||||

|
||||
|
||||
| Permission | Access Level | Purpose |
|
||||
|------------|-------------|---------|
|
||||
| **Email addresses** | Read | User email verification |
|
||||
|
||||
<Note>
|
||||
GitHub Apps don't have account-level permissions - they operate at the organization/repository level.
|
||||
</Note>
|
||||
|
||||
### Permissions and Check Coverage
|
||||
|
||||
With the **Read-only permissions** listed above, Prowler can run:
|
||||
|
||||
| Check Category | Coverage | Notes |
|
||||
|----------------|----------|-------|
|
||||
| Branch protection checks (12 checks) | ✅ Full | Signed commits, status checks, PR reviews, etc. |
|
||||
| Repository security checks | ✅ Full | Secret scanning, Dependabot, SECURITY.md, CODEOWNERS |
|
||||
| Organization checks (3 checks) | ✅ Full | MFA, repo creation policies, default permissions |
|
||||
| Compliance frameworks | ✅ Full | CIS GitHub Benchmark and others |
|
||||
| Merge settings (`delete_branch_on_merge`) | ⚠️ MANUAL | Requires write permission (see below) |
|
||||
|
||||
**Check that returns `MANUAL` status with Read-only permissions:**
|
||||
- `repository_branch_delete_on_merge_enabled`
|
||||
#### **Option 2: Create a Classic Personal Access Token (Not Recommended)**
|
||||
|
||||
<Warning>
|
||||
**About Write Permissions**
|
||||
**Security Risk**
|
||||
|
||||
The `delete_branch_on_merge` setting is only returned by the GitHub API when the token has **Administration: Read and write** permission.
|
||||
|
||||
**Granting Write permissions is not recommended under any circumstances:**
|
||||
- Token can modify repository settings
|
||||
- Token can change branch protection rules
|
||||
- Violates the principle of least privilege
|
||||
|
||||
**Recommendation:** Accept `MANUAL` status for this single check rather than granting write access. This limitation applies equally to Fine-Grained PATs and GitHub Apps.
|
||||
</Warning>
|
||||
|
||||
### Step-by-Step Permission Assignment
|
||||
|
||||
#### Fine-Grained Personal Access Token (Recommended for Individual Use)
|
||||
|
||||
**Benefits of Fine-Grained Tokens**
|
||||
|
||||
Fine-Grained Personal Access Tokens are ideal for:
|
||||
- **Individual users** scanning their own repositories
|
||||
- **Quick setup** without app registration overhead
|
||||
- **Temporary access** with mandatory expiration
|
||||
- **Repository-specific access** when you only need to scan certain repos
|
||||
|
||||
**Create a Fine-Grained Token:**
|
||||
|
||||
1. Navigate to **GitHub Settings** > **Developer settings**.
|
||||
|
||||
2. Click **Personal access tokens** > **Fine-grained tokens** > **Generate new token**.
|
||||
|
||||
3. Configure basic settings:
|
||||
- **Token name**: Descriptive name (e.g., "Prowler Security Scanner")
|
||||
- **Expiration**: 90 days or less (recommended)
|
||||
- **Resource owner**:
|
||||
- Personal account (for user repositories)
|
||||
- Organization name (for organization scanning - requires admin approval)
|
||||
- **Repository access**: "All repositories" (recommended)
|
||||
|
||||
4. Configure **Repository permissions**:
|
||||
- Administration: Read
|
||||
- Contents: Read
|
||||
- Metadata: Read (auto-selected)
|
||||
- Dependabot alerts: Read
|
||||
|
||||
5. Configure **Organization permissions** (only appears when Resource owner is an organization):
|
||||
- Administration: Read
|
||||
- Members: Read
|
||||
|
||||
6. Configure **Account permissions**:
|
||||
- Email addresses: Read (optional)
|
||||
|
||||
7. Click **Generate token** and copy the token immediately.
|
||||
|
||||
<Warning>
|
||||
GitHub shows the token only once. Store it securely.
|
||||
Classic tokens provide broad permissions that may exceed what Prowler actually needs. Use fine-grained tokens instead for better security.
|
||||
|
||||
</Warning>
|
||||
1. **Navigate to GitHub Settings**
|
||||
- Open [GitHub](https://github.com) and sign in
|
||||
- Click the profile picture in the top right corner
|
||||
- Select "Settings" from the dropdown menu
|
||||
|
||||

|
||||
2. **Access Developer Settings**
|
||||
- Scroll down the left sidebar
|
||||
- Click "Developer settings"
|
||||
|
||||
#### OAuth App Token
|
||||
3. **Generate Classic Token**
|
||||
- Click "Personal access tokens"
|
||||
- Select "Tokens (classic)"
|
||||
- Click "Generate new token"
|
||||
|
||||
**Recommended OAuth App Use Cases:**
|
||||
4. **Configure Token Permissions**
|
||||
To enable Prowler functionality, configure the following scopes:
|
||||
- `repo`: Full control of private repositories (includes `repo:status` and `repo:contents`)
|
||||
- `read:org`: Read organization and team membership
|
||||
- `read:user`: Read user profile data
|
||||
- `security_events`: Access security events (secret scanning and Dependabot alerts)
|
||||
- `read:enterprise`: Read enterprise data (if using GitHub Enterprise)
|
||||
|
||||
Use OAuth App Tokens when building applications that need delegated user permissions and explicit user authorization.
|
||||
5. **Copy and Store the Token**
|
||||
- Copy the generated token immediately (GitHub displays tokens only once)
|
||||
- Store tokens securely using environment variables
|
||||
|
||||
**OAuth Scopes:**
|
||||
## OAuth App Token
|
||||
|
||||
- `repo`: Full control of repositories
|
||||
- `read:org`: Read organization and team membership
|
||||
- `read:user`: Read user profile data
|
||||
OAuth Apps enable applications to act on behalf of users with explicit consent.
|
||||
|
||||
**Create an OAuth App:**
|
||||
### Create an OAuth App Token
|
||||
|
||||
1. Navigate to **GitHub Settings** > **Developer settings** > **OAuth Apps**.
|
||||
1. **Navigate to Developer Settings**
|
||||
- Open GitHub Settings → Developer settings
|
||||
- Click "OAuth Apps"
|
||||
|
||||
2. Click **New OAuth App** and complete:
|
||||
- Application name
|
||||
- Homepage URL
|
||||
- Authorization callback URL
|
||||
2. **Register New Application**
|
||||
- Click "New OAuth App"
|
||||
- Complete the required fields:
|
||||
- **Application name**: Descriptive application name
|
||||
- **Homepage URL**: Application homepage
|
||||
- **Authorization callback URL**: User redirection URL after authorization
|
||||
|
||||
3. Obtain authorization code:
|
||||
3. **Obtain Authorization Code**
|
||||
- Request authorization code (replace `{app_id}` with the application ID):
|
||||
```
|
||||
https://github.com/login/oauth/authorize?client_id={app_id}
|
||||
```
|
||||
|
||||
4. Exchange authorization code for access token:
|
||||
4. **Exchange Code for Token**
|
||||
- Exchange authorization code for access token (replace `{app_id}`, `{secret}`, and `{code}`):
|
||||
```
|
||||
https://github.com/login/oauth/access_token?code={code}&client_id={app_id}&client_secret={secret}
|
||||
```
|
||||
|
||||
#### GitHub App Credentials
|
||||
## GitHub App Credentials
|
||||
GitHub Apps provide the recommended integration method for accessing multiple repositories or organizations.
|
||||
|
||||
<Note>
|
||||
**When to Use GitHub Apps**
|
||||
### Create a GitHub App
|
||||
|
||||
GitHub Apps are ideal for:
|
||||
- **Organization-wide scanning** without tying access to a personal account
|
||||
- **CI/CD pipelines** where you need machine identity (not user-based)
|
||||
- **Multi-organization setups** with centralized app management
|
||||
- **Audit compliance** where you need to track app-level access separately from users
|
||||
1. **Navigate to Developer Settings**
|
||||
- Open GitHub Settings → Developer settings
|
||||
- Click "GitHub Apps"
|
||||
|
||||
GitHub Apps use the same permission model as Fine-Grained PATs - both provide full access to all Prowler checks.
|
||||
</Note>
|
||||
2. **Create New GitHub App**
|
||||
- Click "New GitHub App"
|
||||
- Complete the required fields:
|
||||
- **GitHub App name**: Choose a unique, descriptive name (e.g., "Prowler Security Scanner")
|
||||
- **Homepage URL**: Enter your organization's website or the Prowler documentation URL (e.g., `https://prowler.com` or `https://docs.prowler.com`). This is just for reference and doesn't affect functionality.
|
||||
- **Webhook URL**: Leave blank or uncheck "Active" under Webhook. Prowler doesn't require webhooks since it performs on-demand scans rather than responding to GitHub events.
|
||||
- **Webhook secret**: Leave blank (not needed for Prowler)
|
||||
- **Permissions**: Configure in the next step (see below)
|
||||
|
||||
**GitHub App Permissions:**
|
||||
<Note>
|
||||
**About Homepage URL and Webhooks**
|
||||
|
||||
If a GitHub App is required:
|
||||
The Homepage URL is purely informational and can be any valid URL - it's just displayed to users who view the app. Use your company website, your GitHub organization URL, or even `https://docs.prowler.com`.
|
||||
|
||||
**Repository permissions:**
|
||||
Webhooks are **not required** for Prowler. Since Prowler performs on-demand security scans when you run it (rather than automatically responding to GitHub events), you can safely disable webhooks or leave the URL blank.
|
||||
</Note>
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Branch protection, security settings | All branch protection checks, `repository_secret_scanning_enabled` |
|
||||
| **Contents** | Read | File existence checks | `repository_public_has_securitymd_file`, `repository_has_codeowners_file` |
|
||||
| **Metadata** | Read | Basic repository information | All checks (automatically granted) |
|
||||
| **Dependabot alerts** | Read | Dependency vulnerability scanning | `repository_dependency_scanning_enabled` |
|
||||
3. **Configure Permissions**
|
||||
To enable Prowler functionality, configure these permissions:
|
||||
- **Repository permissions**:
|
||||
- Contents (Read)
|
||||
- Metadata (Read)
|
||||
- Pull requests (Read)
|
||||
- **Organization permissions**:
|
||||
- Members (Read)
|
||||
- Administration (Read)
|
||||
- **Account permissions**:
|
||||
- Email addresses (Read)
|
||||
|
||||
**Organization permissions:**
|
||||
4. **Where can this GitHub App be installed?**
|
||||
- Select "Any account" to be able to install the GitHub App in any organization.
|
||||
|
||||
| Permission | Access Level | Purpose | Checks Enabled |
|
||||
|------------|-------------|---------|----------------|
|
||||
| **Administration** | Read | Organization security policies | `organization_members_mfa_required`, `organization_repository_creation_limited`, `organization_default_repository_permission_strict` |
|
||||
| **Members** | Read | Member access reviews | Organization membership auditing |
|
||||
5. **Generate Private Key**
|
||||
- Scroll to the "Private keys" section after app creation
|
||||
- Click "Generate a private key"
|
||||
- Download the `.pem` file and store securely
|
||||
|
||||
**Create a GitHub App:**
|
||||
5. **Record App ID**
|
||||
- Locate the App ID at the top of the GitHub App settings page
|
||||
|
||||
1. Navigate to **GitHub Settings** > **Developer settings** > **GitHub Apps**.
|
||||
### Install the GitHub App
|
||||
|
||||
2. Click **New GitHub App** and complete:
|
||||
- **GitHub App name**: Descriptive name (e.g., "Prowler Security Scanner")
|
||||
- **Homepage URL**: Your organization's URL or Prowler documentation
|
||||
- **Webhook**: Uncheck "Active" (Prowler doesn't need webhooks)
|
||||
1. **Install Application**
|
||||
- Navigate to GitHub App settings
|
||||
- Click "Install App" in the left sidebar
|
||||
- Select the target account/organization
|
||||
- Choose specific repositories or select "All repositories"
|
||||
|
||||
3. Configure **Repository permissions** (see table above):
|
||||
- Administration: Read
|
||||
- Contents: Read
|
||||
- Metadata: Read (auto-selected)
|
||||
- Dependabot alerts: Read
|
||||
## Best Practices
|
||||
|
||||
4. Configure **Organization permissions** (see table above):
|
||||
- Administration: Read
|
||||
- Members: Read
|
||||
### Security Considerations
|
||||
|
||||
5. Under **Where can this GitHub App be installed?**, select:
|
||||
- "Only on this account" for single-organization use
|
||||
- "Any account" if you need to install across multiple organizations
|
||||
Implement the following security measures:
|
||||
|
||||
6. Click **Create GitHub App**.
|
||||
- **Secure Credential Storage**: Store credentials using environment variables instead of hardcoding tokens
|
||||
- **Secrets Management**: Use dedicated secrets management systems in production environments
|
||||
- **Regular Token Rotation**: Rotate tokens and keys regularly
|
||||
- **Least Privilege Principle**: Grant only minimum required permissions
|
||||
- **Permission Auditing**: Review and audit permissions regularly
|
||||
- **Token Expiration**: Set appropriate expiration times for tokens
|
||||
- **Usage Monitoring**: Monitor token usage and revoke unused tokens
|
||||
|
||||
7. On the app settings page:
|
||||
- Record the **App ID** (displayed at the top)
|
||||
- Click **Generate a private key** and download the `.pem` file
|
||||
### Authentication Method Selection
|
||||
|
||||
8. Install the GitHub App:
|
||||
- Click **Install App** in the left sidebar
|
||||
- Select target account/organization
|
||||
- Choose "All repositories" or select specific repositories
|
||||
- Click **Install**
|
||||
Choose the appropriate method based on use case:
|
||||
|
||||
<Warning>
|
||||
**Private Key Security**
|
||||
- **Personal Access Token**: Individual use, testing, or simple automation
|
||||
- **OAuth App Token**: Applications requiring user consent and delegation
|
||||
- **GitHub App**: Production integrations, especially for organizations
|
||||
|
||||
Store the `.pem` private key securely. Anyone with this key can authenticate as your GitHub App. Never commit it to version control.
|
||||
</Warning>
|
||||
## Troubleshooting Common Issues
|
||||
|
||||
---
|
||||
### Insufficient Permissions
|
||||
- Verify token/app has necessary scopes/permissions
|
||||
- Check organization restrictions on third-party applications
|
||||
|
||||
## Prowler Cloud Authentication
|
||||
|
||||
For step-by-step setup instructions for Prowler Cloud, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloudapp).
|
||||
|
||||
### Using Personal Access Token
|
||||
|
||||
1. In Prowler Cloud, navigate to **Configuration** > **Cloud Providers** > **Add Cloud Provider** > **GitHub**.
|
||||
|
||||
2. Enter your GitHub Account ID (username or organization name).
|
||||
|
||||
3. Select **Personal Access Token** as the authentication method.
|
||||
|
||||
4. Enter your Fine-Grained Personal Access Token.
|
||||
|
||||
5. Click **Verify** to test the connection, then **Save**.
|
||||
|
||||
### Using OAuth App Token
|
||||
|
||||
1. Follow the same steps as Personal Access Token.
|
||||
|
||||
2. Select **OAuth App Token** as the authentication method.
|
||||
|
||||
3. Enter your OAuth App Token.
|
||||
|
||||
### Using GitHub App
|
||||
|
||||
1. Follow the same steps as Personal Access Token.
|
||||
|
||||
2. Select **GitHub App** as the authentication method.
|
||||
|
||||
3. Enter your GitHub App ID and upload the private key (`.pem` file).
|
||||
|
||||
For complete step-by-step instructions, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cloudapp).
|
||||
|
||||
---
|
||||
|
||||
## Prowler CLI Authentication
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
Prowler CLI automatically detects credentials using environment variables in this order:
|
||||
|
||||
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
|
||||
2. `GITHUB_OAUTH_APP_TOKEN`
|
||||
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
|
||||
|
||||
### Using Environment Variables (Recommended)
|
||||
|
||||
```bash
|
||||
# Personal Access Token (Recommended)
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN="ghp_xxxxxxxxxxxx"
|
||||
prowler github
|
||||
|
||||
# OAuth App Token
|
||||
export GITHUB_OAUTH_APP_TOKEN="oauth_token_here"
|
||||
prowler github
|
||||
|
||||
# GitHub App
|
||||
export GITHUB_APP_ID="123456"
|
||||
export GITHUB_APP_KEY="$(cat /path/to/private-key.pem)"
|
||||
prowler github
|
||||
```
|
||||
|
||||
### Using CLI Flags
|
||||
|
||||
```bash
|
||||
# Personal Access Token
|
||||
prowler github --personal-access-token ghp_xxxxxxxxxxxx
|
||||
|
||||
# OAuth App Token
|
||||
prowler github --oauth-app-token oauth_token_here
|
||||
|
||||
# GitHub App
|
||||
prowler github --github-app-id 123456 --github-app-key-path /path/to/private-key.pem
|
||||
```
|
||||
|
||||
### Scan Scope
|
||||
|
||||
<Warning>
|
||||
**Understanding Scan Scope**
|
||||
|
||||
What Prowler scans depends on the invocation method:
|
||||
|
||||
| Command | What Gets Scanned | Organization Checks? |
|
||||
|---------|------------------|---------------------|
|
||||
| `prowler github` | All accessible repositories | No |
|
||||
| `prowler github --repository owner/repo` | Single repository | No |
|
||||
| `prowler github --organization org-name` | Organization repos + settings | Yes |
|
||||
|
||||
**Key Point:** Scanning user repositories does NOT include organization-level checks. To audit organization MFA, security policies, etc., you must use `--organization`.
|
||||
|
||||
</Warning>
|
||||
|
||||
**Scan user repositories:**
|
||||
|
||||
```bash
|
||||
prowler github
|
||||
prowler github --repository username/my-repo
|
||||
```
|
||||
|
||||
**Scan organizations:**
|
||||
|
||||
```bash
|
||||
prowler github --organization org-name
|
||||
prowler github --organization org1 --organization org2
|
||||
```
|
||||
|
||||
**Filter scans:**
|
||||
|
||||
```bash
|
||||
prowler github --severity critical
|
||||
prowler github --checks repository_default_branch_protection_enabled
|
||||
prowler github --compliance cis_1.0_github
|
||||
```
|
||||
|
||||
For complete step-by-step instructions, see the [Getting Started Guide](/user-guide/providers/github/getting-started-github#prowler-cli).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Insufficient Permissions" Errors
|
||||
|
||||
**Symptom:** Checks fail or return `MANUAL` status.
|
||||
|
||||
**Solutions:**
|
||||
1. Verify token has all required permissions
|
||||
2. For organization scans, ensure organization approved the Fine-Grained Token
|
||||
3. For merge settings checks, accept `MANUAL` status (Write permission not recommended)
|
||||
|
||||
### "No Organizations Found"
|
||||
|
||||
**Symptom:** Prowler doesn't find organizations even though you're a member.
|
||||
|
||||
**Cause:** Fine-Grained Token's Resource Owner is set to personal account.
|
||||
|
||||
**Solution:** Create a new token with Resource Owner set to the organization and get it approved by an admin.
|
||||
|
||||
### Organization Checks Return `MANUAL`
|
||||
|
||||
**Symptom:** Checks like `organization_members_mfa_required` return `MANUAL`.
|
||||
|
||||
**Cause:** Token lacks `Organization → Administration: Read` permission.
|
||||
|
||||
**Solutions:**
|
||||
1. Edit token and grant `Organization → Administration: Read`
|
||||
2. Ensure token's **Resource owner** is the organization (not personal account)
|
||||
3. Get organization admin approval
|
||||
|
||||
### Token Not Showing Organization Permissions
|
||||
|
||||
**Symptom:** Can't find Organization permissions section when creating token.
|
||||
|
||||
**Cause:** **Resource owner** is set to personal account.
|
||||
|
||||
**Solution:** Change **Resource owner** dropdown to the organization name. Organization permissions section will appear.
|
||||
### Token Expiration
|
||||
- Confirm token has not expired
|
||||
- Verify fine-grained tokens have correct resource access
|
||||
|
||||
### Rate Limiting
|
||||
- GitHub implements API call rate limits
|
||||
- Consider GitHub Apps for higher rate limits
|
||||
|
||||
**Symptom:** "API rate limit exceeded" errors.
|
||||
|
||||
**Solutions:**
|
||||
- Scan during off-peak hours
|
||||
- Use `--repository` to scan specific repos instead of all
|
||||
- Implement delays between scans
|
||||
|
||||
### Token Expired or Revoked
|
||||
|
||||
**Symptom:** Authentication fails with valid-looking token.
|
||||
|
||||
**Solutions:**
|
||||
1. Check token expiration date in GitHub settings
|
||||
2. Verify token wasn't revoked
|
||||
3. For Fine-Grained Tokens, check if organization approval was revoked
|
||||
4. Generate a new token
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [GitHub REST API Authentication](https://docs.github.com/en/rest/authentication)
|
||||
- [Fine-Grained Personal Access Tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token)
|
||||
- [GitHub Apps Documentation](https://docs.github.com/en/apps)
|
||||
- [GitHub API Rate Limits](https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api)
|
||||
- [Getting Started Guide](/user-guide/providers/github/getting-started-github)
|
||||
### Organization Settings
|
||||
- Some organizations restrict third-party applications
|
||||
- Contact organization administrator if access is denied
|
||||
@@ -2,276 +2,96 @@
|
||||
title: 'Getting Started with GitHub'
|
||||
---
|
||||
|
||||
This guide covers setting up GitHub security scanning with Prowler. Choose a preferred interface below:
|
||||
|
||||
<Note>
|
||||
**Understanding GitHub Scan Scope**
|
||||
|
||||
Prowler can scan either:
|
||||
- **User Repositories**: All repositories owned by or accessible to a specific GitHub user
|
||||
- **Organizations**: Repositories and organization-level settings
|
||||
|
||||
**Important**: Scanning user repositories does NOT include organization-level checks (MFA requirements, security policies, etc.). To scan organizations, you must explicitly configure them.
|
||||
|
||||
</Note>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Prowler Cloud/App" icon="cloud" href="#prowler-cloudapp">
|
||||
Web-based interface with centralized management
|
||||
</Card>
|
||||
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
|
||||
Command-line interface for local or automated scans
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
---
|
||||
|
||||
## Prowler Cloud/App
|
||||
## Prowler App
|
||||
|
||||
<iframe width="560" height="380" src="https://www.youtube-nocookie.com/embed/9ETI84Xpu2g" title="Prowler Cloud Onboarding Github" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen="1"></iframe>
|
||||
|
||||
> Walkthrough video onboarding a GitHub Account using GitHub App.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before adding GitHub to Prowler Cloud/App, ensure you have:
|
||||
|
||||
1. **GitHub Account Access**
|
||||
- Personal GitHub account, OR
|
||||
- Admin access to a GitHub organization
|
||||
|
||||
2. **Authentication Credentials**
|
||||
- Choose one method (see [Authentication Guide](/user-guide/providers/github/authentication)):
|
||||
- **Fine-Grained Personal Access Token** (Recommended)
|
||||
- OAuth App Token
|
||||
- GitHub App Credentials (Not Recommended - limited data access)
|
||||
|
||||
### Step 1: Access Prowler Cloud/App
|
||||
|
||||
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
|
||||
2. Go to **Configuration** → **Cloud Providers**
|
||||
2. Go to "Configuration" > "Cloud Providers"
|
||||
|
||||

|
||||
|
||||
3. Click **Add Cloud Provider**
|
||||
3. Click "Add Cloud Provider"
|
||||
|
||||

|
||||
|
||||
4. Select **GitHub**
|
||||
4. Select "GitHub"
|
||||
|
||||

|
||||
|
||||
### Step 2: Configure GitHub Account
|
||||
|
||||
5. Add the **GitHub Account ID** and an optional alias:
|
||||
- **Account ID**: Your GitHub username (e.g., `username`) or organization name (e.g., `org-name`)
|
||||
- **Alias** (optional): Friendly name for this connection (e.g., `My Personal Repos` or `Prowler Org`)
|
||||
5. Add the GitHub Account ID (username or organization name) and an optional alias, then click "Next"
|
||||
|
||||

|
||||
|
||||
6. Click **Next**
|
||||
### Step 2: Choose the preferred authentication method
|
||||
|
||||
### Step 3: Choose Authentication Method
|
||||
|
||||
<Note>
|
||||
**Recommended: Fine-Grained Personal Access Token**
|
||||
|
||||
**Fine-Grained Personal Access Tokens** are strongly recommended because they provide:
|
||||
- Best data access for comprehensive security scanning
|
||||
- Granular permission control
|
||||
- Resource-specific access
|
||||
|
||||
**GitHub Apps are not recommended** — they provide the most limited access to GitHub data for security scanning purposes.
|
||||
</Note>
|
||||
|
||||
7. Select your preferred authentication method:
|
||||
6. Choose the preferred authentication method:
|
||||
|
||||

|
||||
|
||||
7. Configure the authentication method:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Personal Access Token (Recommended)">
|
||||
<Tab title="Personal Access Token">
|
||||

|
||||
|
||||
**Recommended method** - provides the best data access for security scanning.
|
||||
|
||||
1. Enter your Fine-Grained Personal Access Token
|
||||
2. Click **Verify** to test the connection
|
||||
3. Click **Save**
|
||||
|
||||
**Don't have a token yet?** See [How to create a Personal Access Token](/user-guide/providers/github/authentication#create-a-fine-grained-personal-access-token)
|
||||
For more details on how to create a Personal Access Token, see [Authentication > Personal Access Token](/user-guide/providers/github/authentication#personal-access-token-pat).
|
||||
</Tab>
|
||||
|
||||
<Tab title="OAuth App Token">
|
||||

|
||||
|
||||
For applications requiring user consent and delegated permissions.
|
||||
|
||||
1. Enter your OAuth App Token
|
||||
2. Click **Verify** to test the connection
|
||||
3. Click **Save**
|
||||
|
||||
**Don't have an OAuth token?** See [How to create an OAuth App Token](/user-guide/providers/github/authentication#oauth-app-token)
|
||||
For more details on how to create an OAuth App Token, see [Authentication > OAuth App Token](/user-guide/providers/github/authentication#oauth-app-token).
|
||||
</Tab>
|
||||
|
||||
<Tab title="GitHub App (Not Recommended)">
|
||||
<Tab title="GitHub App">
|
||||

|
||||
|
||||
<Warning>
|
||||
**Not recommended** - most limited data access. Use only if required by organization policy.
|
||||
</Warning>
|
||||
|
||||
1. Enter your GitHub App ID
|
||||
2. Upload or paste your Private Key (`.pem` file)
|
||||
3. Click **Verify** to test the connection
|
||||
4. Click **Save**
|
||||
|
||||
**Don't have a GitHub App?** See [How to create a GitHub App](/user-guide/providers/github/authentication#github-app-credentials)
|
||||
For more details on how to create a GitHub App, see [Authentication > GitHub App](/user-guide/providers/github/authentication#github-app-credentials).
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
|
||||
8. Click **Start Scan** to begin your first security assessment
|
||||
|
||||
### Step 5: View Results
|
||||
|
||||
Once the scan completes, you can:
|
||||
- View security findings in the dashboard
|
||||
- Export results in multiple formats (JSON, CSV, HTML)
|
||||
- Set up continuous scanning schedules
|
||||
- Configure alerts for critical findings
|
||||
|
||||
---
|
||||
|
||||
## Prowler CLI
|
||||
|
||||
### Prerequisites
|
||||
### Authentication
|
||||
|
||||
Before running Prowler CLI for GitHub, ensure you have:
|
||||
|
||||
1. **Prowler Installed**
|
||||
```bash
|
||||
# Install via pip
|
||||
pip install prowler
|
||||
|
||||
# Or via poetry
|
||||
poetry install
|
||||
```
|
||||
|
||||
2. **Authentication Credentials**
|
||||
- Choose one method (see [Authentication Guide](/user-guide/providers/github/authentication)):
|
||||
- **Fine-Grained Personal Access Token** (Recommended)
|
||||
- OAuth App Token
|
||||
- GitHub App Credentials (Not Recommended)
|
||||
|
||||
### Authentication Setup
|
||||
|
||||
Prowler CLI automatically detects authentication credentials using environment variables in this order:
|
||||
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
|
||||
|
||||
1. `GITHUB_PERSONAL_ACCESS_TOKEN`
|
||||
2. `GITHUB_OAUTH_APP_TOKEN`
|
||||
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY`
|
||||
3. `GITHUB_APP_ID` and `GITHUB_APP_KEY` (where the key is the content of the private key file)
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Environment Variables (Recommended)">
|
||||
```bash
|
||||
# Personal Access Token (Recommended)
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN="ghp_xxxxxxxxxxxx"
|
||||
<Note>
|
||||
Ensure the corresponding environment variables are set up before running Prowler for automatic detection when not specifying the login method.
|
||||
|
||||
# OAuth App Token
|
||||
export GITHUB_OAUTH_APP_TOKEN="oauth_token_here"
|
||||
</Note>
|
||||
For more details on how to set up authentication with GitHub, see [Authentication > GitHub](/user-guide/providers/github/authentication).
|
||||
|
||||
# GitHub App
|
||||
export GITHUB_APP_ID="123456"
|
||||
export GITHUB_APP_KEY="$(cat /path/to/private-key.pem)"
|
||||
#### Personal Access Token (PAT)
|
||||
|
||||
Use this method by providing a personal access token directly.
|
||||
|
||||
```console
|
||||
prowler github --personal-access-token pat
|
||||
```
|
||||
|
||||
Then run Prowler without additional flags:
|
||||
```bash
|
||||
prowler github
|
||||
```
|
||||
</Tab>
|
||||
#### OAuth App Token
|
||||
|
||||
<Tab title="CLI Flags">
|
||||
```bash
|
||||
# Personal Access Token
|
||||
prowler github --personal-access-token ghp_xxxxxxxxxxxx
|
||||
Authenticate using an OAuth app token.
|
||||
|
||||
# OAuth App Token
|
||||
prowler github --oauth-app-token oauth_token_here
|
||||
|
||||
# GitHub App
|
||||
prowler github --github-app-id 123456 --github-app-key-path /path/to/private-key.pem
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
**Don't have credentials yet?** See the [Authentication Guide](/user-guide/providers/github/authentication) for step-by-step instructions.
|
||||
|
||||
### Scan Scope: Understanding What Gets Scanned
|
||||
|
||||
<Warning>
|
||||
**Distinguishing User Scans from Organization Scans**
|
||||
|
||||
The scan scope depends entirely on the Prowler CLI invocation method:
|
||||
|
||||
| Command | What Gets Scanned | Organization Checks Included? |
|
||||
|---------|------------------|-------------------------------|
|
||||
| `prowler github` | All repositories the token has access to | No |
|
||||
| `prowler github --repository owner/repo` | Single specified repository | No |
|
||||
| `prowler github --organization org-name` | Organization repos + org settings | Yes |
|
||||
| `prowler github --organization org-name --repository owner/repo` | Organization + single repository | Yes |
|
||||
|
||||
**Key Points:**
|
||||
- Scanning **user repositories** does NOT run organization-level checks
|
||||
- To audit organization MFA, security policies, etc., the `--organization` flag is required
|
||||
- Members of multiple organizations should specify each one explicitly
|
||||
|
||||
</Warning>
|
||||
|
||||
### Scanning User Repositories
|
||||
|
||||
Scan repositories owned by your user account:
|
||||
|
||||
```bash
|
||||
# Scan all repositories accessible to your token
|
||||
prowler github
|
||||
|
||||
# Scan a specific repository
|
||||
prowler github --repository username/my-repo
|
||||
|
||||
# Scan multiple specific repositories
|
||||
prowler github --repository username/repo1 --repository username/repo2
|
||||
```console
|
||||
prowler github --oauth-app-token oauth_token
|
||||
```
|
||||
|
||||
**What gets scanned:**
|
||||
- Repository security settings
|
||||
- Branch protection rules
|
||||
- Secret scanning configuration
|
||||
- Dependabot settings
|
||||
- Organization-level policies (not included)
|
||||
#### GitHub App Credentials
|
||||
|
||||
### Scanning Organizations
|
||||
Use GitHub App credentials by specifying the App ID and the private key path.
|
||||
|
||||
Scan organization repositories and organization-level security settings:
|
||||
|
||||
```bash
|
||||
# Scan a single organization
|
||||
prowler github --organization prowler-cloud
|
||||
|
||||
# Scan multiple organizations
|
||||
prowler github --organization org1 --organization org2
|
||||
|
||||
# Scan organization and specific repositories within it
|
||||
prowler github --organization my-org --repository my-org/critical-repo
|
||||
```console
|
||||
prowler github --github-app-id app_id --github-app-key-path app_key_path
|
||||
```
|
||||
|
||||
**What gets scanned:**
|
||||
- All organization repositories
|
||||
- Repository security settings
|
||||
- Organization MFA requirements
|
||||
- Organization security policies
|
||||
- Member access and permissions
|
||||
|
||||
### Scan Scoping
|
||||
|
||||
Scan scoping controls which repositories and organizations Prowler includes in a security assessment. By default, Prowler scans all repositories accessible to the authenticated user or organization. To limit the scan to specific repositories or organizations, use the following flags.
|
||||
@@ -323,120 +143,3 @@ In this case, `my-repo` is qualified as `my-org/my-repo`, while `other-owner/oth
|
||||
<Note>
|
||||
The `--repository` and `--organization` flags can be combined with any authentication method.
|
||||
</Note>
|
||||
|
||||
### Filtering Scans
|
||||
|
||||
Customize your scan scope with these options:
|
||||
|
||||
```bash
|
||||
# Run only critical severity checks
|
||||
prowler github --severity critical
|
||||
|
||||
# Run specific checks
|
||||
prowler github --checks repository_default_branch_protection_enabled,organization_members_mfa_required
|
||||
|
||||
# Exclude specific checks
|
||||
prowler github --excluded-checks repository_archived
|
||||
|
||||
# Scan with specific compliance framework
|
||||
prowler github --compliance cis_1.0_github
|
||||
|
||||
# Output results in specific format
|
||||
prowler github --output-formats json,csv,html
|
||||
```
|
||||
|
||||
### Example Workflows
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Quick Security Assessment">
|
||||
```bash
|
||||
# Scan your personal repositories for critical issues
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN="ghp_xxxx"
|
||||
prowler github --severity critical high
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Organization Compliance Audit">
|
||||
```bash
|
||||
# Full organization scan with CIS compliance
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN="ghp_xxxx"
|
||||
prowler github \
|
||||
--organization prowler-cloud \
|
||||
--compliance cis_1.0_github \
|
||||
--output-formats json,html
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="CI/CD Integration">
|
||||
```bash
|
||||
# Scan specific repository in CI pipeline
|
||||
prowler github \
|
||||
--personal-access-token "$GITHUB_TOKEN" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--severity critical \
|
||||
--output-formats json
|
||||
|
||||
# Exit with non-zero if critical findings
|
||||
if grep -q '"Status": "FAIL".*"Severity": "critical"' prowler-output*.json; then
|
||||
echo "Critical security issues found!"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="Multi-Organization Scan">
|
||||
```bash
|
||||
# Scan multiple organizations you're part of
|
||||
export GITHUB_PERSONAL_ACCESS_TOKEN="ghp_xxxx"
|
||||
prowler github \
|
||||
--organization org1 \
|
||||
--organization org2 \
|
||||
--organization org3 \
|
||||
--output-formats csv
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Viewing Prowler CLI Scan Results
|
||||
|
||||
Prowler CLI generates results in multiple formats:
|
||||
|
||||
```bash
|
||||
# Results are saved in ./output/ directory by default
|
||||
ls output/
|
||||
|
||||
# View HTML report in browser
|
||||
open output/prowler-output-*.html
|
||||
|
||||
# Parse JSON results with jq
|
||||
cat output/prowler-output-*.json | jq '.findings[] | select(.Status=="FAIL")'
|
||||
|
||||
# Import CSV into spreadsheet
|
||||
open output/prowler-output-*.csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Authentication Guide" icon="key" href="/user-guide/providers/github/authentication">
|
||||
Detailed permissions and token creation
|
||||
</Card>
|
||||
<Card title="Available Checks" icon="list-check" href="https://hub.prowler.com/github">
|
||||
Browse all GitHub security checks
|
||||
</Card>
|
||||
<Card title="Compliance Frameworks" icon="shield-check" href="https://hub.prowler.com/compliance">
|
||||
CIS, NIST, and other frameworks
|
||||
</Card>
|
||||
<Card title="Troubleshooting" icon="circle-question" href="/user-guide/providers/github/authentication#troubleshooting">
|
||||
Common issues and solutions
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [GitHub REST API Documentation](https://docs.github.com/en/rest)
|
||||
- [Fine-Grained Personal Access Tokens](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token)
|
||||
- [GitHub Security Best Practices](https://docs.github.com/en/code-security)
|
||||
- [Prowler CLI Reference](/getting-started/basic-usage/prowler-cli)
|
||||
@@ -10,7 +10,7 @@ Prowler's Image provider enables comprehensive container image security scanning
|
||||
|
||||
* **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues.
|
||||
* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan.
|
||||
* **Authentication:** No registry authentication is required for public images. For private registries, configure Docker credentials via `docker login` before scanning.
|
||||
* **Authentication:** No registry authentication is required for public images. For private registries, credentials can be provided via environment variables or manual `docker login`.
|
||||
* **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
|
||||
|
||||
## Prowler CLI
|
||||
@@ -173,25 +173,147 @@ prowler image -I large-image:latest --timeout 10m
|
||||
|
||||
The timeout accepts values in seconds (`s`), minutes (`m`), or hours (`h`). Default: `5m`.
|
||||
|
||||
### Authentication for Private Registries
|
||||
### Registry Scan Mode
|
||||
|
||||
The Image provider relies on Trivy for registry authentication. To scan images from private registries, configure Docker credentials before running the scan:
|
||||
Registry Scan Mode enumerates and scans all images from an OCI-compatible registry, Docker Hub namespace, or Amazon ECR registry. To activate it, use the `--registry` flag with the registry URL:
|
||||
|
||||
```bash
|
||||
# Log in to a private registry
|
||||
docker login myregistry.io
|
||||
prowler image --registry myregistry.io
|
||||
```
|
||||
|
||||
#### Discover Available Images
|
||||
|
||||
To list all repositories and tags available in the registry without running a scan, use the `--registry-list` flag. This is useful for discovering image names and tags before building filter regexes:
|
||||
|
||||
```bash
|
||||
prowler image --registry myregistry.io --registry-list
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```text
|
||||
Registry: myregistry.io (3 repositories, 8 images)
|
||||
|
||||
api-service (2 tags)
|
||||
latest, v3.1
|
||||
hub-scanner (3 tags)
|
||||
latest, v1.0, v2.0
|
||||
web-frontend (3 tags)
|
||||
latest, v1.0, v2.0
|
||||
```
|
||||
|
||||
Filters can be combined with `--registry-list` to preview the results before scanning:
|
||||
|
||||
```bash
|
||||
prowler image --registry myregistry.io --registry-list --image-filter "api.*"
|
||||
```
|
||||
|
||||
#### Filter Repositories
|
||||
|
||||
To filter repositories by name during enumeration, use the `--image-filter` flag with a Python regex pattern (matched via `re.search`):
|
||||
|
||||
```bash
|
||||
# Scan only repositories starting with "prod/"
|
||||
prowler image --registry myregistry.io --image-filter "^prod/"
|
||||
```
|
||||
|
||||
#### Filter Tags
|
||||
|
||||
To filter tags during enumeration, use the `--tag-filter` flag with a Python regex pattern:
|
||||
|
||||
```bash
|
||||
# Scan only semantic version tags
|
||||
prowler image --registry myregistry.io --tag-filter "^v\d+\.\d+\.\d+$"
|
||||
```
|
||||
|
||||
Both filters can be combined:
|
||||
|
||||
```bash
|
||||
prowler image --registry myregistry.io --image-filter "^prod/" --tag-filter "^(latest|v\d+)"
|
||||
```
|
||||
|
||||
#### Limit the Number of Images
|
||||
|
||||
To prevent accidentally scanning a large number of images, use the `--max-images` flag. The scan aborts if the discovered image count exceeds the limit:
|
||||
|
||||
```bash
|
||||
prowler image --registry myregistry.io --max-images 10
|
||||
```
|
||||
|
||||
Setting `--max-images` to `0` (default) disables the limit.
|
||||
|
||||
<Note>
|
||||
When `--registry-list` is active, the `--max-images` limit is not enforced because no scan is performed.
|
||||
</Note>
|
||||
|
||||
#### Skip TLS Verification
|
||||
|
||||
To connect to registries with self-signed certificates, use the `--registry-insecure` flag:
|
||||
|
||||
```bash
|
||||
prowler image --registry internal-registry.local --registry-insecure
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates.
|
||||
</Warning>
|
||||
|
||||
#### Supported Registries
|
||||
|
||||
Registry Scan Mode supports the following registry types:
|
||||
|
||||
* **OCI-compatible registries:** Any registry implementing the OCI Distribution Specification (e.g., Harbor, GitLab Container Registry, GitHub Container Registry).
|
||||
* **Docker Hub:** Specify a namespace with `--registry docker.io/{org_or_user}`. Public namespaces can be scanned without credentials; authenticated access is used automatically when `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are set.
|
||||
* **Amazon ECR:** Use the full ECR endpoint URL (e.g., `123456789.dkr.ecr.us-east-1.amazonaws.com`). Authentication is handled via AWS credentials.
|
||||
|
||||
### Authentication for Private Registries
|
||||
|
||||
To scan images from private registries, the Image provider supports three authentication methods. Prowler uses the first available method in this priority order:
|
||||
|
||||
#### 1. Basic Authentication (Environment Variables)
|
||||
|
||||
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler automatically runs `docker login`, pulls the image, and performs a `docker logout` after the scan completes:
|
||||
|
||||
```bash
|
||||
export REGISTRY_USERNAME="myuser"
|
||||
export REGISTRY_PASSWORD="mypassword"
|
||||
|
||||
# Then scan the image
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
Trivy automatically uses credentials from Docker's credential store (`~/.docker/config.json`).
|
||||
Both variables must be set for this method to activate. Prowler handles the full lifecycle — login, pull, scan, and cleanup — without any manual Docker commands.
|
||||
|
||||
#### 2. Token-Based Authentication
|
||||
|
||||
To authenticate using a registry token (such as a bearer or OAuth2 token), set the `REGISTRY_TOKEN` environment variable. Prowler passes the token directly to Trivy:
|
||||
|
||||
```bash
|
||||
export REGISTRY_TOKEN="my-registry-token"
|
||||
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
This method is useful for registries that support token-based access without requiring a username and password.
|
||||
|
||||
#### 3. Manual Docker Login (Fallback)
|
||||
|
||||
If no environment variables are set, Prowler relies on existing credentials in Docker's credential store (`~/.docker/config.json`). To configure credentials manually before scanning:
|
||||
|
||||
```bash
|
||||
docker login myregistry.io
|
||||
|
||||
prowler image -I myregistry.io/myapp:v1.0
|
||||
```
|
||||
|
||||
<Note>
|
||||
When basic authentication is active (method 1), Prowler automatically logs out from all authenticated registries after the scan completes. Manual `docker login` sessions (method 3) are not affected by this cleanup.
|
||||
</Note>
|
||||
|
||||
### Troubleshooting Common Scan Errors
|
||||
|
||||
The Image provider categorizes common Trivy errors with actionable guidance:
|
||||
|
||||
* **Authentication failure (401/403):** Registry credentials are missing or invalid. Run `docker login` for the target registry and retry the scan.
|
||||
* **Authentication failure (401/403):** Registry credentials are missing or invalid. Verify the `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` or `REGISTRY_TOKEN` environment variables, or run `docker login` for the target registry and retry the scan.
|
||||
* **Image not found (404):** The specified image name, tag, or registry is incorrect. Verify the image reference exists and is accessible.
|
||||
* **Rate limited (429):** The container registry is throttling requests. Wait before retrying, or authenticate to increase rate limits.
|
||||
* **Network issue:** Trivy cannot reach the registry due to connectivity problems. Check network access, DNS resolution, and firewall rules.
|
||||
Generated
+2
-14
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -2139,18 +2139,6 @@ files = [
|
||||
{file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "defusedxml"
|
||||
version = "0.7.1"
|
||||
description = "XML bomb protection for Python stdlib modules"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
|
||||
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.2.18"
|
||||
@@ -6893,4 +6881,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
content-hash = "509440ff7a10d735686d330ac032f824fc92cf2dbacc66371e688ae1dd25dc2f"
|
||||
content-hash = "f1ac30f34fd838017ad24702043564e2c37afd1fdbf674cf5e5def79082463d5"
|
||||
+6
-10
@@ -20,12 +20,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- OpenStack compute 7 new checks [(#9944)](https://github.com/prowler-cloud/prowler/pull/9944)
|
||||
- CSA CCM 4.0 for the Alibaba Cloud provider [(#10061)](https://github.com/prowler-cloud/prowler/pull/10061)
|
||||
- ECS Exec (ECS-006) privilege escalation detection via `ecs:ExecuteCommand` + `ecs:DescribeTasks` [(#10066)](https://github.com/prowler-cloud/prowler/pull/10066)
|
||||
- Add file descriptor limits (`ulimits`) to Docker Compose worker services to prevent `Too many open files` errors [(#10107)](https://github.com/prowler-cloud/prowler/pull/10107)
|
||||
- Registry scan mode for `image` provider: enumerate and scan all images from OCI standard, Docker Hub, and ECR [(#9985)](https://github.com/prowler-cloud/prowler/pull/9985)
|
||||
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Update Azure Monitor service metadata to new format [(#9622)](https://github.com/prowler-cloud/prowler/pull/9622)
|
||||
- GitHub provider enhanced documentation and `repository_branch_delete_on_merge_enabled` logic [(#9830)](https://github.com/prowler-cloud/prowler/pull/9830)
|
||||
- Parallelize Cloudflare zone API calls with threading to improve scan performance [(#9982)](https://github.com/prowler-cloud/prowler/pull/9982)
|
||||
- Update GCP API Keys service metadata to new format [(#9637)](https://github.com/prowler-cloud/prowler/pull/9637)
|
||||
- Update GCP BigQuery service metadata to new format [(#9638)](https://github.com/prowler-cloud/prowler/pull/9638)
|
||||
@@ -39,9 +39,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- Update GCP IAM service metadata to new format [(#9646)](https://github.com/prowler-cloud/prowler/pull/9646)
|
||||
- Update GCP KMS service metadata to new format [(#9647)](https://github.com/prowler-cloud/prowler/pull/9647)
|
||||
- Update GCP Logging service metadata to new format [(#9648)](https://github.com/prowler-cloud/prowler/pull/9648)
|
||||
- Update Azure Key Vault service metadata to new format [(#9621)](https://github.com/prowler-cloud/prowler/pull/9621)
|
||||
- Update Azure Entra ID service metadata to new format [(#9619)](https://github.com/prowler-cloud/prowler/pull/9619)
|
||||
- Update Azure Virtual Machines service metadata to new format [(#9629)](https://github.com/prowler-cloud/prowler/pull/9629)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `image` provider: dynamic User-Agent version, correct finding Categories and RelatedUrl metadata, remove dead docker login/pull code, replace SystemExit in registry listing
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
@@ -55,7 +56,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
- `pip install prowler` failing on systems without C compiler due to `netifaces` transitive dependency from `openstacksdk` [(#10055)](https://github.com/prowler-cloud/prowler/pull/10055)
|
||||
- `kms_key_not_publicly_accessible` false negative for specific KMS actions (e.g., `kms:DescribeKey`, `kms:Decrypt`) with unrestricted principals [(#10071)](https://github.com/prowler-cloud/prowler/pull/10071)
|
||||
- Remove account_id and location for manual requirements in M365CIS [(#10105)](https://github.com/prowler-cloud/prowler/pull/10105)
|
||||
|
||||
---
|
||||
|
||||
@@ -66,10 +66,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `--repository` and `--organization` flags combined interaction in GitHub provider, qualifying unqualified repository names with organization [(#10001)](https://github.com/prowler-cloud/prowler/pull/10001)
|
||||
- HPACK library logging tokens in debug mode for Azure, M365, and Cloudflare providers [(#10010)](https://github.com/prowler-cloud/prowler/pull/10010)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Use `defusedxml` in the Alibaba Cloud OSS service to prevent XXE vulnerabilities when parsing XML responses [(#9999)](https://github.com/prowler-cloud/prowler/pull/9999)
|
||||
|
||||
---
|
||||
|
||||
## [5.18.0] (Prowler v5.18.0)
|
||||
|
||||
@@ -22,8 +22,8 @@ def load_checks_to_execute(
|
||||
) -> set:
|
||||
"""Generate the list of checks to execute based on the cloud provider and the input arguments given"""
|
||||
try:
|
||||
# Bypass check loading for IAC provider since it uses Trivy directly
|
||||
if provider == "iac":
|
||||
# Bypass check loading for IAC/Image providers since they use Trivy directly
|
||||
if provider in ("iac", "image"):
|
||||
return set()
|
||||
|
||||
# Local subsets
|
||||
|
||||
@@ -864,6 +864,7 @@ class CheckReportImage(Check_Report):
|
||||
resource_name: str
|
||||
resource_id: str
|
||||
image_digest: str
|
||||
image_sha: str
|
||||
package_name: str
|
||||
installed_version: str
|
||||
fixed_version: str
|
||||
@@ -895,6 +896,7 @@ class CheckReportImage(Check_Report):
|
||||
or finding.get("ID", "")
|
||||
)
|
||||
self.image_digest = finding.get("PkgID", "")
|
||||
self.image_sha = ""
|
||||
self.package_name = finding.get("PkgName", "")
|
||||
self.installed_version = finding.get("InstalledVersion", "")
|
||||
self.fixed_version = finding.get("FixedVersion", "")
|
||||
|
||||
@@ -77,8 +77,8 @@ class M365CIS(ComplianceOutput):
|
||||
compliance_row = M365CISModel(
|
||||
Provider=compliance.Provider.lower(),
|
||||
Description=compliance.Description,
|
||||
TenantId="",
|
||||
Location="",
|
||||
TenantId=finding.account_uid,
|
||||
Location=finding.region,
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
|
||||
@@ -384,10 +384,12 @@ class Finding(BaseModel):
|
||||
output_data["auth_method"] = provider.auth_method
|
||||
output_data["account_uid"] = "image"
|
||||
output_data["account_name"] = "image"
|
||||
output_data["resource_name"] = getattr(
|
||||
check_output, "resource_name", ""
|
||||
image_name = getattr(check_output, "resource_name", "")
|
||||
image_sha = getattr(check_output, "image_sha", "")
|
||||
output_data["resource_name"] = image_name
|
||||
output_data["resource_uid"] = (
|
||||
f"{image_name}:{image_sha}" if image_sha else image_name
|
||||
)
|
||||
output_data["resource_uid"] = getattr(check_output, "resource_id", "")
|
||||
output_data["region"] = getattr(check_output, "region", "container")
|
||||
output_data["package_name"] = getattr(check_output, "package_name", "")
|
||||
output_data["installed_version"] = getattr(
|
||||
|
||||
@@ -27,6 +27,7 @@ from prowler.lib.scan.exceptions.exceptions import (
|
||||
from prowler.providers.common.models import Audit_Metadata, ProviderOutputOptions
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.iac.iac_provider import IacProvider
|
||||
from prowler.providers.image.image_provider import ImageProvider
|
||||
|
||||
|
||||
class Scan:
|
||||
@@ -92,10 +93,10 @@ class Scan:
|
||||
except ValueError:
|
||||
raise ScanInvalidStatusError(f"Invalid status provided: {s}.")
|
||||
|
||||
# Special setup for IaC provider - override inputs to work with traditional flow
|
||||
if provider.type == "iac":
|
||||
# IaC doesn't use traditional Prowler checks, so clear all input parameters
|
||||
# to avoid validation errors and let it flow through the normal logic
|
||||
# Special setup for IaC/Image providers - override inputs to work with traditional flow
|
||||
if provider.type in ("iac", "image"):
|
||||
# These providers don't use traditional Prowler checks, so clear all input
|
||||
# parameters to avoid validation errors and let them flow through the normal logic
|
||||
checks = None
|
||||
services = None
|
||||
excluded_checks = None
|
||||
@@ -160,8 +161,8 @@ class Scan:
|
||||
)
|
||||
|
||||
# Load checks to execute
|
||||
if provider.type == "iac":
|
||||
self._checks_to_execute = ["iac_scan"] # Dummy check name for IaC
|
||||
if provider.type in ("iac", "image"):
|
||||
self._checks_to_execute = [f"{provider.type}_scan"] # Dummy check name
|
||||
else:
|
||||
self._checks_to_execute = sorted(
|
||||
load_checks_to_execute(
|
||||
@@ -200,8 +201,8 @@ class Scan:
|
||||
self._number_of_checks_to_execute = len(self._checks_to_execute)
|
||||
|
||||
# Set up service-based checks tracking
|
||||
if provider.type == "iac":
|
||||
service_checks_to_execute = {"iac": set(["iac_scan"])}
|
||||
if provider.type in ("iac", "image"):
|
||||
service_checks_to_execute = {provider.type: set([f"{provider.type}_scan"])}
|
||||
else:
|
||||
service_checks_to_execute = get_service_checks_to_execute(
|
||||
self._checks_to_execute
|
||||
@@ -346,6 +347,68 @@ class Scan:
|
||||
self._duration = int((end_time - start_time).total_seconds())
|
||||
return
|
||||
|
||||
# Special handling for Image provider
|
||||
elif self._provider.type == "image":
|
||||
if isinstance(self._provider, ImageProvider):
|
||||
logger.info("Running Image scan with Trivy...")
|
||||
|
||||
total_images = len(self._provider.images)
|
||||
self._number_of_checks_to_execute = max(total_images, 1)
|
||||
|
||||
for i, (image_name, image_reports) in enumerate(
|
||||
self._provider.scan_per_image()
|
||||
):
|
||||
# Build resource UID from image name + SHA (all reports share the same SHA)
|
||||
image_sha = image_reports[0].image_sha if image_reports else ""
|
||||
resource_uid = (
|
||||
f"{image_name}:{image_sha}" if image_sha else image_name
|
||||
)
|
||||
|
||||
findings = []
|
||||
for report in image_reports:
|
||||
finding_uid = (
|
||||
f"{report.check_metadata.CheckID}"
|
||||
f"-{image_name}"
|
||||
f"-{report.resource_id}"
|
||||
)
|
||||
status_enum = (
|
||||
Status.FAIL if report.status == "FAIL" else Status.PASS
|
||||
)
|
||||
if report.muted:
|
||||
status_enum = Status.MUTED
|
||||
|
||||
finding = Finding(
|
||||
auth_method="Registry",
|
||||
timestamp=datetime.datetime.now(timezone.utc),
|
||||
account_uid=getattr(self._provider, "registry", None)
|
||||
or "image",
|
||||
account_name="Container Registry",
|
||||
metadata=report.check_metadata,
|
||||
uid=finding_uid,
|
||||
status=status_enum,
|
||||
status_extended=report.status_extended,
|
||||
muted=report.muted,
|
||||
resource_uid=resource_uid,
|
||||
resource_metadata=report.resource,
|
||||
resource_name=image_name,
|
||||
resource_details=report.resource_details,
|
||||
resource_tags={},
|
||||
region=report.region,
|
||||
compliance={},
|
||||
raw=report.resource,
|
||||
)
|
||||
findings.append(finding)
|
||||
|
||||
if self._status:
|
||||
findings = [f for f in findings if f.status in self._status]
|
||||
|
||||
self._number_of_checks_completed = i + 1
|
||||
yield (self.progress, findings)
|
||||
|
||||
end_time = datetime.datetime.now()
|
||||
self._duration = int((end_time - start_time).total_seconds())
|
||||
return
|
||||
|
||||
for check_name in checks_to_execute:
|
||||
try:
|
||||
# Recover service from check name
|
||||
|
||||
@@ -6,9 +6,9 @@ from datetime import datetime
|
||||
from email.utils import formatdate
|
||||
from threading import Lock
|
||||
from typing import Optional
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
from defusedxml import ElementTree
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_conditional_access_policy_require_mfa_for_management_api",
|
||||
"CheckTitle": "Tenant requires MFA for all users to access Windows Azure Service Management API",
|
||||
"CheckTitle": "Ensure Multifactor Authentication is Required for Windows Azure Service Management API",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "#microsoft.graph.conditionalAccess",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra Conditional Access** requires **MFA** for the **Windows Azure Service Management API** when an `enabled` policy targets `All users` and grants `Require multifactor authentication` to tokens for this management endpoint.",
|
||||
"Risk": "Without MFA on Azure management endpoints, stolen or phished passwords can enable control-plane access.\n\nAttackers can change configs, create/delete resources, extract secrets, pivot laterally, and disrupt services-compromising confidentiality, integrity, and availability, with added cost exposure.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-sg/entra/identity/conditional-access/policy-old-require-mfa-azure-mgmt",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000233942-ensure-that-multi-factor-authentication-is-required-for-windows-azure-service-management-api-manual-"
|
||||
],
|
||||
"Description": "This recommendation ensures that users accessing the Windows Azure Service Management API (i.e. Azure Powershell, Azure CLI, Azure Resource Manager API, etc.) are required to use multifactor authentication (MFA) credentials when accessing resources through the Windows Azure Service Management API.",
|
||||
"Risk": "Administrative access to the Windows Azure Service Management API should be secured with a higher level of scrutiny to authenticating mechanisms. Enabling multifactor authentication is recommended to reduce the potential for abuse of Administrative actions, and to prevent intruders or compromised admin credentials from changing administrative settings.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/howto-conditional-access-policy-azure-management",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "New-MgIdentityConditionalAccessPolicy -DisplayName \"Require MFA for Azure management\" -State \"enabled\" -Conditions @{Users=@{IncludeUsers=@(\"All\")}; Applications=@{IncludeApplications=@(\"797f4846-ba00-4fd7-ba43-dac1f8f63013\")}} -GrantControls @{BuiltInControls=@(\"mfa\")}",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In the Microsoft Entra admin center, go to Protection > Conditional Access > Policies\n2. Click New policy\n3. Users: Include > All users\n4. Target resources: Resources > Include > Select resources > choose \"Windows Azure Service Management API\"\n5. Grant: Grant access > check Require multifactor authentication > Select\n6. Enable policy: On > Create",
|
||||
"Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"<example_resource_name>\" {\n display_name = \"Require MFA for Azure management\"\n state = \"enabled\" # Critical: policy must be enabled to pass\n\n conditions {\n client_app_types = [\"all\"]\n applications {\n included_applications = [\n \"797f4846-ba00-4fd7-ba43-dac1f8f63013\" # Critical: Windows Azure Service Management API (Azure management)\n ]\n }\n users {\n included_users = [\"All\"] # Critical: apply to all users\n }\n }\n\n grant_controls {\n operator = \"OR\"\n built_in_controls = [\"mfa\"] # Critical: require multifactor authentication\n }\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **MFA** via Conditional Access for `Windows Azure Service Management API` scoped to `All users`, with only break-glass exclusions. Prefer **phishing-resistant** methods, apply **least privilege** and **separation of duties**, and monitor sign-ins. Also secure related admin apps and explicitly protect Azure DevOps as a distinct target.",
|
||||
"Url": "https://hub.prowler.com/check/entra_conditional_access_policy_require_mfa_for_management_api"
|
||||
"Text": "1. From the Azure Admin Portal dashboard, open Microsoft Entra ID. 2. Click Security in the Entra ID blade. 3. Click Conditional Access in the Security blade. 4. Click Policies in the Conditional Access blade. 5. Click + New policy. 6. Enter a name for the policy. 7. Click the blue text under Users. 8. Under Include, select All users. 9. Under Exclude, check Users and groups. 10. Select users or groups to be exempted from this policy (e.g. break-glass emergency accounts, and non-interactive service accounts) then click the Select button. 11. Click the blue text under Target Resources. 12. Under Include, click the Select apps radio button. 13. Click the blue text under Select. 14. Check the box next to Windows Azure Service Management APIs then click the Select button. 15. Click the blue text under Grant. 16. Under Grant access check the box for Require multifactor authentication then click the Select button. 17. Before creating, set Enable policy to Report-only. 18. Click Create. After testing the policy in report-only mode, update the Enable policy setting from Report-only to On.",
|
||||
"Url": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Conditional Access policies require Microsoft Entra ID P1 or P2 licenses. Similarly, they may require additional overhead to maintain if users lose access to their MFA. Any users or groups which are granted an exception to this policy should be carefully tracked, be granted only minimal necessary privileges, and conditional access exceptions should be regularly reviewed or investigated."
|
||||
|
||||
+11
-17
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_global_admin_in_less_than_five_users",
|
||||
"CheckTitle": "Global Administrator role has fewer than 5 members",
|
||||
"CheckTitle": "Ensure fewer than 5 users have global administrator assignment",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"ResourceType": "#microsoft.graph.directoryRole",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra Global Administrator** assignments are evaluated by counting current role members per tenant and identifying when the number of assignees is `5` or more.",
|
||||
"Risk": "Having **5+ Global Administrators** expands the privileged attack surface. Compromised credentials or tokens can enable tenant-wide changes, disable security controls, exfiltrate data, and create persistence, impacting **confidentiality**, **integrity**, and **availability** across Entra, Microsoft 365, and Azure.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#5-limit-the-number-of-global-administrators-to-less-than-5",
|
||||
"https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide#security-guidelines-for-assigning-roles"
|
||||
],
|
||||
"Description": "This recommendation aims to maintain a balance between security and operational efficiency by ensuring that a minimum of 2 and a maximum of 4 users are assigned the Global Administrator role in Microsoft Entra ID. Having at least two Global Administrators ensures redundancy, while limiting the number to four reduces the risk of excessive privileged access.",
|
||||
"Risk": "The Global Administrator role has extensive privileges across all services in Microsoft Entra ID. The Global Administrator role should never be used in regular daily activities, administrators should have a regular user account for daily activities, and a separate account for administrative responsibilities. Limiting the number of Global Administrators helps mitigate the risk of unauthorized access, reduces the potential impact of human error, and aligns with the principle of least privilege to reduce the attack surface of an Azure tenant. Conversely, having at least two Global Administrators ensures that administrative functions can be performed without interruption in case of unavailability of a single admin.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices#5-limit-the-number-of-global-administrators-to-less-than-5",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Remove-MgDirectoryRoleMember -DirectoryRoleId (Get-MgDirectoryRole -Filter \"displayName eq 'Global Administrator'\").Id -DirectoryObjectId '<example_user_id>'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Identity > Roles & admins > Global Administrator\n3. Select View assignments (or Assignments)\n4. Remove members until the total Global Administrator assignments are fewer than 5\n5. Save changes",
|
||||
"Terraform": "```hcl\n# Keep Global Administrator assignments below 5 by defining only required principals\ndata \"azuread_directory_role\" \"global_admin\" {\n display_name = \"Global Administrator\"\n}\n\n# Critical: This assignment grants GA to a specific principal; keep total GA assignments < 5\nresource \"azuread_directory_role_assignment\" \"ga_primary\" {\n role_id = data.azuread_directory_role.global_admin.id # Assigns the Global Administrator role\n principal_object_id = \"<example_resource_id>\" # Required account (e.g., break-glass)\n}\n\n# Critical: Add only necessary GA assignments; remove extras to ensure count < 5\nresource \"azuread_directory_role_assignment\" \"ga_secondary\" {\n role_id = data.azuread_directory_role.global_admin.id # Assigns the Global Administrator role\n principal_object_id = \"<example_resource_id>\" # Second required account\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Limit the **Global Administrator** role to **fewer than 5** users.\n- Apply **least privilege**; use narrower roles where possible\n- Use **PIM** for just-in-time, no standing access\n- Enforce **MFA** and dedicated admin accounts\n- Run **access reviews** regularly and keep cloud-only `break-glass` accounts for emergencies",
|
||||
"Url": "https://hub.prowler.com/check/entra_global_admin_in_less_than_five_users"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Select Roles and Administrators 4. Select Global Administrator 5. Ensure less than 5 users are actively assigned the role. 6. Ensure that at least 2 users are actively assigned the role.",
|
||||
"Url": "https://learn.microsoft.com/en-us/microsoft-365/admin/add-users/about-admin-roles?view=o365-worldwide#security-guidelines-for-assigning-roles"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Implementing this recommendation may require changes in administrative workflows or the redistribution of roles and responsibilities. Adequate training and awareness should be provided to all Global Administrators."
|
||||
|
||||
+11
-18
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_non_privileged_user_has_mfa",
|
||||
"CheckTitle": "Non-privileged user has multi-factor authentication enabled",
|
||||
"CheckTitle": "Ensure that 'Multi-Factor Auth Status' is 'Enabled' for all Non-Privileged Users",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "high",
|
||||
"ResourceType": "#microsoft.graph.users",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra** non-privileged users are assessed for **multifactor authentication** by verifying they have **two or more registered authentication methods** (*MFA enrollment*).",
|
||||
"Risk": "Absent **MFA** on standard accounts enables password-only logins after phishing, reuse, or spraying, leading to **account takeover**. Attackers can access email, files, and apps, send internal phishing, and escalate, undermining **confidentiality** and **integrity**, and risking **availability** via malicious changes.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000219680-ensure-that-multi-factor-auth-status-is-enabled-for-all-non-privileged-users",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks"
|
||||
],
|
||||
"Description": "Enable multi-factor authentication for all non-privileged users.",
|
||||
"Risk": "Multi-factor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multi-factor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multi-factor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method POST --url https://graph.microsoft.com/v1.0/users/<example_user_id>/authentication/temporaryAccessPassMethods --body '{\"lifetimeInMinutes\":60,\"isUsableOnce\":true}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Entra ID > Users and select the non-privileged user\n3. Select Security > Authentication methods\n4. Click Add authentication method > Temporary Access Pass\n5. Click Create (accept defaults)\n6. Confirm the method appears under the user's authentication methods",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/multi-factor-authentication-for-all-non-privileged-users.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **MFA** for all users, including non-privileged. Prefer **phishing-resistant** methods (FIDO2/passkeys or Authenticator with number matching); avoid SMS/voice when possible. Use **Conditional Access** to require MFA by risk and context. Pair with **least privilege**, device trust, and sign-in monitoring.",
|
||||
"Url": "https://hub.prowler.com/check/entra_non_privileged_user_has_mfa"
|
||||
"Text": "Activate one of the available multi-factor authentication methods for users in Microsoft Entra ID.",
|
||||
"Url": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Users would require two forms of authentication before any access is granted. Also, this requires an overhead for managing dual forms of authentication."
|
||||
|
||||
+12
-18
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_default_users_cannot_create_security_groups",
|
||||
"CheckTitle": "Authorization policy disallows non-privileged users from creating security groups",
|
||||
"CheckTitle": "Ensure that 'Users can create security groups in Azure portals, API or PowerShell' is set to 'No'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "high",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra authorization policy** setting for default user role permissions governing creation of **security groups** by non-privileged users.\n\nThe value of `allowed_to_create_security_groups` is examined to ensure group creation is limited to administrators across portals, API, and PowerShell.",
|
||||
"Risk": "Allowing standard users to create security groups drives **entitlement sprawl** and can grant **unauthorized access** when those groups are tied to apps, sites, or roles. This weakens **least privilege**, complicates audits, and enables **lateral movement** or data exfiltration via misassigned group-based permissions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/users-can-create-security-groups.html",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management"
|
||||
],
|
||||
"Description": "Restrict security group creation to administrators only.",
|
||||
"Risk": "When creating security groups is enabled, all users in the directory are allowed to create new security groups and add members to those groups. Unless a business requires this day-to-day delegation, security group creation should be restricted to administrators only.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/users/groups-self-service-management",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method PATCH --url https://graph.microsoft.com/v1.0/policies/authorizationPolicy/authorizationPolicy --body '{\"defaultUserRolePermissions\":{\"allowedToCreateSecurityGroups\":false}}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Identity > Users > User settings\n3. Find \"Users can create security groups in Azure portals, API, or PowerShell\"\n4. Set it to \"No\"\n5. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n default_user_role_permissions {\n allowed_to_create_security_groups = false # Critical: disables security group creation for non-privileged users\n }\n}\n```"
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/users-can-create-security-groups.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Restrict creation to **administrators** or a narrowly delegated role per **least privilege**. Set `allowed_to_create_security_groups` to `false` and use request/approval for new groups. Apply **governance**: naming standards, owner accountability, periodic **access reviews**, and monitor group lifecycle in audit logs.",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_default_users_cannot_create_security_groups"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Select Groups 4. Select General under Settings 5. Set Users can create security groups in Azure portals, API or PowerShell to No",
|
||||
"Url": ""
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Enabling this setting could create a number of requests that would need to be managed by an administrator."
|
||||
|
||||
+11
-18
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_ensure_default_user_cannot_create_apps",
|
||||
"CheckTitle": "Tenant does not allow non-admin users to register applications",
|
||||
"CheckTitle": "Ensure That 'Users Can Register Applications' Is Set to 'No'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra authorization policy** controls whether default users can create application registrations via `allowed_to_create_apps`. App creation is expected to be limited to administrators or explicitly delegated roles.",
|
||||
"Risk": "Permitting default users to register apps enables **unvetted service principals**, **consent phishing**, and **over-privileged API access**, threatening data **confidentiality** and **integrity**. Adversaries can persist with app credentials, exfiltrate mail/files, and perform **lateral movement** using rogue permissions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/users-can-register-applications.html",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications",
|
||||
"https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added#who-has-permission-to-add-applications-to-my-azure-ad-instance"
|
||||
],
|
||||
"Description": "Require administrators or appropriately delegated users to register third-party applications.",
|
||||
"Risk": "It is recommended to only allow an administrator to register custom-developed applications. This ensures that the application undergoes a formal security review and approval process prior to exposing Azure Active Directory data. Certain users like developers or other high-request users may also be delegated permissions to prevent them from waiting on an administrative user. Your organization should review your policies and decide your needs.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity-platform/how-applications-are-added#who-has-permission-to-add-applications-to-my-azure-ad-instance",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method PATCH --url https://graph.microsoft.com/v1.0/policies/authorizationPolicy/authorizationPolicy --body '{\"defaultUserRolePermissions\":{\"allowedToCreateApps\":false}}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Microsoft Entra ID > Users > User settings\n3. Set \"Users can register applications\" to \"No\"\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n default_user_role_permissions {\n allowed_to_create_apps = false # Critical: disables application registration for non-privileged users\n }\n}\n```"
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/users-can-register-applications.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Apply **least privilege**: restrict app registration to admins or delegated roles; set `Users can register applications` to `No`. Use the **Application Developer** role for exceptions, require **admin consent** workflows, routinely review app/service principal permissions, and audit changes for **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_ensure_default_user_cannot_create_apps"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Azure Active Directory 3. Select Users 4. Select User settings 5. Ensure that Users can register applications is set to No",
|
||||
"Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/delegate-app-roles#restrict-who-can-create-applications"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Enforcing this setting will create additional requests for approval that will need to be addressed by an administrator. If permissions are delegated, a user may approve a malevolent third party application, potentially giving it access to your data."
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_ensure_default_user_cannot_create_tenants",
|
||||
"CheckTitle": "Authorization policy restricts non-admin users from creating tenants",
|
||||
"CheckTitle": "Ensure that 'Restrict non-admin users from creating tenants' is set to 'Yes'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "high",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra authorization policy** governs whether default users can create new tenants. This evaluates if tenant creation is disabled for non-admin users via `allowed_to_create_tenants=false`.",
|
||||
"Risk": "Permitting default users to create tenants fuels **shadow IT** and identity sprawl. Creators become **Global Administrators** of unmanaged tenants, eroding **confidentiality** and **integrity** through unsanctioned apps and unmonitored data flows, and degrading **availability** of centralized governance.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/disable-user-tenant-creation.html",
|
||||
"https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions"
|
||||
],
|
||||
"Description": "Require administrators or appropriately delegated users to create new tenants.",
|
||||
"Risk": "It is recommended to only allow an administrator to create new tenants. This prevent users from creating new Azure AD or Azure AD B2C tenants and ensures that only authorized users are able to do so.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Update-MgPolicyAuthorizationPolicy -AuthorizationPolicyId authorizationPolicy -BodyParameter @{ defaultUserRolePermissions = @{ allowedToCreateTenants = $false } }",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Go to Microsoft Entra admin center (https://entra.microsoft.com)\n2. Navigate: Microsoft Entra ID > Users > User settings\n3. Set \"Restrict non-admin users from creating tenants\" to Yes\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n default_user_role_permissions {\n allowed_to_create_tenants = false # Critical: disables tenant creation for non-privileged users\n }\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Apply **least privilege**: set `allowed_to_create_tenants=false` so only vetted admins or the **Tenant Creator** role (managed with **PIM**) can create tenants. Enforce **separation of duties**, require approvals, and monitor audits. Review this setting regularly to prevent tenant sprawl and maintain **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_ensure_default_user_cannot_create_tenants"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Azure Active Directory 3. Select Users 4. Select User settings 5. Set 'Restrict non-admin users from creating' tenants to 'Yes'",
|
||||
"Url": "https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#tenant-creator"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Enforcing this setting will ensure that only authorized users are able to create new tenants."
|
||||
|
||||
+12
-18
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_guest_invite_only_for_admin_roles",
|
||||
"CheckTitle": "Tenant authorization policy restricts guest invitations to users with specific admin roles or disables guest invitations",
|
||||
"CheckTitle": "Ensure that 'Guest invite restrictions' is set to 'Only users assigned to specific admin roles can invite guest users'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra authorization policy** controls who can send **B2B guest invitations**.\n\nSecure posture is when invitations are restricted to specific admin roles (`adminsAndGuestInviters`) or completely disabled (`none`).",
|
||||
"Risk": "**Open guest invitation** rights let members or guests add external users without oversight, expanding the attack surface.\n\nImpacts:\n- **Confidentiality**: data leakage via overshared resources\n- **Integrity**: privilege escalation through group/team access\n- **Availability**: difficult containment due to account sprawl",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/answers/questions/685101/how-to-allow-only-admins-to-add-guests",
|
||||
"https://learn.microsoft.com/en-us/entra/external-id/external-collaboration-settings-configure"
|
||||
],
|
||||
"Description": "Restrict invitations to users with specific administrative roles only.",
|
||||
"Risk": "Restricting invitations to users with specific administrator roles ensures that only authorized accounts have access to cloud resources. This helps to maintain 'Need to Know' permissions and prevents inadvertent access to data. By default the setting Guest invite restrictions is set to Anyone in the organization can invite guest users including guests and non-admins. This would allow anyone within the organization to invite guests and non-admins to the tenant, posing a security risk.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/external-id/external-collaboration-settings-configure",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method PATCH --url https://graph.microsoft.com/v1.0/policies/authorizationPolicy/authorizationPolicy --headers 'Content-Type=application/json' --body '{\"allowInvitesFrom\":\"adminsAndGuestInviters\"}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Entra ID > External Identities > External collaboration settings\n3. Under Guest invite settings, select \"Only users assigned to specific admin roles can invite guest users\" (or select \"No one in the organization can invite guest users\")\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n allow_invites_from = \"adminsAndGuestInviters\" # Restricts guest invitations to specific admin roles, making the check PASS\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Restrict invitations to `Only users assigned to specific admin roles can invite guest users`, or disable them where not needed. Apply **least privilege** (use dedicated Guest Inviter role), enforce approvals, allowlist trusted domains, and run periodic access reviews with audit monitoring to remove stale or risky guests.",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_guest_invite_only_for_admin_roles"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Then External Identities 4. Select External collaboration settings 5. Under Guest invite settings, for Guest invite restrictions, ensure that Only users assigned to specific admin roles can invite guest users is selected",
|
||||
"Url": "https://learn.microsoft.com/en-us/answers/questions/685101/how-to-allow-only-admins-to-add-guests"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "With the option of Only users assigned to specific admin roles can invite guest users selected, users with specific admin roles will be in charge of sending invitations to the external users, requiring additional overhead by them to manage user accounts. This will mean coordinating with other departments as they are onboarding new users."
|
||||
|
||||
+11
-17
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_guest_users_access_restrictions",
|
||||
"CheckTitle": "Authorization policy restricts guest user access to properties and memberships of their own directory objects",
|
||||
"CheckTitle": "Ensure That 'Guest users access restrictions' is set to 'Guest user access is restricted to properties and memberships of their own directory objects'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra authorization policy** guest settings are assessed to determine whether guest user access is limited to the properties and memberships of their own directory objects (`Restricted access`) instead of broader visibility into users and groups",
|
||||
"Risk": "Excess guest visibility enables **directory reconnaissance**, exposing user and group details for **phishing**, **password spraying**, and targeted attacks. This weakens **confidentiality** and can facilitate **privilege escalation** and lateral movement through informed abuse of group memberships and access paths.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions",
|
||||
"https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#member-and-guest-users"
|
||||
],
|
||||
"Description": "Limit guest user permissions.",
|
||||
"Risk": "Limiting guest access ensures that guest accounts do not have permission for certain directory tasks, such as enumerating users, groups or other directory resources, and cannot be assigned to administrative roles in your directory. Guest access has three levels of restriction. 1. Guest users have the same access as members (most inclusive), 2. Guest users have limited access to properties and memberships of directory objects (default value), 3. Guest user access is restricted to properties and memberships of their own directory objects (most restrictive). The recommended option is the 3rd, most restrictive: 'Guest user access is restricted to their own directory object'.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/users/users-restrict-guest-permissions",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method patch --url https://graph.microsoft.com/v1.0/policies/authorizationPolicy/authorizationPolicy --body '{\"guestUserRoleId\":\"2af84b1e-32c8-42b7-82bc-daa82404023b\"}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Go to Microsoft Entra admin center > External Identities > External collaboration settings\n2. Select \"Guest user access is restricted to properties and memberships of their own directory objects\"\n3. Click Save\n4. Allow up to 15 minutes for the change to take effect",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n # Critical: sets guests to 'Restricted access' so they can only access their own directory object\n guest_user_role_id = \"2af84b1e-32c8-42b7-82bc-daa82404023b\"\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Apply **least privilege** to external users:\n- Set guest access to `Restricted access` so guests can only view their own directory objects\n- Avoid assigning admin roles to guests; use **PIM** for rare exceptions\n- Constrain external collaboration and group visibility, and run periodic **access reviews** to remove stale guest access",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_guest_users_access_restrictions"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Then External Identities 4. Select External collaboration settings 5. Under Guest user access, change Guest user access restrictions to be Guest user access is restricted to properties and memberships of their own directory objects",
|
||||
"Url": "https://learn.microsoft.com/en-us/entra/fundamentals/users-default-permissions#member-and-guest-users"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This may create additional requests for permissions to access resources that administrators will need to approve. According to https://learn.microsoft.com/en-us/azure/active-directory/enterprise- users/users-restrict-guest-permissions#services-currently-not-supported Service without current support might have compatibility issues with the new guest restriction setting."
|
||||
|
||||
+11
-19
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_restricts_user_consent_for_apps",
|
||||
"CheckTitle": "Entra authorization policy disallows user consent for applications",
|
||||
"CheckTitle": "Ensure 'User consent for applications' is set to 'Do not allow user consent'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Microsoft Entra authorization settings are evaluated to determine if the default user role permits **user consent to applications**. The check looks at permission grant policies to see whether end users can authorize apps to access organization data on their behalf, or if consent is restricted (e.g., `Do not allow user consent`).",
|
||||
"Risk": "Permitting end-user consent enables **consent phishing** and over-privileged OAuth grants. Attackers can obtain tokens to read/send mail, access files, or act as the user, causing **data exfiltration**, persistence beyond password resets/MFA changes, and abuse of connected apps, impacting confidentiality and integrity.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/users-can-consent-to-apps-accessing-company-data-on-their-behalf.html#",
|
||||
"https://learn.microsoft.com/en-gb/entra/identity/enterprise-apps/configure-user-consent?pivots=portal",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent"
|
||||
],
|
||||
"Description": "Require administrators to provide consent for applications before use.",
|
||||
"Risk": "If Microsoft Entra ID is running as an identity provider for third-party applications, permissions and consent should be limited to administrators or pre-approved. Malicious applications may attempt to exfiltrate data or abuse privileged user accounts.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-gb/entra/identity/enterprise-apps/configure-user-consent?pivots=portal",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Update-MgPolicyAuthorizationPolicy -BodyParameter @{ permissionGrantPolicyIdsAssignedToDefaultUserRole = @() }",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center (entra.microsoft.com) with a Global Administrator\n2. Go to Identity > Applications > Enterprise applications\n3. Select Consent and permissions > User consent settings\n4. Choose Do not allow user consent\n5. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n # Critical: remove all self-consent policies so users cannot consent to apps\n permission_grant_policy_ids_assigned_to_default_user_role = []\n}\n```"
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/users-can-consent-to-apps-accessing-company-data-on-their-behalf.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **least privilege** by setting user consent to `Do not allow user consent`. Use the **admin consent workflow** to review requests and pre-approve only vetted apps. *If needed*, allow consent only for verified publishers with low-impact scopes. Regularly review existing grants and monitor audit/sign-in logs.",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_restricts_user_consent_for_apps"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Select Enterprise Applications 4. Select Consent and permissions 5. Select User consent settings 6. Set User consent for applications to Do not allow user consent 7. Click save",
|
||||
"Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Enforcing this setting may create additional requests that administrators need to review."
|
||||
|
||||
+11
-17
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_policy_user_consent_for_verified_apps",
|
||||
"CheckTitle": "Entra tenant does not allow users to consent to non-verified applications",
|
||||
"CheckTitle": "Ensure 'User consent for applications' Is Set To 'Allow for Verified Publishers'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"ResourceType": "#microsoft.graph.authorizationPolicy",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra** authorization policy for the default user role is assessed for assignment of the user-consent policy `microsoft-user-default-legacy`. Its presence means users can self-consent to app permissions; its absence indicates consent is restricted (e.g., only verified publishers or low-impact scopes).",
|
||||
"Risk": "Broad self-consent enables **OAuth consent phishing** and rogue apps to gain tokens to tenant data (**confidentiality**), request write scopes to change resources (**integrity**), and persist via refresh tokens after password changes. Mis-scoped grants can drive lateral movement and privilege escalation.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent?pivots=portal#configure-user-consent-to-applications"
|
||||
],
|
||||
"Description": "Allow users to provide consent for selected permissions when a request is coming from a verified publisher.",
|
||||
"Risk": "If Microsoft Entra ID is running as an identity provider for third-party applications, permissions and consent should be limited to administrators or pre-approved. Malicious applications may attempt to exfiltrate data or abuse privileged user accounts.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/configure-user-consent?pivots=portal#configure-user-consent-to-applications",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Update-MgPolicyAuthorizationPolicy -BodyParameter @{permissionGrantPolicyIdsAssignedToDefaultUserRole=@('ManagePermissionGrantsForSelf.microsoft-user-default-low')}",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to Microsoft Entra admin center as Global Administrator or Privileged Role Administrator\n2. Go to Identity > Applications > Enterprise applications\n3. Select Consent and permissions > User consent settings\n4. Under User consent for applications, select \"Allow user consent for apps from verified publishers, for selected permissions\"\n5. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azuread_authorization_policy\" \"<example_resource_name>\" {\n # Critical: restricts user consent to verified publishers with low-impact permissions only\n permission_grant_policy_ids_assigned_to_default_user_role = [\"ManagePermissionGrantsForSelf.microsoft-user-default-low\"]\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **least privilege** for app consent:\n- Remove `microsoft-user-default-legacy`\n- Allow consent only for verified publishers and low-impact permissions (e.g., `microsoft-user-default-low`)\n- Require admin approval for higher-risk scopes via the admin consent workflow\n- Periodically review and revoke unused consent grants",
|
||||
"Url": "https://hub.prowler.com/check/entra_policy_user_consent_for_verified_apps"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Select Enterprise Applications 4. Select Consent and permissions 5. Select User consent settings 6. Under User consent for applications, select Allow user consent for apps from verified publishers, for selected permissions 7. Select Save",
|
||||
"Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-privileged-access#pa-1-separate-and-limit-highly-privilegedadministrative-users"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Enforcing this setting may create additional requests that administrators need to review."
|
||||
|
||||
+11
-18
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_privileged_user_has_mfa",
|
||||
"CheckTitle": "Privileged user has multi-factor authentication enabled",
|
||||
"CheckTitle": "Ensure that 'Multi-Factor Auth Status' is 'Enabled' for all Privileged Users",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"ResourceType": "#microsoft.graph.users",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra** privileged accounts are expected to use **multifactor authentication**. This evaluates users assigned to elevated directory roles and confirms they have **multiple authentication methods** registered for sign-in.",
|
||||
"Risk": "Without **MFA**, privileged accounts face **phishing**, **password spraying**, and **credential reuse** risks. Compromise can grant tenant-wide admin control to alter roles, create backdoors, exfiltrate data, and weaken defenses, impacting **confidentiality**, **integrity**, and **availability**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/multi-factor-authentication-for-all-privileged-users.html#",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks"
|
||||
],
|
||||
"Description": "Enable multi-factor authentication for all roles, groups, and users that have write access or permissions to Azure resources. These include custom created objects or built-in roles such as, - Service Co-Administrators - Subscription Owners - Contributors",
|
||||
"Risk": "Multi-factor authentication requires an individual to present a minimum of two separate forms of authentication before access is granted. Multi-factor authentication provides additional assurance that the individual attempting to gain access is who they claim to be. With multi-factor authentication, an attacker would need to compromise at least two different authentication mechanisms, increasing the difficulty of compromise and thus reducing the risk.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/authentication/concept-mfa-howitworks",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method post --url https://graph.microsoft.com/v1.0/users/<example_resource_id>/authentication/phoneMethods --body '{\"phoneNumber\":\"+10000000000\",\"phoneType\":\"mobile\"}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to Microsoft Entra admin center\n2. Go to Identity > Protection > Conditional Access > + New policy\n3. Name the policy\n4. Under Users > Select users and groups > Directory roles, select the privileged roles to protect\n5. Under Target resources (or Cloud apps), select All cloud apps\n6. Under Grant, select Grant access and check Require multifactor authentication\n7. Set Enable policy to On and click Create\n8. Have each privileged user go to https://myaccount.microsoft.com/security-info and add at least one MFA method (e.g., Microsoft Authenticator or phone) to complete registration",
|
||||
"Terraform": "```hcl\nresource \"azuread_conditional_access_policy\" \"<example_resource_name>\" {\n display_name = \"<example_resource_name>\"\n state = \"enabled\"\n\n conditions {\n users {\n included_roles = [\"<example_role_template_id>\"] # Critical: targets privileged role(s)\n }\n applications {\n included_applications = [\"All\"]\n }\n }\n\n grant_controls {\n operator = \"OR\"\n built_in_controls = [\"mfa\"] # Critical: requires MFA to access\n }\n}\n```"
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/multi-factor-authentication-for-all-privileged-users.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **MFA** for all privileged roles via **Conditional Access** or security defaults. Prefer **phishing-resistant** methods (FIDO2, passkeys, Authenticator push) over SMS/voice. Require registration before granting privileges, block legacy/basic auth, and apply **least privilege** with protected break-glass accounts.",
|
||||
"Url": "https://hub.prowler.com/check/entra_privileged_user_has_mfa"
|
||||
"Text": "Activate one of the available multi-factor authentication methods for users in Microsoft Entra ID.",
|
||||
"Url": "https://learn.microsoft.com/en-us/entra/identity/authentication/tutorial-enable-azure-mfa"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Users would require two forms of authentication before any access is granted. Additional administrative time will be required for managing dual forms of authentication when enabling multi-factor authentication."
|
||||
|
||||
+11
-17
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_security_defaults_enabled",
|
||||
"CheckTitle": "Microsoft Entra ID tenant has Security Defaults enabled",
|
||||
"CheckTitle": "Ensure Security Defaults is enabled on Microsoft Entra ID",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "high",
|
||||
"ResourceType": "#microsoft.graph.identitySecurityDefaultsEnforcementPolicy",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "Microsoft Entra **Security defaults** provide tenant-wide baseline identity protections:\n- MFA registration and challenges\n- Legacy auth (`IMAP/POP/SMTP`) blocked\n- Extra checks for privileged access\n\nThis evaluation identifies whether that baseline is enabled at the tenant level.",
|
||||
"Risk": "Absent these defaults, users can sign in with **password-only** or via **legacy protocols** that bypass MFA, enabling **password spray**, replay, and phishing-based takeovers. Compromise risks data exposure (confidentiality), unauthorized changes (integrity), and service disruption (availability).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/security-defaults-enabled.html",
|
||||
"https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults"
|
||||
],
|
||||
"Description": "Security defaults in Microsoft Entra ID make it easier to be secure and help protect your organization. Security defaults contain preconfigured security settings for common attacks. Security defaults is available to everyone. The goal is to ensure that all organizations have a basic level of security enabled at no extra cost. You may turn on security defaults in the Azure portal.",
|
||||
"Risk": "Security defaults provide secure default settings that we manage on behalf of organizations to keep customers safe until they are ready to manage their own identity security settings. For example, doing the following: - Requiring all users and admins to register for MFA. - Challenging users with MFA - when necessary, based on factors such as location, device, role, and task. - Disabling authentication from legacy authentication clients, which can’t do MFA.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method PATCH --url https://graph.microsoft.com/v1.0/policies/identitySecurityDefaultsEnforcementPolicy --body '{\"isEnabled\":true}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center with a Conditional Access Administrator or Global Administrator account\n2. Go to Identity > Overview > Properties\n3. Click Manage security defaults\n4. Select Enabled and click Save",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/security-defaults-enabled.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Activate **Security defaults** or implement equivalent **Conditional Access** as defense in depth:\n- Require MFA for all identities\n- Block legacy authentication\n- Safeguard admin portals and APIs\nApply **least privilege** and **zero trust**, and regularly review access patterns and break-glass exceptions to keep coverage complete.",
|
||||
"Url": "https://hub.prowler.com/check/entra_security_defaults_enabled"
|
||||
"Text": "1. From Azure Home select the Portal Menu. 2. Browse to Microsoft Entra ID > Properties 3. Select Manage security defaults 4. Set the Enable security defaults to Enabled 5. Select Save",
|
||||
"Url": "https://techcommunity.microsoft.com/t5/microsoft-entra-blog/introducing-security-defaults/ba-p/1061414"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This recommendation should be implemented initially and then may be overridden by other service/product specific CIS Benchmarks. Administrators should also be aware that certain configurations in Microsoft Entra ID may impact other Microsoft services such as Microsoft 365."
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_trusted_named_locations_exists",
|
||||
"CheckTitle": "Entra tenant has a trusted named location with IP ranges defined",
|
||||
"CheckTitle": "Ensure Trusted Locations Are Defined",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "low",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "#microsoft.graph.ipNamedLocation",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**Microsoft Entra ID Conditional Access** supports **trusted named locations** defined by **public IP ranges**. Presence of at least one location marked `trusted` with IP CIDR ranges available for use in policy conditions.",
|
||||
"Risk": "Without trusted IP-based locations, policies can't reliably distinguish corporate networks from unknown sources. This weakens **confidentiality and integrity**, enabling risky sign-ins to avoid stricter controls and forcing coarse rules that over-prompt users or leave **account takeover** and **data exfiltration** paths open.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-7-restrict-resource-access-based-on--conditions",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/conditional-access/location-condition"
|
||||
],
|
||||
"Description": "Microsoft Entra ID Conditional Access allows an organization to configure Named locations and configure whether those locations are trusted or untrusted. These settings provide organizations the means to specify Geographical locations for use in conditional access policies, or define actual IP addresses and IP ranges and whether or not those IP addresses and/or ranges are trusted by the organization.",
|
||||
"Risk": "Defining trusted source IP addresses or ranges helps organizations create and enforce Conditional Access policies around those trusted or untrusted IP addresses and ranges. Users authenticating from trusted IP addresses and/or ranges may have less access restrictions or access requirements when compared to users that try to authenticate to Microsoft Entra ID from untrusted locations or untrusted source IP addresses/ranges.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/entra/identity/conditional-access/location-condition",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method post --url https://graph.microsoft.com/v1.0/identity/conditionalAccess/namedLocations --headers Content-Type=application/json --body '{\"@odata.type\":\"#microsoft.graph.ipNamedLocation\",\"displayName\":\"<example_resource_name>\",\"isTrusted\":true,\"ipRanges\":[{\"@odata.type\":\"#microsoft.graph.iPv4CidrRange\",\"cidrAddress\":\"203.0.113.0/24\"}]}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center (entra.microsoft.com)\n2. Go to Microsoft Entra ID > Protection > Conditional Access > Named locations\n3. Click New location\n4. Enter Name: <example_resource_name>\n5. Choose IP ranges location and add an IP range (e.g., 203.0.113.0/24)\n6. Check Mark as trusted location\n7. Click Create",
|
||||
"Terraform": "```hcl\nresource \"azuread_named_location\" \"<example_resource_name>\" {\n display_name = \"<example_resource_name>\"\n\n ip {\n ip_ranges = [\"203.0.113.0/24\"]\n trusted = true # Critical: marks the location as trusted for Conditional Access policies\n }\n}\n```"
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Define **named locations** for your organization's egress IP ranges and mark them as `trusted`. Keep ranges accurate and narrow; review regularly. Use them in **Conditional Access** to enforce stronger controls off trusted networks. Apply **zero trust** and **least privilege**, and require MFA or device compliance when outside trusted locations.",
|
||||
"Url": "https://hub.prowler.com/check/entra_trusted_named_locations_exists"
|
||||
"Text": "1. Navigate to the Microsoft Entra ID Conditional Access Blade 2. Click on the Named locations blade 3. Within the Named locations blade, click on IP ranges location 4. Enter a name for this location setting in the Name text box 5. Click on the + sign 6. Add an IP Address Range in CIDR notation inside the text box that appears 7. Click on the Add button 8. Repeat steps 5 through 7 for each IP Range that needs to be added 9. If the information entered are trusted ranges, select the Mark as trusted location check box 10. Once finished, click on Create",
|
||||
"Url": "https://learn.microsoft.com/en-us/security/benchmark/azure/mcsb-identity-management#im-7-restrict-resource-access-based-on--conditions"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "When configuring Named locations, the organization can create locations using Geographical location data or by defining source IP addresses or ranges. Configuring Named locations using a Country location does not provide the organization the ability to mark those locations as trusted, and any Conditional Access policy relying on those Countries location setting will not be able to use the All trusted locations setting within the Conditional Access policy. They instead will have to rely on the Select locations setting. This may add additional resource requirements when configuring, and will require thorough organizational testing. In general, Conditional Access policies may completely prevent users from authenticating to Microsoft Entra ID, and thorough testing is recommended. To avoid complete lockout, a 'Break Glass' account with full Global Administrator rights is recommended in the event all other administrators are locked out of authenticating to Microsoft Entra ID. This 'Break Glass' account should be excluded from Conditional Access Policies and should be configured with the longest pass phrase feasible. This account should only be used in the event of an emergency and complete administrator lockout."
|
||||
|
||||
+10
-17
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_user_with_vm_access_has_mfa",
|
||||
"CheckTitle": "Entra ID user with VM access has multi-factor authentication enabled",
|
||||
"CheckTitle": "Ensure only MFA enabled identities can access privileged Virtual Machine",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "#microsoft.graph.users",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra** users with Azure roles that grant VM sign-in or management access-such as `Owner`, `Contributor`, `Virtual Machine * Login`, and `Virtual Machine Contributor`-are evaluated for **multi-factor authentication** enrollment. The finding highlights accounts with VM access that lack more than one authentication factor.",
|
||||
"Risk": "Without **MFA**, accounts with VM access are vulnerable to phishing, password spraying, and credential stuffing. Compromise can enable remote VM login, abuse of the VM's managed identity, privilege escalation, and lateral movement-impacting confidentiality, integrity, and availability of workloads.",
|
||||
"Description": "Verify identities without MFA that can log in to a privileged virtual machine using separate login credentials. An adversary can leverage the access to move laterally and perform actions with the virtual machine's managed identity. Make sure the virtual machine only has necessary permissions, and revoke the admin-level permissions according to the least privileges principal",
|
||||
"Risk": "Managed disks are by default encrypted on the underlying hardware, so no additional encryption is required for basic protection. It is available if additional encryption is required. Managed disks are by design more resilient that storage accounts. For ARM-deployed Virtual Machines, Azure Adviser will at some point recommend moving VHDs to managed disks both from a security and cost management perspective.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.rebeladmin.com/step-step-guide-enable-mfa-azure-admins-preview/",
|
||||
"https://learn.microsoft.com/en-us/entra/identity/authentication/howto-mfa-userdevicesettings",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/vm-access-with-mfa-enabled-identities.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method post --url https://graph.microsoft.com/v1.0/users/<example_user_id>/authentication/phoneMethods --body '{\"phoneNumber\":\"+10000000000\",\"phoneType\":\"mobile\"}'",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to Microsoft Entra admin center (entra.microsoft.com) with an admin account\n2. Go to Identity > Users > select the user with VM access\n3. Select Authentication methods > Add authentication method > choose Phone\n4. Enter the user's E.164 phone number (e.g., +15551234567) and click Add",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **MFA** for all identities that can sign in to or manage VMs via **Conditional Access**, preferring strong, phishing-resistant methods. Apply **least privilege** by removing broad roles (`Owner`, `Contributor`) when not required. Use **PIM/JIT** for admin access and monitor sign-in risk for continuous assurance.",
|
||||
"Url": "https://hub.prowler.com/check/entra_user_with_vm_access_has_mfa"
|
||||
"Text": "1. Log in to the Azure portal. Reducing access of managed identities attached to virtual machines. 2. This can be remediated by enabling MFA for user, Removing user access or • Case I : Enable MFA for users having access on virtual machines. 1. Navigate to Azure AD from the left pane and select Users from the Manage section. 2. Click on Per-User MFA from the top menu options and select each user with MULTI-FACTOR AUTH STATUS as Disabled and can login to virtual machines: From quick steps on the right side select enable. Click on enable multi-factor auth and share the link with the user to setup MFA as required. • Case II : Removing user access on a virtual machine. 1. Select the Subscription, then click on Access control (IAM). 2. Select Role assignments and search for Virtual Machine Administrator Login or Virtual Machine User Login or any role that provides access to log into virtual machines. 3. Click on Role Name, Select Assignments, and remove identities with no MFA configured. • Case III : Reducing access of managed identities attached to virtual machines. 1. Select the Subscription, then click on Access control (IAM). 2. Select Role Assignments from the top menu and apply filters on Assignment type as Privileged administrator roles and Type as Virtual Machines. 3. Click on Role Name, Select Assignments, and remove identities access make sure this follows the least privileges principal.",
|
||||
"Url": ""
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This recommendation requires an Azure AD P2 License to implement. Ensure that identities that are provisioned to a virtual machine utilizes an RBAC/ABAC group and is allocated a role using Azure PIM, and the Role settings require MFA or use another PAM solution (like CyberArk) for accessing Virtual Machines."
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "entra_users_cannot_create_microsoft_365_groups",
|
||||
"CheckTitle": "Microsoft 365 group creation by users is disabled",
|
||||
"CheckTitle": "Ensure that 'Users can create Microsoft 365 groups in Azure portals, API or PowerShell' is set to 'No'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.aadiam/tenants",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Microsoft.Users/Settings",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Microsoft Entra** directory setting **Group.Unified** governs who can create **Microsoft 365 Groups**. The evaluation inspects `EnableGroupCreation` and, when present, `GroupCreationAllowedGroupId` to determine if group creation is broadly allowed or restricted to a designated group.",
|
||||
"Risk": "Unrestricted group creation drives sprawl of Teams, SharePoint sites, and mailboxes, undermining **confidentiality** via public spaces and guest invites. Compromised accounts can create groups to stage exfiltration or impersonation. It also heightens **integrity** risks from unsanctioned owners and **operational** burden for lifecycle and governance.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/ActiveDirectory/users-can-create-office-365-groups.html#",
|
||||
"https://learn.microsoft.com/en-us/microsoft-365/community/all-about-groups#microsoft-365-groups",
|
||||
"https://learn.microsoft.com/en-us/microsoft-365/solutions/manage-creation-of-groups?view=o365-worldwide&redirectSourcePath=%252fen-us%252farticle%252fControl-who-can-create-Office-365-Groups-4c46c8cb-17d0-44b5-9776-005fced8e618"
|
||||
],
|
||||
"Description": "Restrict Microsoft 365 group creation to administrators only.",
|
||||
"Risk": "Restricting Microsoft 365 group creation to administrators only ensures that creation of Microsoft 365 groups is controlled by the administrator. Appropriate groups should be created and managed by the administrator and group creation rights should not be delegated to any other user.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/microsoft-365/community/all-about-groups#microsoft-365-groups",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "Update-MgDirectorySetting -DirectorySettingId (Get-MgDirectorySetting | Where-Object {$_.DisplayName -eq 'Group.Unified'}).Id -BodyParameter @{Values = @(@{Name = 'EnableGroupCreation'; Value = 'false'})}",
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center\n2. Go to Groups > General\n3. Set \"Users can create Microsoft 365 groups in Azure portals, API, or PowerShell\" to \"No\"\n4. Click Save",
|
||||
"Terraform": "```hcl\ndata \"azuread_directory_setting_template\" \"unified\" {\n display_name = \"Group.Unified\"\n}\n\nresource \"azuread_directory_setting\" \"example_resource_name\" {\n template_id = data.azuread_directory_setting_template.unified.id\n\n values = {\n EnableGroupCreation = \"false\"\n # Critical: sets EnableGroupCreation to false so users cannot create Microsoft 365 groups\n }\n}\n```"
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/ActiveDirectory/users-can-create-office-365-groups.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Apply **least privilege**: set `EnableGroupCreation=false` and allow only a controlled group via `GroupCreationAllowedGroupId`. Use **governed provisioning** with naming policies, sensitivity labels, and expiration/owner reviews. Monitor creation events and enforce **separation of duties** with approvals and lifecycle management.",
|
||||
"Url": "https://hub.prowler.com/check/entra_users_cannot_create_microsoft_365_groups"
|
||||
"Text": "1. From Azure Home select the Portal Menu 2. Select Microsoft Entra ID 3. Then Groups 4. Select General in settings 5. Set Users can create Microsoft 365 groups in Azure portals, API or PowerShell to No",
|
||||
"Url": "https://learn.microsoft.com/en-us/microsoft-365/solutions/manage-creation-of-groups?view=o365-worldwide&redirectSourcePath=%252fen-us%252farticle%252fControl-who-can-create-Office-365-Groups-4c46c8cb-17d0-44b5-9776-005fced8e618"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Enabling this setting could create a number of requests that would need to be managed by an administrator."
|
||||
|
||||
+13
-21
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_access_only_through_private_endpoints",
|
||||
"CheckTitle": "Key Vault using private endpoints has public network access disabled",
|
||||
"CheckTitle": "Ensure that public network access when using private endpoint is disabled.",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"ResourceIdTemplate": "/subscriptions/{subscription_id}/resourceGroups/{resource_group}/providers/Microsoft.KeyVault/vaults/{vault_name}",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vaults** configured with **private endpoints** have **public network access** set to `Disabled`, so connectivity occurs only over the private link.",
|
||||
"Risk": "Internet exposure alongside a **private endpoint** breaks isolation and expands attack surface:\n- Brute-force or token replay on the data plane\n- Abuse of misconfigured allowlists or trusted bypass\n- DDoS on the public endpoint\nThis can enable secret exfiltration or unauthorized key use, impacting **confidentiality** and **integrity**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://support.icompaas.com/support/solutions/articles/62000234050-ensure-that-public-network-access-when-using-private-endpoint-is-disabled-automated-",
|
||||
"https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/network-security"
|
||||
],
|
||||
"Description": "Checks if Key Vaults with private endpoints have public network access disabled.",
|
||||
"Risk": "Allowing public network access to Key Vault when using private endpoint can expose sensitive data to unauthorized access.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/key-vault/general/network-security",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault update --resource-group <resource_group> --name <vault_name> --public-network-access Disabled",
|
||||
"NativeIaC": "```bicep\n// Disable public network access for the Key Vault\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {\n name: '<example_resource_name>'\n location: '<location>'\n properties: {\n tenantId: '<tenant_id>'\n sku: {\n name: 'standard'\n family: 'A'\n }\n publicNetworkAccess: 'Disabled' // Critical: disables public access so only private endpoints are used\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Key vaults and select your vault\n2. Open Networking\n3. Under Public access, set Public network access to Disabled\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault\" \"kv\" {\n name = \"<example_resource_name>\"\n location = \"<location>\"\n resource_group_name = \"<example_resource_name>\"\n tenant_id = \"<tenant_id>\"\n sku_name = \"standard\"\n\n public_network_access_enabled = false # Critical: disables public access when using private endpoints\n}\n```"
|
||||
"CLI": "az keyvault update --resource-group <resource_group> --name <vault_name> --public-network-access disabled",
|
||||
"NativeIaC": "{\n \"type\": \"Microsoft.KeyVault/vaults\",\n \"apiVersion\": \"2022-07-01\",\n \"properties\": {\n \"publicNetworkAccess\": \"disabled\"\n }\n}",
|
||||
"Terraform": "resource \"azurerm_key_vault\" \"example\" {\n # ... other configuration ...\n\n public_network_access_enabled = false\n}",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/KeyVault/use-private-endpoints.html"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Restrict access to **private endpoints** only:\n- Set `publicNetworkAccess` to `Disabled`\n- Avoid broad allowlists; limit `Trusted services`\n- Use private DNS with controlled egress\n- Enforce **least privilege** and monitor access logs\nThis sustains **defense in depth** and prevents Internet exposure.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_access_only_through_private_endpoints"
|
||||
"Text": "Disable public network access for Key Vaults that use private endpoint to ensure network traffic only flows through the private endpoint.",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_key_expiration_set_in_non_rbac",
|
||||
"CheckTitle": "Key Vault without RBAC authorization has expiration date set for all enabled keys",
|
||||
"CheckTitle": "Ensure that the Expiration Date is set for all Keys in Non-RBAC Key Vaults.",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vaults** using access **policies (non-RBAC)** are assessed to confirm all **enabled keys** have an `expiration` (`exp`) defined. The finding highlights keys in these vaults that lack a set lifetime.",
|
||||
"Risk": "Non-expiring keys enable indefinite use, degrading **confidentiality** and **integrity**. Stale or compromised keys can decrypt data, forge signatures, and maintain persistence. Absent lifetimes weaken rotation discipline and impede timely revocation, increasing exposure to cryptographic and operational drift.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/basic-concepts",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#key-vault-keys",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/KeyVault/key-expiration-check.html#"
|
||||
],
|
||||
"Description": "Ensure that all Keys in Non Role Based Access Control (RBAC) Azure Key Vaults have an expiration date set.",
|
||||
"Risk": "Azure Key Vault enables users to store and use cryptographic keys within the Microsoft Azure environment. The exp (expiration date) attribute identifies the expiration date on or after which the key MUST NOT be used for a cryptographic operation. By default, keys never expire. It is thus recommended that keys be rotated in the key vault and set an explicit expiration date for all keys. This ensures that the keys cannot be used beyond their assigned lifetimes.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault key set-attributes --vault-name <example_resource_name> --name <example_resource_name> --expires 2030-01-01T00:00:00Z",
|
||||
"NativeIaC": "```bicep\n// Set expiration on a Key Vault key\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {\n name: '<example_resource_name>'\n}\n\nresource key 'Microsoft.KeyVault/vaults/keys@2023-07-01' = {\n name: '<example_resource_name>'\n parent: kv\n properties: {\n kty: 'RSA'\n attributes: {\n exp: 1893456000 // CRITICAL: sets the key expiration (Unix epoch seconds) to pass the check\n }\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Key vaults and open the vault\n2. Select Keys and choose the enabled key that failed\n3. Open the current version and click Edit (or Update)\n4. Set Expiration date (UTC) to a future date\n5. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault_key\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n key_vault_id = \"<example_resource_id>\"\n key_type = \"RSA\"\n\n expires_on = \"2030-01-01T00:00:00Z\" # CRITICAL: sets key expiration to pass the check\n}\n```"
|
||||
"CLI": "az keyvault key set-attributes --name <keyName> --vault-name <vaultName> --expires Y-m-d'T'H:M:S'Z'",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/KeyVault/key-expiration-check.html#",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/set-an-expiration-date-on-all-keys#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an `expiration` on all keys and enforce **automated rotation** with advance alerts. Retire or disable old versions promptly and rotate after any suspected exposure. Apply **least privilege** and **separation of duties** for key administration. Prefer standardized lifecycle policies (e.g., RBAC-based governance) to enforce consistent control.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_key_expiration_set_in_non_rbac"
|
||||
"Text": "From Azure Portal: 1. Go to Key vaults. 2. For each Key vault, click on Keys. 3. In the main pane, ensure that an appropriate Expiration date is set for any keys that are Enabled. From Azure CLI: Update the Expiration date for the key using the below command: az keyvault key set-attributes --name <keyName> --vault-name <vaultName> -- expires Y-m-d'T'H:M:S'Z' Note: To view the expiration date on all keys in a Key Vault using Microsoft API, the 'List' Key permission is required. To update the expiration date for the keys: 1. Go to the Key vault, click on Access Control (IAM). 2. Click on Add role assignment and assign the role of Key Vault Crypto Officer to the appropriate user. From PowerShell: Set-AzKeyVaultKeyAttribute -VaultName <VaultName> -Name <KeyName> -Expires <DateTime>",
|
||||
"Url": "https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-keys"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Keys cannot be used beyond their assigned expiration dates respectively. Keys need to be rotated periodically wherever they are used."
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_key_rotation_enabled",
|
||||
"CheckTitle": "Key Vault key has automatic rotation enabled",
|
||||
"CheckTitle": "Ensure Automatic Key Rotation is Enabled Within Azure Key Vault for the Supported Services",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault** keys configured with a **rotation policy** that includes a `Rotate` lifetime action.\n\nThe evaluation looks for lifetime actions that schedule automatic key version creation; keys without this policy are not configured for auto-rotation.",
|
||||
"Risk": "Without **auto-rotation**, keys may outlive policy, increasing exposure if material is leaked and weakening **confidentiality**.\n\nExpired keys without planned rollover can break decrypt/unwrap operations, impacting **availability**. Long-lived keys hinder incident response and enable prolonged misuse of stale versions.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/storage/common/customer-managed-keys-overview#update-the-key-version",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/keys/how-to-configure-key-rotation",
|
||||
"https://www.techtarget.com/searchcloudcomputing/tutorial/How-to-perform-and-automate-key-rotation-in-Azure-Key-Vault"
|
||||
],
|
||||
"Description": "Automatic Key Rotation is available in Public Preview. The currently supported applications are Key Vault, Managed Disks, and Storage accounts accessing keys within Key Vault. The number of supported applications will incrementally increased.",
|
||||
"Risk": "Once set up, Automatic Private Key Rotation removes the need for manual administration when keys expire at intervals determined by your organization's policy. The recommended key lifetime is 2 years. Your organization should determine its own key expiration policy.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/keys/how-to-configure-key-rotation",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault key rotation-policy update --vault-name <VAULT_NAME> --name <KEY_NAME> --value '{\"lifetimeActions\":[{\"trigger\":{\"timeAfterCreate\":\"P18M\"},\"action\":{\"type\":\"Rotate\"}}]}'",
|
||||
"NativeIaC": "```bicep\nresource key 'Microsoft.KeyVault/vaults/keys@2023-02-01' = {\n name: '<example_resource_name>/<example_resource_name>'\n location: resourceGroup().location\n properties: {\n kty: 'RSA'\n rotationPolicy: {\n lifetimeActions: [\n {\n trigger: { timeAfterCreate: 'P18M' }\n action: { type: 'Rotate' } // Critical: enables automatic rotation, satisfying the check\n }\n ]\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Key Vaults > <your key vault> > Keys\n2. Select the key <KEY_NAME>\n3. Click Rotation policy\n4. Enable auto-rotation and set a rotation interval (e.g., After creation: P18M)\n5. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault_key\" \"key\" {\n name = \"<example_resource_name>\"\n key_vault_id = \"<example_resource_id>\"\n key_type = \"RSA\"\n\n rotation_policy {\n automatic {\n time_after_creation = \"P18M\" # Critical: creates a Rotate lifetime action to enable auto-rotation\n }\n }\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Define a per-key **rotation policy** to automatically `Rotate` on a fixed cadence (e.g., `P2Y`) and set an **expiry** to enforce lifecycle.\n\nUse versionless key URIs in dependent services, apply **least privilege** to rotation roles, enable near-expiry notifications, and monitor events for **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_key_rotation_enabled"
|
||||
"Text": "Note: Azure CLI and Powershell use ISO8601 flags to input timespans. Every timespan input will be in the format P<timespanInISO8601Format>(Y,M,D). The leading P is required with it denoting period. The (Y,M,D) are for the duration of Year, Month,and Day respectively. A time frame of 2 years, 2 months, 2 days would be (P2Y2M2D). From Azure Portal 1. From Azure Portal select the Portal Menu in the top left. 2. Select Key Vaults. 3. Select a Key Vault to audit. 4. Under Objects select Keys. 5. Select a key to audit. 6. In the top row select Rotation policy. 7. Select an Expiry time. 8. Set Enable auto rotation to Enabled. 9. Set an appropriate Rotation option and Rotation time. 10. Optionally set the Notification time. 11. Select Save. 12. Repeat steps 3-11 for each Key Vault and Key. From PowerShell Run the following command for each key to update its policy: Set-AzKeyVaultKeyRotationPolicy -VaultName test-kv -Name test-key -PolicyPath rotation_policy.json",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/storage/common/customer-managed-keys-overview#update-the-key-version"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "There are an additional costs per operation in running the needed applications."
|
||||
|
||||
+13
-21
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_logging_enabled",
|
||||
"CheckTitle": "Key Vault has a diagnostic setting capturing audit logs",
|
||||
"CheckTitle": "Ensure that logging for Azure Key Vault is 'Enabled'",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault** diagnostic settings capture **audit logs** (`AuditEvent`) when category groups `audit` and `allLogs` are enabled and routed to a supported destination. Logged events include management and data-plane operations on vaults, keys, secrets, and certificates.",
|
||||
"Risk": "Without **Key Vault audit logging**, access and changes to keys, secrets, and certificates are untracked.\n\nAttackers can misuse keys to decrypt data, alter or delete crypto material, and evade detection-eroding **confidentiality** and **integrity** and delaying **incident response**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/logging?tabs=Vault",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/KeyVault/enable-audit-event-logging-for-azure-key-vaults.html",
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-8-ensure-security-of-key-and-certificate-repository",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/howto-logging?tabs=azure-cli"
|
||||
],
|
||||
"Description": "Enable AuditEvent logging for key vault instances to ensure interactions with key vaults are logged and available.",
|
||||
"Risk": "Monitoring how and when key vaults are accessed, and by whom, enables an audit trail of interactions with confidential information, keys, and certificates managed by Azure Keyvault. Enabling logging for Key Vault saves information in an Azure storage account which the user provides. This creates a new container named insights-logs-auditevent automatically for the specified storage account. This same storage account can be used for collecting logs for multiple key vaults.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-logging",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az monitor diagnostic-settings create --name <example_resource_name> --resource <example_resource_id> --workspace <example_resource_id> --logs '[{\"categoryGroup\":\"audit\",\"enabled\":true},{\"categoryGroup\":\"allLogs\",\"enabled\":true}]'",
|
||||
"NativeIaC": "```bicep\n// Enable Key Vault diagnostic settings with audit + allLogs\nparam keyVaultName string\nparam workspaceId string\n\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' existing = {\n name: keyVaultName\n}\n\nresource diag 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = {\n name: '<example_resource_name>'\n scope: kv\n properties: {\n workspaceId: workspaceId\n logs: [\n {\n categoryGroup: 'audit' // critical: enables audit logs\n enabled: true // required to pass the check\n }\n {\n categoryGroup: 'allLogs' // critical: enables allLogs group\n enabled: true // required to pass the check\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to your Key Vault > Monitoring > Diagnostic settings\n2. Click Add diagnostic setting\n3. Under Category groups, select audit and allLogs\n4. Choose a destination (e.g., Send to Log Analytics workspace) and select the workspace\n5. Click Save",
|
||||
"Terraform": "```hcl\n# Enable diagnostic settings on Key Vault with audit + allLogs\nresource \"azurerm_monitor_diagnostic_setting\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n target_resource_id = \"<example_resource_id>\" # Key Vault resource ID\n log_analytics_workspace_id = \"<example_resource_id>\" # Destination workspace ID\n\n enabled_log { # critical: audit category group\n category_group = \"audit\" # enables audit logs\n }\n enabled_log { # critical: allLogs category group\n category_group = \"allLogs\" # enables all logs\n }\n}\n```"
|
||||
"CLI": "az monitor diagnostic-settings create --name <diagnostic settings name> --resource <key vault resource ID> --logs'[{category:AuditEvents,enabled:true,retention-policy:{enabled:true,days:180}}]' --metrics'[{category:AllMetrics,enabled:true,retention-policy:{enabled:true,days:180}}]' <[--event-hub <event hub ID> --event-hub-rule <event hub auth rule ID> | --storage-account <storage account ID> |--workspace <log analytics workspace ID> | --marketplace-partner-id <full resource ID of third-party solution>]>",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity-staging/knowledge-base/azure/KeyVault/enable-audit-event-logging-for-azure-key-vaults.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable **diagnostic settings** to collect `AuditEvent` logs-covering category groups `audit` and `allLogs`-and send them to a central sink. Apply **least privilege** to log access, enforce secure **retention/immutability**, monitor with alerts for anomalous operations, and use **separation of duties** to prevent logging bypass.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_logging_enabled"
|
||||
"Text": "1. Go to Key vaults 2. For each Key vault 3. Go to Diagnostic settings 4. Click on Edit Settings 5. Ensure that Archive to a storage account is Enabled 6. Ensure that AuditEvent is checked, and the retention days is set to 180 days or as appropriate",
|
||||
"Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-8-ensure-security-of-key-and-certificate-repository"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"logging"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "By default, Diagnostic AuditEvent logging is not enabled for Key Vault instances."
|
||||
|
||||
+13
-19
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_non_rbac_secret_expiration_set",
|
||||
"CheckTitle": "Non-RBAC Key Vault has expiration date set for all secrets",
|
||||
"CheckTitle": "Ensure that the Expiration Date is set for all Secrets in Non-RBAC Key Vaults",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"Severity": "high",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault (non-RBAC)** secrets are expected to have an **explicit expiration date**.\n\nThis examines each **enabled secret** to confirm the `expires` attribute is defined.",
|
||||
"Risk": "Secrets without expiration persist indefinitely, widening the window for misuse.\n\nIf leaked or forgotten, they allow long-term, covert access to services and data, undermining **confidentiality** and **integrity**, and complicating incident response and revocation.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/basic-concepts",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#key-vault-secrets"
|
||||
],
|
||||
"Description": "Ensure that all Secrets in Non Role Based Access Control (RBAC) Azure Key Vaults have an expiration date set.",
|
||||
"Risk": "The Azure Key Vault enables users to store and keep secrets within the Microsoft Azure environment. Secrets in the Azure Key Vault are octet sequences with a maximum size of 25k bytes each. The exp (expiration date) attribute identifies the expiration date on or after which the secret MUST NOT be used. By default, secrets never expire. It is thus recommended to rotate secrets in the key vault and set an explicit expiration date for all secrets. This ensures that the secrets cannot be used beyond their assigned lifetimes.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault secret set-attributes --vault-name <vaultName> --name <secretName> --expires <YYYY-MM-DDTHH:MM:SSZ>",
|
||||
"NativeIaC": "```bicep\n// Set an expiration date on a Key Vault secret\nresource secret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {\n name: '<example_vault_name>/<example_resource_name>'\n properties: {\n value: '<example_value>'\n attributes: {\n exp: 1767225599 // CRITICAL: sets the secret expiration (Unix time in seconds) so the check passes\n }\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Key vaults and open your vault\n2. Select Secrets, then click the secret that failed\n3. Click + New version\n4. Set Expiration date and click Create\n5. Repeat for any other secret without an expiration",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault_secret\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n value = \"<example_value>\"\n key_vault_id = \"<example_resource_id>\"\n\n expiration_date = \"2025-12-31T23:59:59Z\" # CRITICAL: sets the secret expiration so the check passes\n}\n```"
|
||||
"CLI": "az keyvault secret set-attributes --name <secretName> --vault-name <vaultName> --expires Y-m-d'T'H:M:S'Z'",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-secrets-policies/set-an-expiration-date-on-all-secrets#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an **expiration** on every secret and enforce a **rotation policy** aligned with risk and compliance.\n\nAutomate rotation and alerts, disable or purge stale versions, and apply **least privilege**. *Where possible*, use **managed identities** to reduce secret sprawl.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_non_rbac_secret_expiration_set"
|
||||
"Text": "From Azure Portal: 1. Go to Key vaults. 2. For each Key vault, click on Secrets. 3. In the main pane, ensure that the status of the secret is Enabled. 4. Set an appropriate Expiration date on all secrets. From Azure CLI: Update the Expiration date for the secret using the below command: az keyvault secret set-attributes --name <secretName> --vault-name <vaultName> --expires Y-m-d'T'H:M:S'Z' Note: To view the expiration date on all secrets in a Key Vault using Microsoft API, the List Key permission is required. To update the expiration date for the secrets: 1. Go to Key vault, click on Access policies. 2. Click on Create and add an access policy with the Update permission (in the Secret Permissions - Secret Management Operations section). From PowerShell: For each Key vault with the EnableRbacAuthorization setting set to False or empty, run the following command. Set-AzKeyVaultSecret -VaultName <Vault Name> -Name <Secret Name> -Expires <DateTime>",
|
||||
"Url": "https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-secrets"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Secrets cannot be used beyond their assigned expiry date respectively. Secrets need to be rotated periodically wherever they are used."
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_private_endpoints",
|
||||
"CheckTitle": "Key Vault uses private endpoints",
|
||||
"CheckTitle": "Ensure that Private Endpoints are Used for Azure Key Vault",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault** has **private endpoint connections** to serve secret and key operations over a private IP within your virtual network via Azure Private Link.",
|
||||
"Risk": "Without **private endpoints**, the vault relies on a public endpoint, expanding exposure to scanning and misconfigured allowlists. Egress controls are harder to enforce, enabling unauthorized secret retrieval for **data exfiltration** and potential key misuse, impacting confidentiality and integrity.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/private-link/private-endpoint-overview",
|
||||
"https://learn.microsoft.com/en-us/azure/storage/common/storage-private-endpoints"
|
||||
],
|
||||
"Description": "Private endpoints will secure network traffic from Azure Key Vault to the resources requesting secrets and keys.",
|
||||
"Risk": "Private endpoints will keep network requests to Azure Key Vault limited to the endpoints attached to the resources that are whitelisted to communicate with each other. Assigning the Key Vault to a network without an endpoint will allow other resources on that network to view all traffic from the Key Vault to its destination. In spite of the complexity in configuration, this is recommended for high security secrets.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/private-link/private-endpoint-overview",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az network private-endpoint create --name <example_resource_name> --resource-group <example_resource_name> --location <LOCATION> --vnet-name <example_resource_name> --subnet <example_resource_name> --private-connection-resource-id <example_resource_id> --group-ids vault --connection-name <example_resource_name>",
|
||||
"NativeIaC": "```bicep\n// Create a Private Endpoint for an existing Key Vault\nresource pe 'Microsoft.Network/privateEndpoints@2021-08-01' = {\n name: '<example_resource_name>'\n location: '<LOCATION>'\n properties: {\n subnet: {\n id: '<example_resource_id>' // Critical: subnet resource ID where the private endpoint NIC will be placed\n }\n privateLinkServiceConnections: [\n {\n name: '<example_resource_name>'\n properties: {\n privateLinkServiceId: '<example_resource_id>' // Critical: Key Vault resource ID to connect to\n groupIds: [ 'vault' ] // Critical: targets the Key Vault subresource to create the private endpoint connection\n }\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In Azure portal, open your Key Vault\n2. Go to Networking > Private endpoint connections > + Create\n3. Basics: select Subscription and Resource group, then Next\n4. Resource: Service = Microsoft.KeyVault/vaults (your vault is preselected), Subresource = vault, Next\n5. Configuration: choose Virtual network and Subnet, then Next and Create\n6. Wait for the connection state to show Approved (auto-approves if you have permission)",
|
||||
"Terraform": "```hcl\n# Create a Private Endpoint for an existing Key Vault\nresource \"azurerm_private_endpoint\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<LOCATION>\"\n resource_group_name = \"<example_resource_name>\"\n subnet_id = \"<example_resource_id>\" # Critical: subnet resource ID for the private endpoint NIC\n\n private_service_connection {\n name = \"<example_resource_name>\"\n private_connection_resource_id = \"<example_resource_id>\" # Critical: Key Vault resource ID to connect\n subresource_names = [\"vault\"] # Critical: targets Key Vault subresource to create the connection\n }\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable **Private Endpoints** for each Key Vault and disable public network access. Use private DNS so the vault FQDN resolves to the private IP. Apply **least privilege** with RBAC and managed identities, restrict traffic with NSGs and routing, and monitor access logs as part of **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_private_endpoints"
|
||||
"Text": "Please see the additional information about the requirements needed before starting this remediation procedure. From Azure Portal 1. From Azure Home open the Portal Menu in the top left. 2. Select Key Vaults. 3. Select a Key Vault to audit. 4. Select Networking in the left column. 5. Select Private endpoint connections from the top row. 6. Select + Create. 7. Select the subscription the Key Vault is within, and other desired configuration. 8. Select Next. 9. For resource type select Microsoft.KeyVault/vaults. 10. Select the Key Vault to associate the Private Endpoint with. 11. Select Next. 12. In the Virtual Networking field, select the network to assign the Endpoint. 13. Select other configuration options as desired, including an existing or new application security group. 14. Select Next. 15. Select the private DNS the Private Endpoints will use. 16. Select Next. 17. Optionally add Tags. 18. Select Next : Review + Create. 19. Review the information and select Create. Follow the Audit Procedure to determine if it has successfully applied. 20. Repeat steps 3-19 for each Key Vault. From Azure CLI 1. To create an endpoint, run the following command: az network private-endpoint create --resource-group <resourceGroup --vnet- name <vnetName> --subnet <subnetName> --name <PrivateEndpointName> -- private-connection-resource-id '/subscriptions/<AZURE SUBSCRIPTION ID>/resourceGroups/<resourceGroup>/providers/Microsoft.KeyVault/vaults/<keyVa ultName>' --group-ids vault --connection-name <privateLinkConnectionName> -- location <azureRegion> --manual-request 2. To manually approve the endpoint request, run the following command: az keyvault private-endpoint-connection approve --resource-group <resourceGroup> --vault-name <keyVaultName> –name <privateLinkName> 4. Determine the Private Endpoint's IP address to connect the Key Vault to the Private DNS you have previously created: 5. Look for the property networkInterfaces then id, the value must be placed in the variable <privateEndpointNIC> within step 7. az network private-endpoint show -g <resourceGroupName> -n <privateEndpointName> 6. Look for the property networkInterfaces then id, the value must be placed on <privateEndpointNIC> in step 7. az network nic show --ids <privateEndpointName> 7. Create a Private DNS record within the DNS Zone you created for the Private Endpoint: az network private-dns record-set a add-record -g <resourcecGroupName> -z 'privatelink.vaultcore.azure.net' -n <keyVaultName> -a <privateEndpointNIC> 8. nslookup the private endpoint to determine if the DNS record is correct: nslookup <keyVaultName>.vault.azure.net nslookup <keyVaultName>.privatelink.vaultcore.azure.n",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/storage/common/storage-private-endpoints"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed",
|
||||
"trust-boundaries"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Incorrect or poorly-timed changing of network configuration could result in service interruption. There are also additional costs tiers for running a private endpoint perpetabyte or more of networking traffic."
|
||||
|
||||
+12
-18
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_rbac_enabled",
|
||||
"CheckTitle": "Key Vault uses Azure RBAC for access control",
|
||||
"CheckTitle": "Enable Role Based Access Control for Azure Key Vault",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault** uses the **Azure RBAC permission model** for data-plane access to keys, secrets, and certificates, rather than legacy access policies.\n\nEvaluates whether data access is managed through role assignments at the vault.",
|
||||
"Risk": "Without **Azure RBAC**, data access relies on coarse access policies. **Control-plane Contributors** can grant themselves data-plane rights, enabling secret or key exfiltration and unauthorized crypto operations.\n\nLack of JIT and least-privilege weakens **confidentiality** and **integrity** and hinders auditing.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/rbac-guide?tabs=azure-cli",
|
||||
"https://learn.microsoft.com/en-gb/azure/role-based-access-control/role-assignments-portal?tabs=current"
|
||||
],
|
||||
"Description": "WARNING: Role assignments disappear when a Key Vault has been deleted (soft-delete) and recovered. Afterwards it will be required to recreate all role assignments. This is a limitation of the soft-delete feature across all Azure services.",
|
||||
"Risk": "The new RBAC permissions model for Key Vaults enables a much finer grained access control for key vault secrets, keys, certificates, etc., than the vault access policy. This in turn will permit the use of privileged identity management over these roles, thus securing the key vaults with JIT Access management.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-gb/azure/key-vault/general/rbac-migration#vault-access-policy-to-azure-rbac-migration-steps",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault update --name <KEY_VAULT_NAME> --resource-group <RESOURCE_GROUP_NAME> --enable-rbac-authorization true",
|
||||
"NativeIaC": "```bicep\n// Enable Azure RBAC on a Key Vault\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {\n name: '<example_resource_name>'\n location: '<location>'\n properties: {\n tenantId: '<tenant_id>'\n enableRbacAuthorization: true // Critical: switches permission model to Azure RBAC to pass the check\n sku: {\n family: 'A'\n name: 'standard'\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Key Vaults and open <KEY_VAULT_NAME>\n2. Under Settings, select Properties\n3. Set Permission model to Azure role-based access control\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<location>\"\n resource_group_name = \"<example_resource_name>\"\n tenant_id = \"<tenant_id>\"\n sku_name = \"standard\"\n enable_rbac_authorization = true // Critical: enables Azure RBAC to satisfy the control\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Adopt **Azure RBAC** for Key Vault data access and design roles with **least privilege** at appropriate scopes (prefer vault-level per app/env). Use **Privileged Identity Management** for JIT, restrict control-plane Contributor rights, and monitor role assignments. *Role assignments aren't preserved after soft-delete recovery*.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_rbac_enabled"
|
||||
"Text": "From Azure Portal Key Vaults can be configured to use Azure role-based access control on creation. For existing Key Vaults: 1. From Azure Home open the Portal Menu in the top left corner 2. Select Key Vaults 3. Select a Key Vault to audit 4. Select Access configuration 5. Set the Permission model radio button to Azure role-based access control, taking note of the warning message 6. Click Save 7. Select Access Control (IAM) 8. Select the Role Assignments tab 9. Reapply permissions as needed to groups or users",
|
||||
"Url": "https://docs.microsoft.com/en-gb/azure/role-based-access-control/role-assignments-portal?tabs=current"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Implementation needs to be properly designed from the ground up, as this is a fundamental change to the way key vaults are accessed/managed. Changing permissions to key vaults will result in loss of service as permissions are re-applied. For the least amount of downtime, map your current groups and users to their corresponding permission needs."
|
||||
|
||||
+13
-22
@@ -1,39 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_rbac_key_expiration_set",
|
||||
"CheckTitle": "RBAC-enabled Key Vault has expiration date set for all keys",
|
||||
"CheckTitle": "Ensure that the Expiration Date is set for all Keys in RBAC Key Vaults",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"Severity": "high",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vaults** with **RBAC-enabled access control** are evaluated to confirm every **enabled key** defines an **expiration** (`exp`). Any key lacking this attribute is identified.",
|
||||
"Risk": "**Keys without expiration** can remain active indefinitely.\nIf exposed, attackers can decrypt data, forge signatures (code/tokens), and maintain persistence, undermining **confidentiality** and **integrity**. Absent end-of-life also weakens rotation discipline and crypto agility.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/basic-concepts",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#key-vault-keys",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/KeyVault/key-expiration-check.html#",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/azure-policy",
|
||||
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/recommendations-reference-keyvault"
|
||||
],
|
||||
"Description": "Ensure that all Keys in Role Based Access Control (RBAC) Azure Key Vaults have an expiration date set",
|
||||
"Risk": "Azure Key Vault enables users to store and use cryptographic keys within the Microsoft Azure environment. The exp (expiration date) attribute identifies the expiration date on or after which the key MUST NOT be used for encryption of new data, wrapping of new keys, and signing. By default, keys never expire. It is thus recommended that keys be rotated in the key vault and set an explicit expiration date for all keys to help enforce the key rotation. This ensures that the keys cannot be used beyond their assigned lifetimes.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault key set-attributes --vault-name <vaultName> --name <keyName> --expires <YYYY-MM-DDThh:mm:ssZ>",
|
||||
"NativeIaC": "```bicep\n// Set an expiration date on a Key Vault key\nresource key 'Microsoft.KeyVault/vaults/keys@2023-07-01' = {\n name: '<example_resource_name>/<example_resource_name>'\n properties: {\n kty: 'RSA'\n attributes: {\n exp: 1767225599 // Critical: expiration timestamp (UTC epoch seconds). Ensures the key has an expiration date to pass the check.\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Key vaults and open <vaultName>\n2. Select Keys and choose the key missing an expiration\n3. Open the current version and click Update/Edit\n4. Set Expiration date (UTC) and click Save",
|
||||
"Terraform": "```hcl\n# Set an expiration date on a Key Vault key\nresource \"azurerm_key_vault_key\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n key_vault_id = \"<example_resource_id>\"\n key_type = \"RSA\"\n\n expiration_date = \"2025-12-31T23:59:59Z\" # Critical: ensures the key has an expiration date to pass the check\n}\n```"
|
||||
"CLI": "az keyvault key set-attributes --name <keyName> --vault-name <vaultName> --expires Y-m-d'T'H:M:S'Z'",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/KeyVault/key-expiration-check.html#",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/set-an-expiration-date-on-all-keys#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "- Set `exp` on all enabled keys and enforce a **rotation policy** with short lifetimes and automated renewal.\n- Use **governance policies** to require expiration and alert before expiry.\n- Apply **least privilege** and **separation of duties** for key admins vs consumers.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_rbac_key_expiration_set"
|
||||
"Text": "From Azure Portal: 1. Go to Key vaults. 2. For each Key vault, click on Keys. 3. In the main pane, ensure that an appropriate Expiration date is set for any keys that are Enabled. From Azure CLI: Update the Expiration date for the key using the below command: az keyvault key set-attributes --name <keyName> --vault-name <vaultName> -- expires Y-m-d'T'H:M:S'Z' Note: To view the expiration date on all keys in a Key Vault using Microsoft API, the 'List' Key permission is required. To update the expiration date for the keys: 1. Go to the Key vault, click on Access Control (IAM). 2. Click on Add role assignment and assign the role of Key Vault Crypto Officer to the appropriate user. From PowerShell: Set-AzKeyVaultKeyAttribute -VaultName <VaultName> -Name <KeyName> -Expires <DateTime>",
|
||||
"Url": "https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-keys"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Keys cannot be used beyond their assigned expiration dates respectively. Keys need to be rotated periodically wherever they are used."
|
||||
|
||||
+13
-19
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_rbac_secret_expiration_set",
|
||||
"CheckTitle": "RBAC-enabled Key Vault has expiration date set for all enabled secrets",
|
||||
"CheckTitle": "Ensure that the Expiration Date is set for all Secrets in RBAC Key Vaults",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"Severity": "high",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault (RBAC)** secrets are assessed to confirm every **enabled secret** has an `exp` (expiration) date configured",
|
||||
"Risk": "Without an **expiration**, secrets become perpetual credentials. Leaked or abandoned values can grant persistent access, undermining **confidentiality** and **integrity**. Attackers can reuse old secrets to maintain footholds, perform unauthorized API calls, and exfiltrate data.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/basic-concepts",
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/about-keys-secrets-certificates#key-vault-secrets"
|
||||
],
|
||||
"Description": "Ensure that all Secrets in Role Based Access Control (RBAC) Azure Key Vaults have an expiration date set.",
|
||||
"Risk": "The Azure Key Vault enables users to store and keep secrets within the Microsoft Azure environment. Secrets in the Azure Key Vault are octet sequences with a maximum size of 25k bytes each. The exp (expiration date) attribute identifies the expiration date on or after which the secret MUST NOT be used. By default, secrets never expire. It is thus recommended to rotate secrets in the key vault and set an explicit expiration date for all secrets. This ensures that the secrets cannot be used beyond their assigned lifetimes.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-whatis",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault secret set-attributes --vault-name <vaultName> --name <secretName> --expires <YYYY-MM-DDTHH:MM:SSZ>",
|
||||
"NativeIaC": "```bicep\n// Set expiration on a Key Vault secret\nresource secret 'Microsoft.KeyVault/vaults/secrets@2019-09-01' = {\n name: '<example_resource_name>/<example_resource_name>'\n properties: {\n // CRITICAL: sets the secret expiration timestamp (Unix epoch seconds)\n attributes: {\n exp: 1735689600 // 2025-01-01T00:00:00Z\n }\n value: '<secret_value>'\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Key vaults and open your vault\n2. Select Secrets, choose the secret, then open its Current version\n3. Set Expiration date (UTC) and click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault_secret\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n value = \"<secret_value>\"\n key_vault_id = \"<example_resource_id>\"\n\n # CRITICAL: sets the secret expiration timestamp\n expiration_date = \"2025-01-01T00:00:00Z\"\n}\n```"
|
||||
"CLI": "az keyvault secret set-attributes --name <secretName> --vault-name <vaultName> --expires Y-m-d'T'H:M:S'Z'",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-secrets-policies/set-an-expiration-date-on-all-secrets#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an **expiration** on all enabled secrets and enforce a **regular rotation policy**.\n\nPrefer **short-lived, identity-based access** to reduce secret usage. Apply **least privilege** for secret access, alert on upcoming expirations, and automate rotation and version cleanup to minimize exposure.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_rbac_secret_expiration_set"
|
||||
"Text": "From Azure Portal: 1. Go to Key vaults. 2. For each Key vault, click on Secrets. 3. In the main pane, ensure that the status of the secret is Enabled. 4. For each enabled secret, ensure that an appropriate Expiration date is set. From Azure CLI: Update the Expiration date for the secret using the below command: az keyvault secret set-attributes --name <secretName> --vault-name <vaultName> --expires Y-m-d'T'H:M:S'Z' Note: To view the expiration date on all secrets in a Key Vault using Microsoft API, the List Key permission is required. To update the expiration date for the secrets: 1. Go to the Key vault, click on Access Control (IAM). 2. Click on Add role assignment and assign the role of Key Vault Secrets Officer to the appropriate user. From PowerShell: Set-AzKeyVaultSecretAttribute -VaultName <Vault Name> -Name <Secret Name> - Expires <DateTime>",
|
||||
"Url": "https://docs.microsoft.com/en-us/rest/api/keyvault/about-keys--secrets-and-certificates#key-vault-secrets"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Secrets cannot be used beyond their assigned expiry date respectively. Secrets need to be rotated periodically wherever they are used."
|
||||
|
||||
+12
-18
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "keyvault_recoverable",
|
||||
"CheckTitle": "Key Vault has soft delete and purge protection enabled",
|
||||
"CheckTitle": "Ensure the Key Vault is Recoverable",
|
||||
"CheckType": [],
|
||||
"ServiceName": "keyvault",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.keyvault/vaults",
|
||||
"ResourceType": "KeyVault",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "**Azure Key Vault** recoverability requires both `enable_soft_delete` and `enable_purge_protection`. With these enabled, vault objects remain recoverable after deletion and cannot be permanently purged during the retention period.",
|
||||
"Risk": "Absent these protections, deleted vaults or objects can be permanently removed, cutting access to keys, secrets, and certificates. This can render data unreadable, break app authentication, and halt signing/verification, degrading **availability** and **integrity**. Malicious insiders can purge to block recovery.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/key-vault/general/key-vault-recovery?tabs=azure-cli",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/KeyVault/enable-key-vault-recoverability.html#"
|
||||
],
|
||||
"Description": "The Key Vault contains object keys, secrets, and certificates. Accidental unavailability of a Key Vault can cause immediate data loss or loss of security functions (authentication, validation, verification, non-repudiation, etc.) supported by the Key Vault objects. It is recommended the Key Vault be made recoverable by enabling the 'Do Not Purge' and 'Soft Delete' functions. This is in order to prevent loss of encrypted data, including storage accounts, SQL databases, and/or dependent services provided by Key Vault objects (Keys, Secrets, Certificates) etc. This may happen in the case of accidental deletion by a user or from disruptive activity by a malicious user. WARNING: A current limitation of the soft-delete feature across all Azure services is role assignments disappearing when Key Vault is deleted. All role assignments will need to be recreated after recovery.",
|
||||
"Risk": "There could be scenarios where users accidentally run delete/purge commands on Key Vault or an attacker/malicious user deliberately does so in order to cause disruption. Deleting or purging a Key Vault leads to immediate data loss, as keys encrypting data and secrets/certificates allowing access/services will become non-accessible. There are 2 Key Vault properties that play a role in permanent unavailability of a Key Vault: 1. enableSoftDelete: Setting this parameter to 'true' for a Key Vault ensures that even if Key Vault is deleted, Key Vault itself or its objects remain recoverable for the next 90 days. Key Vault/objects can either be recovered or purged (permanent deletion) during those 90 days. If no action is taken, key vault and its objects will subsequently be purged. 2. enablePurgeProtection: enableSoftDelete only ensures that Key Vault is not deleted permanently and will be recoverable for 90 days from date of deletion. However, there are scenarios in which the Key Vault and/or its objects are accidentally purged and hence will not be recoverable. Setting enablePurgeProtection to 'true' ensures that the Key Vault and its objects cannot be purged. Enabling both the parameters on Key Vaults ensures that Key Vaults and their objects cannot be deleted/purged permanently.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/key-vault/key-vault-soft-delete-cli",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az keyvault update -g <resourceGroupName> -n <keyVaultName> --enable-soft-delete true --enable-purge-protection true",
|
||||
"NativeIaC": "```bicep\n// Enable soft delete and purge protection on an existing/new Key Vault\nresource kv 'Microsoft.KeyVault/vaults@2023-07-01' = {\n name: '<example_resource_name>'\n location: '<location>'\n properties: {\n tenantId: '<tenant_id>'\n sku: { name: 'standard' }\n enableSoftDelete: true // Critical: ensures soft delete is enabled\n enablePurgeProtection: true // Critical: prevents permanent purge during retention\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Key vaults and open <keyVaultName>\n2. Select Properties > Recovery\n3. Turn on Soft delete and Purge protection\n4. Click Save",
|
||||
"Terraform": "```hcl\nresource \"azurerm_key_vault\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<location>\"\n resource_group_name = \"<resource_group_name>\"\n tenant_id = \"<tenant_id>\"\n sku_name = \"standard\"\n\n soft_delete_enabled = true # Critical: enables soft delete\n purge_protection_enabled = true # Critical: enables purge protection\n}\n```"
|
||||
"CLI": "az resource update --id /subscriptions/xxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/resourceGroups/<resourceGroupName>/providers/Microsoft.KeyVault/vaults/<keyVaultName> --set properties.enablePurgeProtection=trueproperties.enableSoftDelete=true",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/KeyVault/enable-key-vault-recoverability.html#",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-the-key-vault-is-recoverable#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable both `enable_soft_delete` and `enable_purge_protection` on all vaults. Enforce with policy, restrict purge/recover to **least privilege** and apply **separation of duties**. Keep backups and test recovery. Monitor delete/purge with alerts. *Adjust retention to business needs* to strengthen defense in depth.",
|
||||
"Url": "https://hub.prowler.com/check/keyvault_recoverable"
|
||||
"Text": "To enable 'Do Not Purge' and 'Soft Delete' for a Key Vault: From Azure Portal 1. Go to Key Vaults 2. For each Key Vault 3. Click Properties 4. Ensure the status of soft-delete reads Soft delete has been enabled on this key vault. 5. At the bottom of the page, click 'Enable Purge Protection' Note, once enabled you cannot disable it. From Azure CLI az resource update --id /subscriptions/xxxxxx-xxxx-xxxx-xxxx- xxxxxxxxxxxx/resourceGroups/<resourceGroupName>/providers/Microsoft.KeyVault /vaults/<keyVaultName> --set properties.enablePurgeProtection=true properties.enableSoftDelete=true From PowerShell Update-AzKeyVault -VaultName <vaultName -ResourceGroupName <resourceGroupName -EnablePurgeProtection",
|
||||
"Url": "https://blogs.technet.microsoft.com/kv/2017/05/10/azure-key-vault-recovery-options/"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Once purge-protection and soft-delete are enabled for a Key Vault, the action is irreversible."
|
||||
|
||||
+12
-19
@@ -1,37 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_backup_enabled",
|
||||
"CheckTitle": "Virtual Machine is protected by Azure Backup",
|
||||
"CheckTitle": "Ensure Backups are enabled for Azure Virtual Machines",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure VMs** are evaluated for protection by **Azure Backup** by confirming they exist as VM backup items in a **Recovery Services vault**.\n\nVMs absent from any vault item indicate no configured backup coverage.",
|
||||
"Risk": "Unprotected VMs jeopardize **availability** and **integrity**. Deletion, corruption, or ransomware can wipe data, and without recovery points recovery is slow or impossible, causing extended outages, missed `RPO/RTO`, and cascading impact on dependent services.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/backup/backup-azure-arm-vms-prepare",
|
||||
"https://learn.microsoft.com/en-us/azure/backup/quick-backup-vm-portal",
|
||||
"https://learn.microsoft.com/en-us/azure/backup/backup-overview"
|
||||
],
|
||||
"Description": "Ensure that Microsoft Azure Backup service is in use for your Azure virtual machines (VMs) to protect against accidental deletion or corruption.",
|
||||
"Risk": "Without Azure Backup enabled, VMs are at risk of data loss due to accidental deletion, corruption, or other failures, and recovery options are limited.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/backup/backup-overview",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az backup protection enable-for-vm --resource-group <vault-resource-group> --vault-name <vault-name> --vm <vm-name> --vm-resource-group <vm-resource-group> --policy-name DefaultPolicy",
|
||||
"NativeIaC": "```bicep\n// Enable Azure Backup protection for an existing VM in an existing Recovery Services vault\nparam vmId string // e.g., /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Compute/virtualMachines/<vm>\nparam vaultName string\nparam vaultRg string\nparam policyId string // e.g., /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.RecoveryServices/vaults/<vault>/backupPolicies/<policy>\n\nresource vault 'Microsoft.RecoveryServices/vaults@2023-04-01' existing = {\n name: vaultName\n scope: resourceGroup(vaultRg)\n}\n\nvar vmRg = split(split(vmId, '/resourceGroups/')[1], '/')[0]\nvar vmName = split(vmId, '/virtualMachines/')[1]\n\nresource protect 'Microsoft.RecoveryServices/vaults/backupFabrics/protectionContainers/protectedItems@2023-02-01' = {\n // critical: this resource creates the protected item, enabling backup for the VM\n name: '${vault.name}/Azure/protectionContainers/iaasvmcontainer;iaasvmcontainerv2;${vmRg};${vmName}/protectedItems/VM;iaasvmcontainerv2;${vmRg};${vmName}'\n properties: {\n protectedItemType: 'Microsoft.Compute/virtualMachines' // critical: VM backup item type\n sourceResourceId: vmId // critical: target VM to protect\n policyId: policyId // critical: associates the VM with a backup policy\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Virtual machines > <VM> > Backup\n2. Select a Recovery Services vault in the same region (or create one if prompted)\n3. Choose the DefaultPolicy (or an existing VM backup policy)\n4. Click Enable backup",
|
||||
"Terraform": "```hcl\n# Protect an existing VM with Azure Backup\nresource \"azurerm_backup_protected_vm\" \"<example_resource_name>\" {\n resource_group_name = \"<example_resource_name>\" # vault's resource group\n recovery_vault_name = \"<example_resource_name>\"\n source_vm_id = \"<example_resource_id>\" # critical: VM to protect\n backup_policy_id = \"<example_resource_id>\" # critical: policy that enables backup\n}\n```"
|
||||
"CLI": "az backup protection enable-for-vm --resource-group <resource-group> --vm <vm-name> --vault-name <vault-name> --policy-name DefaultPolicy",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://learn.microsoft.com/en-us/azure/backup/quick-backup-vm-portal",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Protect all VMs with **Azure Backup** in a **Recovery Services vault** under a standardized policy. Align schedules and retention to `RPO/RTO`, use `GRS`/`ZRS` and `immutable` vault features, enforce least privilege on backup operations, automate enrollment at provisioning, and regularly test restores.",
|
||||
"Url": "https://hub.prowler.com/check/vm_backup_enabled"
|
||||
"Text": "Enable Azure Backup for each VM by associating it with a Recovery Services vault and a backup policy.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/backup/quick-backup-vm-portal"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+12
-16
@@ -1,31 +1,27 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_desired_sku_size",
|
||||
"CheckTitle": "Virtual Machine uses an organization-approved SKU size",
|
||||
"CheckTitle": "Ensure that your virtual machine instances are using SKU sizes that are approved by your organization",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure virtual machines** are compared against an organization **allowlist of VM size SKUs** defined in `desired_vm_sku_sizes`.\n\nInstances using sizes outside this list are flagged as non-standard.",
|
||||
"Risk": "Unrestricted VM sizes enable over-provisioned or exotic SKUs, leading to:\n- Sudden cost spikes and quota exhaustion (availability)\n- Use of hardware lacking required security features (confidentiality/integrity)\n- Abuse by compromised accounts for cryptomining at scale",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/resize-vm",
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/overview"
|
||||
],
|
||||
"Description": "Ensure that your virtual machine instances are using SKU sizes that are approved by your organization. This check requires configuration of the desired VM SKU sizes in the Prowler configuration file.",
|
||||
"Risk": "Setting limits for the SKU size(s) of the virtual machine instances provisioned in your Microsoft Azure account can help you to manage better your cloud compute power, address internal compliance requirements and prevent unexpected charges on your Azure monthly bill. Without proper SKU size controls, organizations may face cost overruns and compliance violations.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/overview",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az vm resize --resource-group <RESOURCE_GROUP> --name <VM_NAME> --size <desired-sku-1>",
|
||||
"NativeIaC": "```bicep\n// Resize VM to an approved SKU\nresource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = {\n name: '<example_resource_name>'\n location: '<example_location>'\n properties: {\n hardwareProfile: {\n vmSize: '<desired-sku-1>' // CRITICAL: sets VM to an approved size so the check passes\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Virtual machines and select the VM\n2. Under Availability + scale, select Size\n3. Choose an approved size (e.g., <desired-sku-1>) and click Resize\n4. If the size isn't listed, Stop (deallocate) the VM, then retry Resize",
|
||||
"Terraform": "```hcl\n# Enforce allowed VM SKUs via Azure Policy\nresource \"azurerm_policy_assignment\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n scope = \"/subscriptions/<example_resource_id>\"\n policy_definition_id = \"/providers/Microsoft.Authorization/policyDefinitions/<example_resource_id>\"\n\n parameters = jsonencode({\n listOfAllowedSKUs = { value = [\"<desired-sku-1>\", \"<desired-sku-2>\"] } # CRITICAL: only these SKUs are allowed\n })\n}\n```"
|
||||
"CLI": "az policy assignment create --display-name 'Allowed VM SKU Sizes' --policy cccc23c7-8427-4f53-ad12-b6a63eb452b3 -p '{\"listOfAllowedSKUs\": {\"value\": [\"<desired-sku-1>\", \"<desired-sku-2>\"]}}' --scope /subscriptions/<subscription-id>",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Adopt a **least privilege, policy-enforced allowlist** of VM size SKUs per workload tier and region.\n- Deny creation of non-approved sizes across scopes\n- Require exception reviews with time-bound overrides\n- Reassess the list regularly for cost, performance, and compliance\n- Monitor provisioning and cost anomalies for drift",
|
||||
"Url": "https://hub.prowler.com/check/vm_desired_sku_size"
|
||||
"Text": "1. Define and document your organization's approved VM SKU sizes based on workload requirements, cost constraints, and compliance needs. 2. Implement Azure Policy to enforce VM size restrictions across your subscriptions. 3. Use the 'Allowed virtual machine size SKUs' built-in policy to restrict VM creation to approved sizes. 4. Regularly review and update your approved SKU list based on changing business requirements and cost optimization goals. 5. Monitor VM usage and costs to ensure compliance with your SKU size policies.",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/virtual-machines/sizes/resize-vm"
|
||||
}
|
||||
},
|
||||
"Categories": [],
|
||||
|
||||
+13
-21
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_ensure_attached_disks_encrypted_with_cmk",
|
||||
"CheckTitle": "Virtual Machine OS or data disk is encrypted with a customer-managed key (CMK)",
|
||||
"CheckTitle": "Ensure that 'OS and Data' disks are encrypted with Customer Managed Key (CMK)",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/disks",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Attached Azure managed disks** (OS and data) are assessed to confirm encryption uses **customer-managed keys** (`CMK`) via disk encryption sets rather than platform-managed keys. Scope includes disks currently attached to VMs, evaluating their encryption type to verify CMK is applied.",
|
||||
"Risk": "Without **CMK**, you lose control over key lifecycle and access. This weakens confidentiality and compliance: you can't enforce independent rotation, promptly revoke keys to crypto-lock stolen copies/snapshots, or separate duties. Misuse or compromise may keep data readable beyond your trust boundary.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption-overview",
|
||||
"https://support.icompaas.com/support/solutions/articles/62000229895-ensure-that-os-and-data-disks-are-encrypted-with-customer-managed-key-cmk-",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/sse-boot-disk-cmk.html#",
|
||||
"https://learn.microsoft.com/en-us/azure/security/fundamentals/data-encryption-best-practices#protect-data-at-rest"
|
||||
],
|
||||
"Description": "Ensure that OS disks (boot volumes) and data disks (non-boot volumes) are encrypted with CMK (Customer Managed Keys). Customer Managed keys can be either ADE or Server Side Encryption (SSE).",
|
||||
"Risk": "Encrypting the IaaS VM's OS disk (boot volume) and Data disks (non-boot volume) ensures that the entire content is fully unrecoverable without a key, thus protecting the volume from unwanted reads. PMK (Platform Managed Keys) are enabled by default in Azure-managed disks and allow encryption at rest. CMK is recommended because it gives the customer the option to control which specific keys are used for the encryption and decryption of the disk. The customer can then change keys and increase security by disabling them instead of relying on the PMK key that remains unchanging. There is also the option to increase security further by using automatically rotating keys so that access to disk is ensured to be limited. Organizations should evaluate what their security requirements are, however, for the data stored on the disk. For high-risk data using CMK is a must, as it provides extra steps of security. If the data is low risk, PMK is enabled by default and provides sufficient data security.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption-overview",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az disk update -g <RESOURCE_GROUP> -n <DISK_NAME> --encryption-type EncryptionAtRestWithCustomerKey --disk-encryption-set <DES_ID>",
|
||||
"NativeIaC": "```bicep\n// Encrypt a managed disk with a customer-managed key via Disk Encryption Set (DES)\nresource disk 'Microsoft.Compute/disks@2023-10-02' = {\n name: '<example_resource_name>'\n location: '<LOCATION>'\n sku: {\n name: 'Standard_LRS'\n }\n properties: {\n creationData: {\n createOption: 'Empty'\n }\n diskSizeGB: 32\n\n // CRITICAL: Use CMK by attaching a Disk Encryption Set (DES)\n // This switches encryption from platform-managed to customer-managed\n encryption: {\n type: 'EncryptionAtRestWithCustomerKey' // critical: CMK\n diskEncryptionSetId: '<example_resource_id>' // critical: DES resource ID\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, open the target VM and click Stop to deallocate it.\n2. For each data disk: Go to VM > Disks > select the data disk > Detach > Save.\n3. In the portal search box, open Disks, select the detached disk > Encryption.\n4. Set Encryption type to Customer-managed key (Disk encryption set), select your Disk Encryption Set, then Save.\n5. Reattach the disk: VM > Disks > Add data disk (select the same disk) > Save.\n6. For OS disk, use Swap OS disk: create a new managed disk from a snapshot/image with Encryption type = Customer-managed key (select the same DES), then VM > Disks > Swap OS disk and choose that disk.\n7. Start the VM.",
|
||||
"Terraform": "```hcl\n# Encrypt a managed disk with a customer-managed key via Disk Encryption Set (DES)\nresource \"azurerm_managed_disk\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<LOCATION>\"\n resource_group_name = \"<example_resource_name>\"\n storage_account_type = \"Standard_LRS\"\n create_option = \"Empty\"\n disk_size_gb = 32\n\n # CRITICAL: Attach DES to enable SSE with CMK and pass the check\n disk_encryption_set_id = \"<example_resource_id>\" # critical: DES ID\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/sse-boot-disk-cmk.html#",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/bc_azr_general_1#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Use **CMK** with disk encryption sets for all attached OS and data disks.\n- Enforce **least privilege** on key usage and scope\n- Enable periodic key rotation and auditing\n- Store keys in HSM-backed vaults; separate key and data admins\n- Combine with **encryption at host** to cover temp disks and caches",
|
||||
"Url": "https://hub.prowler.com/check/vm_ensure_attached_disks_encrypted_with_cmk"
|
||||
"Text": "Note: Disks must be detached from VMs to have encryption changed. 1. Go to Virtual machines 2. For each virtual machine, go to Settings 3. Click on Disks 4. Click the ellipsis (...), then click Detach to detach the disk from the VM 5. Now search for Disks and locate the unattached disk 6. Click the disk then select Encryption 7. Change your encryption type, then select your encryption set 8. Click Save 9. Go back to the VM and re-attach the disk",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/security/fundamentals/data-encryption-best-practices#protect-data-at-rest"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Using CMK/BYOK will entail additional management of keys."
|
||||
|
||||
+13
-21
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_ensure_unattached_disks_encrypted_with_cmk",
|
||||
"CheckTitle": "Unattached disk is encrypted with a customer-managed key (CMK)",
|
||||
"CheckTitle": "Ensure that 'Unattached disks' are encrypted with 'Customer Managed Key' (CMK)",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/disks",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "Unattached **Azure managed disks** use **Customer-Managed Keys** (`CMK`) for server-side encryption rather than platform-managed keys. Only disks not currently attached to a VM are in scope.",
|
||||
"Risk": "Without **CMK**, you lack independent key control on unattached disks. A compromised admin could attach a disk to read or alter data before you can revoke access. Missing customer-controlled rotation and revocation weakens **confidentiality** and **integrity**, and can hinder data-sovereignty compliance.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/sse-unattached-disk-cmk.html#",
|
||||
"https://learn.microsoft.com/en-us/azure/security/fundamentals/data-encryption-best-practices#protect-data-at-rest",
|
||||
"https://learn.microsoft.com/en-us/rest/api/compute/disks/delete?view=rest-compute-2023-10-02&tabs=HTTP",
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/disk-encryption-overview"
|
||||
],
|
||||
"Description": "Ensure that unattached disks in a subscription are encrypted with a Customer Managed Key (CMK).",
|
||||
"Risk": "Managed disks are encrypted by default with Platform-managed keys. Using Customer-managed keys may provide an additional level of security or meet an organization's regulatory requirements. Encrypting managed disks ensures that its entire content is fully unrecoverable without a key and thus protects the volume from unwarranted reads. Even if the disk is not attached to any of the VMs, there is always a risk where a compromised user account with administrative access to VM service can mount/attach these data disks, which may lead to sensitive information disclosure and tampering.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security/fundamentals/azure-disk-encryption-vms-vmss",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az disk update -g <RESOURCE_GROUP> -n <DISK_NAME> --encryption-type EncryptionAtRestWithCustomerKey --disk-encryption-set <DES_ID>",
|
||||
"NativeIaC": "```bicep\n// Update an existing managed disk to use a customer-managed key via Disk Encryption Set\nresource example_disk 'Microsoft.Compute/disks@2023-08-01' = {\n name: '<example_resource_name>'\n location: '<location>'\n properties: {\n encryption: {\n type: 'EncryptionAtRestWithCustomerKey' // CRITICAL: switch to CMK-based encryption\n diskEncryptionSetId: '<example_resource_id>' // CRITICAL: DES resource ID that holds the CMK\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure portal, go to Disks and open the unattached disk\n2. Select Encryption\n3. Set Encryption type to Customer-managed key\n4. Select the Disk encryption set to use\n5. Click Save",
|
||||
"Terraform": "```hcl\n# Associate an unattached managed disk with a Disk Encryption Set (CMK)\nresource \"azurerm_managed_disk\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<location>\"\n resource_group_name = \"<example_resource_name>\"\n create_option = \"Empty\"\n disk_size_gb = 1\n\n disk_encryption_set_id = \"<example_resource_id>\" # CRITICAL: enables CMK by linking the Disk Encryption Set\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/sse-unattached-disk-cmk.html#",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Encrypt unattached disks with **CMK** backed by a hardened key service. Enforce **least privilege** for disk attachment and key usage, enable key rotation and auditing, and restrict access to keys. Apply lifecycle governance-*remove stale disks*-to reduce exposure and support **defense in depth**.",
|
||||
"Url": "https://hub.prowler.com/check/vm_ensure_unattached_disks_encrypted_with_cmk"
|
||||
"Text": "If data stored in the disk is no longer useful, refer to Azure documentation to delete unattached data disks at: https://learn.microsoft.com/en-us/rest/api/compute/disks/delete?view=rest-compute-2023-10-02&tabs=HTTP",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/security/fundamentals/data-encryption-best-practices#protect-data-at-rest"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"encryption"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "You must have your key vault set up to utilize this. Encryption is available only on Standard tier VMs. This might cost you more. Utilizing and maintaining Customer-managed keys will require additional work to create, protect, and rotate keys."
|
||||
|
||||
+15
-21
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_ensure_using_approved_images",
|
||||
"CheckTitle": "Virtual Machine uses an approved custom machine image",
|
||||
"CheckTitle": "Ensure that Azure VMs are using an approved machine image.",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"SubServiceName": "image",
|
||||
"ResourceIdTemplate": "/subscriptions/<subscription-id>/resourceGroups/<resource-group-name>/providers/Microsoft.Compute/images/<virtual-machine-image-id>",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Microsoft.Compute/images",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure VMs** are evaluated for use of an **approved custom image** by inspecting the VM image reference. The expected format is a subscription-scoped ID like `/subscriptions/.../providers/Microsoft.Compute/images/<image>`, not marketplace, gallery, or community sources.",
|
||||
"Risk": "Using **unapproved images** undermines **integrity** and **confidentiality** by introducing unknown packages, misconfigurations, or malware. Attackers can implant backdoors, weaken hardening, and bypass baselines, enabling data exfiltration and lateral movement, and harming **availability** with unpatched software.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/approved-machine-image.html",
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/windows/create-vm-generalized-managed"
|
||||
],
|
||||
"Description": "Ensure that all your Azure virtual machine instances are launched from approved machine images only.",
|
||||
"Risk": "An approved machine image is a custom virtual machine (VM) image that contains a pre-configured OS and a well-defined stack of server software approved by Azure, fully configured to run your application. Using approved (golden) machine images to launch new VM instances within your Azure cloud environment brings major benefits such as fast and stable application deployment and scaling, secure application stack upgrades, and versioning.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machines/windows/create-vm-generalized-managed",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az vm create --resource-group <RESOURCE_GROUP> --name <VM_NAME> --image /subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Compute/images/<IMAGE_NAME> --admin-username azureuser --generate-ssh-keys",
|
||||
"NativeIaC": "```bicep\n// Create a VM using an approved custom managed image\nparam location string = resourceGroup().location\nparam nicId string\nparam adminUsername string\nparam adminPassword string\n\nresource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = {\n name: '<example_resource_name>'\n location: location\n properties: {\n hardwareProfile: { vmSize: 'Standard_DS1_v2' }\n storageProfile: {\n imageReference: {\n id: '/subscriptions/<subscription_id>/resourceGroups/<example_resource_id>/providers/Microsoft.Compute/images/<example_resource_name>' // CRITICAL: use managed image ID to pass check\n }\n osDisk: { createOption: 'FromImage' }\n }\n osProfile: {\n computerName: '<example_resource_name>'\n adminUsername: adminUsername\n adminPassword: adminPassword\n }\n networkProfile: {\n networkInterfaces: [{ id: nicId }]\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Virtual machines > Create > Azure virtual machine\n2. Under Image, click See all images, then select the My Images tab\n3. Choose the approved managed image (type: Microsoft.Compute/images)\n4. Complete required basics and create the VM\n5. If replacing a non-compliant VM, migrate workload and delete the old VM",
|
||||
"Terraform": "```hcl\n# VM created from an approved custom managed image\nresource \"azurerm_windows_virtual_machine\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_id>\"\n location = \"<example_resource_location>\"\n size = \"Standard_DS1_v2\"\n admin_username = \"<example_resource_name>\"\n admin_password = \"<example_resource_id>\"\n network_interface_ids = [\"<example_resource_id>\"]\n\n source_image_id = \"/subscriptions/<subscription_id>/resourceGroups/<example_resource_id>/providers/Microsoft.Compute/images/<example_resource_name>\" # CRITICAL: managed image ID ensures PASS\n\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/approved-machine-image.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Standardize on **golden images** maintained in an Azure Compute Gallery or managed images.\n- Harden and patch each release; scan for vulnerabilities\n- Restrict who can create/publish images (**least privilege**)\n- Enforce deployments only from approved images via policy\n- Version, sign, and retire images regularly",
|
||||
"Url": "https://hub.prowler.com/check/vm_ensure_using_approved_images"
|
||||
"Text": "Re-create the required VM instances using the approved (golden) machine image.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/virtual-machines/windows/create-vm-generalized-managed"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"software-supply-chain"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "This check only validates if the VM was launched from a custom image. It does not validate the image content or security baseline."
|
||||
|
||||
+13
-21
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_ensure_using_managed_disks",
|
||||
"CheckTitle": "Virtual Machine uses managed disks for OS and data disks",
|
||||
"CheckTitle": "Ensure Virtual Machines are utilizing Managed Disks",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure virtual machines** use **managed disks** for the OS disk and every data disk, rather than page blob VHDs in storage accounts.\n\nThe evaluation confirms that all attached VM disks are managed.",
|
||||
"Risk": "Using **unmanaged disks** ties VM data to storage account keys and SAS, increasing exposure of disk contents if those secrets leak (**confidentiality/integrity**). Limited platform resiliency and quotas increase **availability** risk. With Azure retiring unmanaged disks on `2026-03-31`, such VMs may be stopped and unable to start.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/unmanaged-disks-deprecation",
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/windows/convert-unmanaged-to-managed-disks",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/managed-disks-in-use.html",
|
||||
"https://learn.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-4-enable-data-at-rest-encryption-by-default"
|
||||
],
|
||||
"Description": "Migrate blob-based VHDs to Managed Disks on Virtual Machines to exploit the default features of this configuration. The features include: 1. Default Disk Encryption 2. Resilience, as Microsoft will managed the disk storage and move around if underlying hardware goes faulty 3. Reduction of costs over storage accounts",
|
||||
"Risk": "Managed disks are by default encrypted on the underlying hardware, so no additional encryption is required for basic protection. It is available if additional encryption is required. Managed disks are by design more resilient that storage accounts. For ARM-deployed Virtual Machines, Azure Adviser will at some point recommend moving VHDs to managed disks both from a security and cost management perspective.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machines/unmanaged-disks-deprecation",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az vm convert --resource-group <RESOURCE_GROUP> --name <VM_NAME>",
|
||||
"NativeIaC": "```bicep\n// VM configured to use a managed OS disk\nresource vm 'Microsoft.Compute/virtualMachines@2023-09-01' = {\n name: '<example_resource_name>'\n location: resourceGroup().location\n properties: {\n hardwareProfile: { vmSize: 'Standard_DS1_v2' }\n storageProfile: {\n osDisk: {\n name: 'osdisk'\n osType: 'Linux'\n createOption: 'Attach'\n managedDisk: {\n id: '<example_resource_id>' // CRITICAL: attaching a managed disk makes the VM use managed disks\n }\n }\n }\n networkProfile: { networkInterfaces: [ { id: '<example_resource_id>' } ] }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Virtual machines > select your VM\n2. Click Stop to deallocate the VM\n3. In the VM menu, select Disks\n4. Click Migrate to managed disks, then click Migrate to start\n5. After migration completes, click Start to power on the VM\n6. If the VM is in an availability set: first go to Availability sets > select the set > Convert to managed (SKU: Aligned), then repeat steps 1-5",
|
||||
"Terraform": "```hcl\n# VM attached to a managed OS disk\nresource \"azurerm_virtual_machine\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n location = \"<LOCATION>\"\n resource_group_name = \"<RESOURCE_GROUP>\"\n network_interface_ids = [\"<example_resource_id>\"]\n vm_size = \"Standard_DS1_v2\"\n\n storage_os_disk {\n name = \"osdisk\"\n create_option = \"Attach\"\n managed_disk_id = \"<example_resource_id>\" # CRITICAL: use a managed disk for the OS disk\n }\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/managed-disks-in-use.html",
|
||||
"Terraform": "https://docs.prowler.com/checks/azure/azure-general-policies/ensure-virtual-machines-are-utilizing-managed-disks#terraform"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Adopt **managed disks** for all VM OS and data volumes.\n- Enforce **least privilege** via RBAC at disk scope\n- Use encryption at rest; prefer `CMEK` when mandated\n- Apply backups/snapshots and zone-aware designs for **defense in depth**\n- After migration, delete orphaned VHD blobs to avoid data exposure and cost.",
|
||||
"Url": "https://hub.prowler.com/check/vm_ensure_using_managed_disks"
|
||||
"Text": "1. Using the search feature, go to Virtual Machines 2. Select the virtual machine you would like to convert 3. Select Disks in the menu for the VM 4. At the top select Migrate to managed disks 5. You may follow the prompts to convert the disk and finish by selecting Migrate to start the process",
|
||||
"Url": "https://docs.microsoft.com/en-us/security/benchmark/azure/security-controls-v3-data-protection#dp-4-enable-data-at-rest-encryption-by-default"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "There are additional costs for managed disks based off of disk space allocated. When converting to managed disks, VMs will be powered off and back on."
|
||||
|
||||
+14
-19
@@ -1,35 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_jit_access_enabled",
|
||||
"CheckTitle": "Virtual Machine has Just-in-Time (JIT) access enabled",
|
||||
"CheckTitle": "Enable Just-In-Time Access for Virtual Machines",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure virtual machines** are associated with a **Just-in-Time (JIT) network access policy** that opens management ports only for approved, time-bound requests from specified source IPs.",
|
||||
"Risk": "Without **JIT**, management ports like `22`/`3389` may stay reachable, enabling:\n- brute-force and password-spray attempts\n- exploitation of remote access flaws or stolen keys\nThis threatens **confidentiality** (data exfiltration), **integrity** (unauthorized changes), and **availability** (service disruption).",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/defender-for-cloud/enable-just-in-time-access?tabs=jit-config-asc%2Cjit-request-asc"
|
||||
],
|
||||
"Description": "Ensure that Microsoft Azure virtual machines are configured to use Just-in-Time (JIT) access.",
|
||||
"Risk": "Without JIT access, management ports such as 22 (SSH) and 3389 (RDP) may be exposed, increasing the risk of brute-force and DDoS attacks.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/security-center/security-center-just-in-time?tabs=jit-config-asc%2Cjit-request-asc",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az rest --method PUT --url \"https://management.azure.com/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Security/locations/<LOCATION>/jitNetworkAccessPolicies/default?api-version=2020-01-01\" --body '{\"kind\":\"Basic\",\"properties\":{\"virtualMachines\":[{\"id\":\"/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Compute/virtualMachines/<VM_NAME>\",\"ports\":[{\"number\":22,\"protocol\":\"*\",\"allowedSourceAddressPrefix\":[\"*\"],\"maxRequestAccessDuration\":\"PT3H\"}]}]}}'",
|
||||
"NativeIaC": "```bicep\n// Bicep: Enable JIT for a VM by creating a JIT policy that references the VM\nparam location string = \"<LOCATION>\"\nparam vmId string = \"/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<example_resource_name>/providers/Microsoft.Compute/virtualMachines/<example_resource_name>\"\n\nresource jit 'Microsoft.Security/locations/jitNetworkAccessPolicies@2020-01-01' = {\n name: '${location}/default'\n properties: {\n virtualMachines: [\n {\n id: vmId // Critical: Adding the VM ID enables JIT for this VM\n ports: [\n {\n number: 22\n protocol: '*'\n allowedSourceAddressPrefix: ['*']\n maxRequestAccessDuration: 'PT3H' // Critical: Minimal required port configuration for JIT\n }\n ]\n }\n ]\n }\n}\n```",
|
||||
"Other": "1. In the Azure portal, go to Microsoft Defender for Cloud\n2. Select Workload protections > Just-in-time VM access\n3. Open the Not configured tab, select the VM, and click Enable JIT on VMs\n4. Keep the default port (22 for Linux or 3389 for Windows) and click Save",
|
||||
"Terraform": "```hcl\n# Enable JIT by creating a Security Center JIT policy for the VM\nresource \"azurerm_security_center_jit_network_access_policy\" \"<example_resource_name>\" {\n name = \"default\"\n location = \"<LOCATION>\"\n resource_group_name = \"<example_resource_name>\"\n\n virtual_machines {\n id = \"<example_resource_id>\" # Critical: VM ID included in the JIT policy enables JIT for this VM\n\n ports {\n number = 22\n protocol = \"*\"\n allowed_source_address_prefixes = [\"*\"]\n max_request_access_duration = \"PT3H\" # Critical: Minimal port config required by JIT\n }\n }\n}\n```"
|
||||
"CLI": "az security jit-policy list --query '[*].virtualMachines[*].id | []'",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enable **JIT network access** and apply **least privilege** and **zero trust**:\n- keep admin ports closed by default\n- approve only specific IPs, minimal ports (e.g., `22`, `3389`), and short windows\n- favor **private access** (VPN, Bastion)\n- layer controls (**defense in depth**) and audit access requests",
|
||||
"Url": "https://hub.prowler.com/check/vm_jit_access_enabled"
|
||||
"Text": "Enable Just-in-Time (JIT) network access for your Microsoft Azure virtual machines using the Azure Portal under Security Center > Just-in-time VM access.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/security-center/security-center-just-in-time?tabs=jit-config-asc%2Cjit-request-asc"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "JIT access can only be enabled via the Azure Portal. Ensure Security Center standard pricing tier for servers is enabled."
|
||||
|
||||
+12
-18
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_linux_enforce_ssh_authentication",
|
||||
"CheckTitle": "Linux Virtual Machine has password authentication disabled (SSH key authentication enforced)",
|
||||
"CheckTitle": "Ensure SSH key authentication is enforced on Linux-based Virtual Machines",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachines/{vmName}",
|
||||
"Severity": "high",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure Linux virtual machines** are assessed for SSH configuration where `disablePasswordAuthentication` is set to `true`, allowing only **public key authentication** and disallowing interactive passwords.",
|
||||
"Risk": "With **password-based SSH**, attackers can run brute-force or password-spray campaigns. A successful login grants remote shell, enabling command execution, data exfiltration, and lateral movement, degrading **confidentiality** and **integrity**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/ssh-authentication-type.html",
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/linux/create-ssh-keys-detailed"
|
||||
],
|
||||
"Description": "Ensure that Azure Linux-based virtual machines are configured to use SSH keys by disabling password authentication.",
|
||||
"Risk": "Allowing password-based SSH authentication increases the risk of brute-force attacks and unauthorized access. Enforcing SSH key authentication ensures only users with the private key can access the VM.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/virtual-machines/linux/create-ssh-keys-detailed",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "```bicep\n// Bicep snippet to enforce SSH key authentication on a Linux VM\nresource linuxVm 'Microsoft.Compute/virtualMachines@2024-03-01' = {\n name: '<example_resource_name>'\n location: '<example_location>'\n properties: {\n osProfile: {\n linuxConfiguration: {\n disablePasswordAuthentication: true // CRITICAL: Disables password-based SSH; enforces SSH key authentication\n }\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to your Linux VM > Settings > Export template\n2. Click Edit template\n3. Find properties.osProfile.linuxConfiguration and set disablePasswordAuthentication to true\n4. Ensure an SSH public key is provided (adminPassword must be absent)\n5. Click Save, then Review + create to deploy the update",
|
||||
"Terraform": "```hcl\n# Minimal Terraform to enforce SSH key authentication on a Linux VM\nresource \"azurerm_linux_virtual_machine\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<example_location>\"\n size = \"Standard_B1s\"\n network_interface_ids = [\"<example_resource_id>\"]\n admin_username = \"azureuser\"\n\n disable_password_authentication = true # CRITICAL: Disables password-based SSH; enforces SSH key authentication\n\n admin_ssh_key { # Required when password auth is disabled\n username = \"azureuser\"\n public_key = \"<example_public_key>\"\n }\n\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts\"\n version = \"latest\"\n }\n}\n```"
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/ssh-authentication-type.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce **key-only SSH** by setting `disablePasswordAuthentication` to `true` and disallowing OS password logins. Use strong, passphrase-protected keys with rotation; restrict SSH via NSGs and private access or **Azure Bastion**; enable JIT; and apply **least privilege**.",
|
||||
"Url": "https://hub.prowler.com/check/vm_linux_enforce_ssh_authentication"
|
||||
"Text": "Recreate Linux VMs with SSH key authentication enabled and password authentication disabled.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/virtual-machines/linux/create-ssh-keys-detailed"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+14
-22
@@ -1,38 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_scaleset_associated_with_load_balancer",
|
||||
"CheckTitle": "Virtual Machine Scale Set is associated with a load balancer backend pool",
|
||||
"CheckTitle": "VM Scale Set Is Associated With Load Balancer",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"SubServiceName": "scaleset",
|
||||
"ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachineScaleSets/{vmScaleSetName}",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/virtualmachinescalesets",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachineScaleSets",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure VM scale sets** are associated with at least one **load balancer backend pool**.\n\nThe evaluation looks for a backend pool link on each scale set's network configuration.",
|
||||
"Risk": "Without a load balancer, traffic targets instances directly, bypassing health-based distribution. This degrades **availability** (overloads, no failover) and **reliability** (dropped sessions, uneven scaling) and can amplify **DoS** impact by concentrating flows on fewer nodes, increasing outage risk.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-network/network-overview",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/associated-load-balancers.html",
|
||||
"https://learn.microsoft.com/ms-my/azure/load-balancer/load-balancer-multiple-virtual-machine-scale-set?tabs=azureportal",
|
||||
"https://learn.microsoft.com/en-us/azure/load-balancer/load-balancer-overview"
|
||||
],
|
||||
"Description": "Ensure that your Azure virtual machine scale sets are using load balancers for traffic distribution.",
|
||||
"Risk": "Without load balancer integration, Azure virtual machine scale sets may experience reduced availability and potential service disruptions during traffic spikes or instance failures, leading to degraded user experience and potential business impact.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-network/network-overview",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az vmss update --resource-group <RESOURCE_GROUP> --name <VMSS_NAME> --add virtualMachineProfile.networkProfile.networkInterfaceConfigurations[0].ipConfigurations[0].loadBalancerBackendAddressPools '{\"id\":\"/subscriptions/<SUBSCRIPTION_ID>/resourceGroups/<RESOURCE_GROUP>/providers/Microsoft.Network/loadBalancers/<LB_NAME>/backendAddressPools/<BACKEND_POOL_NAME>\"}'",
|
||||
"NativeIaC": "```bicep\n// Associate VMSS with a Load Balancer backend pool\nresource vmss 'Microsoft.Compute/virtualMachineScaleSets@2023-09-01' = {\n name: '<example_resource_name>'\n location: resourceGroup().location\n properties: {\n virtualMachineProfile: {\n networkProfile: {\n networkInterfaceConfigurations: [\n {\n name: 'nic'\n properties: {\n ipConfigurations: [\n {\n name: 'ipcfg'\n properties: {\n loadBalancerBackendAddressPools: [\n { id: '<example_resource_id>' } // CRITICAL: attach VMSS NIC IP config to LB backend pool\n ]\n }\n }\n ]\n }\n }\n ]\n }\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to Load balancers > select <LB_NAME>\n2. Under Settings, open Backend pools and select the target backend pool\n3. Click Add > IP configurations\n4. Choose Virtual machine scale set, select <VMSS_NAME> and its IP configuration\n5. Click Add, then Save to attach the scale set to the backend pool",
|
||||
"Terraform": "```hcl\n# Attach VMSS to an existing Load Balancer backend pool\nresource \"azurerm_linux_virtual_machine_scale_set\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<example_location>\"\n sku = \"Standard_DS1_v2\"\n instances = 1\n\n admin_username = \"azureuser\"\n disable_password_authentication = true\n admin_ssh_key {\n username = \"azureuser\"\n public_key = \"<SSH_PUBLIC_KEY>\"\n }\n\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-jammy\"\n sku = \"22_04-lts\"\n version = \"latest\"\n }\n\n network_interface {\n name = \"nic\"\n primary = true\n ip_configuration {\n name = \"ipcfg\"\n primary = true\n subnet_id = \"<example_resource_id>\"\n load_balancer_backend_address_pool_ids = [\"<example_resource_id>\"] # CRITICAL: associates VMSS with LB backend pool\n }\n }\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/associated-load-balancers.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Associate VM scale sets with a **Standard Load Balancer** backend pool to enforce health-probed, even distribution and seamless failover.\n\nDesign for **high availability**: run multiple instances across zones, enable autoscale, and apply **defense in depth** with limited exposure and NSG controls. *Prefer SKU `Standard` over legacy Basic.*",
|
||||
"Url": "https://hub.prowler.com/check/vm_scaleset_associated_with_load_balancer"
|
||||
"Text": "Attach a load balancer to your Azure virtual machine scale set to ensure high availability and optimal traffic distribution.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/load-balancer/load-balancer-overview"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+14
-20
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_scaleset_not_empty",
|
||||
"CheckTitle": "Virtual Machine Scale Set has at least one VM instance",
|
||||
"CheckTitle": "Check for Empty Virtual Machine Scale Sets",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/virtualmachinescalesets",
|
||||
"SubServiceName": "scaleset",
|
||||
"ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachineScaleSets/{vmScaleSetName}",
|
||||
"Severity": "low",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachineScaleSets",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure VM scale sets** with `0` VM instances are identified as **empty**.",
|
||||
"Risk": "Orphaned scale sets degrade governance and can hide **stale autoscale** or permissive identities. If reactivated, they may launch VMs from **outdated images**, exposing vulnerable services and enabling lateral movement, impacting **confidentiality** and **availability**. They also create inventory sprawl and unnecessary spend.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/overview",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/empty-vm-scale-sets.html"
|
||||
],
|
||||
"Description": "Identify and remove empty virtual machine scale sets from your Azure cloud account.",
|
||||
"Risk": "Empty virtual machine scale sets may incur unnecessary costs and complicate cloud resource management, impacting cost optimization and compliance.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/overview",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az vmss scale --resource-group <resource-group> --name <scale-set-name> --new-capacity 1",
|
||||
"CLI": "az vmss delete --name <scale-set-name> --resource-group <resource-group>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. In Azure Portal, go to Virtual machine scale sets and select <scale-set-name>\n2. Under Settings, click Scaling\n3. Set Instance count to 1\n4. Click Save",
|
||||
"Terraform": "```hcl\n# Patch the scale set to have at least one instance\nresource \"azapi_update_resource\" \"<example_resource_name>\" {\n type = \"Microsoft.Compute/virtualMachineScaleSets@2024-03-01\"\n resource_id = \"<example_resource_id>\"\n\n body = jsonencode({\n sku = {\n capacity = 1 # Critical: ensures the scale set has at least one VM instance\n }\n })\n}\n```"
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/empty-vm-scale-sets.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Decommission empty scale sets or consolidate capacity. Apply **resource lifecycle** and **governance policies** to prevent drift; require tagging and owners, review regularly. Manage capacity through **IaC**, enforce **least privilege** on scale set identities, and use change control to avoid unintended autoscale activations. *If retained temporarily*, document purpose and review date.",
|
||||
"Url": "https://hub.prowler.com/check/vm_scaleset_not_empty"
|
||||
"Text": "Remove empty Azure virtual machine scale sets to optimize costs and simplify management.",
|
||||
"Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/empty-vm-scale-sets.html"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+11
-17
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_sufficient_daily_backup_retention_period",
|
||||
"CheckTitle": "Virtual Machine has a backup policy with a daily retention period meeting the configured minimum",
|
||||
"CheckTitle": "Ensure there is a sufficient daily backup retention period configured for Azure virtual machines.",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure virtual machines** are evaluated for a backup policy in a Recovery Services vault with a **daily retention** period that meets the configured minimum. VMs lacking protection or using a shorter retention window are identified for review.",
|
||||
"Risk": "**Insufficient or missing VM backups** weaken **availability** and **integrity**. Short retention reduces recovery points, limiting rollback after **ransomware**, accidental deletion, or faulty changes. This increases RPO, extends RTO, and can force rebuilds, causing data loss and downtime.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/azure/VirtualMachines/sufficient-backup-retention-period.html",
|
||||
"https://learn.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction"
|
||||
],
|
||||
"Description": "Ensure there is a sufficient daily backup retention period configured for Azure virtual machines.",
|
||||
"Risk": "Having an optimal daily backup retention period for your Azure virtual machines will enforce your backup strategy to follow the best practices as specified in the compliance regulations promoted by your organization. Retaining VM backups for a longer period of time will allow you to handle more efficiently your data restoration process in the event of a failure.",
|
||||
"RelatedUrl": "https://docs.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "```bicep\n// Create/ensure a VM backup policy with daily retention >= minimum\nresource rv 'Microsoft.RecoveryServices/vaults@2023-01-01' existing = {\n name: '<example_resource_name>'\n}\n\nresource vmPolicy 'Microsoft.RecoveryServices/vaults/backupPolicies@2023-02-01' = {\n name: '${rv.name}/<example_resource_name>'\n properties: {\n backupManagementType: 'AzureIaasVM'\n schedulePolicy: {\n schedulePolicyType: 'SimpleSchedulePolicyV2'\n scheduleRunFrequency: 'Daily'\n scheduleRunTimes: ['2020-01-01T23:00:00Z']\n }\n retentionPolicy: {\n retentionPolicyType: 'LongTermRetentionPolicy'\n dailySchedule: {\n retentionTimes: ['2020-01-01T23:00:00Z']\n retentionDuration: {\n count: 7 // CRITICAL: sets daily retention to at least the minimum required days\n durationType: 'Days' // explains the unit\n }\n }\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure portal, go to Recovery Services vault <example_resource_name>\n2. Select Backup policies > Azure Virtual Machine > Edit (or Create new)\n3. Set Daily retention to at least 7 days and Save\n4. Go to Backup items > Azure Virtual Machine\n5. If the VM is unprotected: click Backup, select the policy from step 3, and Enable\n6. If the VM is already protected with an insufficient policy: select the VM > Change Policy > choose the policy from step 3 > Save",
|
||||
"Terraform": "```hcl\n# Backup policy with sufficient daily retention\nresource \"azurerm_backup_policy_vm\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n recovery_vault_name = \"<example_resource_name>\"\n\n backup {\n frequency = \"Daily\"\n time = \"23:00\"\n }\n\n retention_daily {\n count = 7 # CRITICAL: ensures daily retention meets minimum required days\n }\n}\n\n# Protect the VM with the compliant policy\nresource \"azurerm_backup_protected_vm\" \"<example_resource_name>\" {\n resource_group_name = \"<example_resource_name>\"\n recovery_vault_name = \"<example_resource_name>\"\n source_vm_id = \"<example_resource_id>\"\n backup_policy_id = azurerm_backup_policy_vm.<example_resource_name>.id # CRITICAL: applies the policy to the VM\n}\n```"
|
||||
"NativeIaC": "",
|
||||
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/sufficient-backup-retention-period.html",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Enforce backup policies that provide **daily retention** aligned to business RPO/RTO for every VM. Apply **defense in depth**: isolate backups in a vault, enable immutability/soft delete, limit changes with **least privilege** and MFA, and regularly test restores. Use tiered retention (daily/weekly/monthly/yearly) based on data criticality.",
|
||||
"Url": "https://hub.prowler.com/check/vm_sufficient_daily_backup_retention_period"
|
||||
"Text": "Set the daily backup retention period for each VM's backup policy to meet or exceed your organization's minimum requirement.",
|
||||
"Url": "https://docs.microsoft.com/en-us/azure/backup/backup-azure-vms-introduction"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"resilience"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
|
||||
+13
-18
@@ -1,35 +1,30 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "vm_trusted_launch_enabled",
|
||||
"CheckTitle": "Virtual Machine has Trusted Launch with Secure Boot and vTPM enabled",
|
||||
"CheckTitle": "Ensure Trusted Launch is enabled on Virtual Machines",
|
||||
"CheckType": [],
|
||||
"ServiceName": "vm",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "microsoft.compute/virtualmachines",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Microsoft.Compute/virtualMachines",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Azure VMs** are evaluated for **Trusted Launch** with both **Secure Boot** and **vTPM** enabled.\n\nIdentifies VMs not set to `TrustedLaunch` or missing `secureBootEnabled` and `vTpmEnabled` together.",
|
||||
"Risk": "Missing **Trusted Launch** weakens boot-chain integrity. Attackers can persist via bootkits/rootkits, bypass OS controls, steal secrets, and tamper with drivers. Loss of attestation reduces detection, risking **integrity**, **confidentiality**, and **availability** through stealthy, hard-to-remediate compromises.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch"
|
||||
],
|
||||
"Description": "When Secure Boot and vTPM are enabled together, they provide a strong foundation for protecting your VM from boot attacks. For example, if an attacker attempts to replace the bootloader with a malicious version, Secure Boot will prevent the VM from booting. If the attacker is able to bypass Secure Boot and install a malicious bootloader, vTPM can be used to detect the intrusion and alert you.",
|
||||
"Risk": "Secure Boot and vTPM work together to protect your VM from a variety of boot attacks, including bootkits, rootkits, and firmware rootkits. Not enabling Trusted Launch in Azure VM can lead to increased vulnerability to rootkits and boot-level malware, reduced ability to detect and prevent unauthorized changes to the boot process, and a potential compromise of system integrity and data security.",
|
||||
"RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch-existing-vm?tabs=portal",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az vm update --resource-group <RESOURCE_GROUP> --name <VM_NAME> --security-type TrustedLaunch --enable-secure-boot true --enable-vtpm true",
|
||||
"NativeIaC": "```bicep\n// Enables Trusted Launch with Secure Boot and vTPM on the VM\nresource vm 'Microsoft.Compute/virtualMachines@2022-11-01' = {\n name: '<example_resource_name>'\n location: '<region>'\n properties: {\n securityProfile: {\n securityType: 'TrustedLaunch' // Critical: sets VM security type to Trusted Launch\n uefiSettings: {\n secureBootEnabled: true // Critical: enables Secure Boot\n vTpmEnabled: true // Critical: enables vTPM\n }\n }\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, open the VM and click Stop to deallocate it\n2. Go to Settings > Configuration\n3. Under Security type, select Trusted launch\n4. Check Secure Boot and vTPM\n5. Click Save\n6. Start the VM from the Overview page",
|
||||
"Terraform": "```hcl\n# Patch the existing VM to enable Trusted Launch with Secure Boot and vTPM\nresource \"azapi_update_resource\" \"<example_resource_name>\" {\n type = \"Microsoft.Compute/virtualMachines@2022-11-01\"\n resource_id = \"<example_resource_id>\"\n\n body = jsonencode({\n properties = {\n securityProfile = {\n securityType = \"TrustedLaunch\" # Critical: sets VM security type to Trusted Launch\n uefiSettings = {\n secureBootEnabled = true # Critical: enables Secure Boot\n vTpmEnabled = true # Critical: enables vTPM\n }\n }\n }\n })\n}\n```"
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Adopt **defense in depth**: enable **Trusted Launch** with **Secure Boot** and **vTPM** on Gen2 VMs. Standardize on images with signed boot components and use supported sizes/OS. Enforce **least privilege** for administrators and enable attestation monitoring to prevent and detect boot-level tampering.",
|
||||
"Url": "https://hub.prowler.com/check/vm_trusted_launch_enabled"
|
||||
"Text": "1. Go to Virtual Machines 2. For each VM, under Settings, click on Configuration on the left blade 3. Under Security Type, select 'Trusted Launch Virtual Machines' 4. Make sure Enable Secure Boot & Enable vTPM are checked 5. Click on Apply.",
|
||||
"Url": "https://learn.microsoft.com/en-us/azure/virtual-machines/trusted-launch-existing-vm?tabs=portal#enable-trusted-launch-on-existing-vm"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"node-security"
|
||||
],
|
||||
"Categories": [],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Secure Boot and vTPM are not currently supported for Azure Generation 1 VMs. IMPORTANT: Before enabling Secure Boot and vTPM on a Generation 2 VM which does not already have both enabled, it is highly recommended to create a restore point of the VM prior to remediation."
|
||||
|
||||
@@ -285,6 +285,19 @@ class Provider(ABC):
|
||||
timeout=arguments.timeout,
|
||||
config_path=arguments.config_file,
|
||||
fixer_config=fixer_config,
|
||||
registry_username=getattr(arguments, "registry_username", None),
|
||||
registry_password=getattr(arguments, "registry_password", None),
|
||||
registry_token=getattr(arguments, "registry_token", None),
|
||||
registry=getattr(arguments, "registry", None),
|
||||
image_filter=getattr(arguments, "image_filter", None),
|
||||
tag_filter=getattr(arguments, "tag_filter", None),
|
||||
max_images=getattr(arguments, "max_images", 0),
|
||||
registry_insecure=getattr(
|
||||
arguments, "registry_insecure", False
|
||||
),
|
||||
registry_list_images=getattr(
|
||||
arguments, "registry_list_images", False
|
||||
),
|
||||
)
|
||||
elif "mongodbatlas" in provider_class_name.lower():
|
||||
provider_class(
|
||||
|
||||
@@ -285,18 +285,6 @@ class GithubProvider(Provider):
|
||||
else:
|
||||
app_key = format_rsa_key(github_app_key_content)
|
||||
|
||||
# Check for incomplete GitHub App credentials (user provided only part of them)
|
||||
elif (github_app_key or github_app_key_content) and not github_app_id:
|
||||
raise GithubEnvironmentVariableError(
|
||||
file=os.path.basename(__file__),
|
||||
message="GitHub App authentication requires both --github-app-id and --github-app-key-path (or --github-app-key). Missing: --github-app-id",
|
||||
)
|
||||
elif github_app_id and not (github_app_key or github_app_key_content):
|
||||
raise GithubEnvironmentVariableError(
|
||||
file=os.path.basename(__file__),
|
||||
message="GitHub App authentication requires both --github-app-id and --github-app-key-path (or --github-app-key). Missing: --github-app-key-path or --github-app-key",
|
||||
)
|
||||
|
||||
else:
|
||||
# PAT
|
||||
logger.info(
|
||||
|
||||
+1
-4
@@ -26,10 +26,7 @@ class repository_branch_delete_on_merge_enabled(Check):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Repository {repo.name} does not delete branches on merge in default branch ({repo.default_branch.name})."
|
||||
|
||||
if repo.delete_branch_on_merge is None:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Repository {repo.name} branch deletion setting could not be checked in default branch ({repo.default_branch.name}) due to insufficient permissions. Requires Administration: Read and Write permission."
|
||||
elif repo.delete_branch_on_merge:
|
||||
if repo.delete_branch_on_merge:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Repository {repo.name} does delete branches on merge in default branch ({repo.default_branch.name})."
|
||||
|
||||
|
||||
@@ -241,9 +241,11 @@ class Repository(GithubService):
|
||||
codeowners_exists = None
|
||||
else:
|
||||
codeowners_exists = False
|
||||
# GitHub API only returns delete_branch_on_merge with Administration: Read and Write
|
||||
# With Read-only permission, it returns None - set to None for MANUAL status
|
||||
delete_branch_on_merge = repo.delete_branch_on_merge
|
||||
delete_branch_on_merge = (
|
||||
repo.delete_branch_on_merge
|
||||
if repo.delete_branch_on_merge is not None
|
||||
else False
|
||||
)
|
||||
|
||||
require_pr = False
|
||||
approval_cnt = 0
|
||||
|
||||
@@ -50,12 +50,32 @@ class ImageBaseException(ProwlerException):
|
||||
"message": "Invalid image config scanner type.",
|
||||
"remediation": "Use valid image config scanners: misconfig, secret.",
|
||||
},
|
||||
(11013, "ImageRegistryAuthError"): {
|
||||
"message": "Registry authentication failed.",
|
||||
"remediation": "Check REGISTRY_USERNAME/REGISTRY_PASSWORD or REGISTRY_TOKEN environment variables.",
|
||||
},
|
||||
(11014, "ImageRegistryCatalogError"): {
|
||||
"message": "Registry does not support catalog listing.",
|
||||
"remediation": "Use --image or --image-list instead of --registry.",
|
||||
},
|
||||
(11015, "ImageRegistryNetworkError"): {
|
||||
"message": "Network error communicating with registry.",
|
||||
"remediation": "Check registry URL and network connectivity.",
|
||||
},
|
||||
(11016, "ImageMaxImagesExceededError"): {
|
||||
"message": "Discovered images exceed --max-images limit.",
|
||||
"remediation": "Use --image-filter or --tag-filter to narrow results, or increase --max-images.",
|
||||
},
|
||||
(11017, "ImageInvalidFilterError"): {
|
||||
"message": "Invalid regex filter pattern.",
|
||||
"remediation": "Check the regex syntax for --image-filter or --tag-filter.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
error_info = self.IMAGE_ERROR_CODES.get((code, self.__class__.__name__))
|
||||
if message:
|
||||
error_info["message"] = message
|
||||
if error_info and message:
|
||||
error_info = {**error_info, "message": message}
|
||||
super().__init__(
|
||||
code,
|
||||
source="Image",
|
||||
@@ -162,3 +182,48 @@ class ImageInvalidConfigScannerError(ImageBaseException):
|
||||
super().__init__(
|
||||
11010, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageRegistryAuthError(ImageBaseException):
|
||||
"""Exception raised when registry authentication fails."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11013, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageRegistryCatalogError(ImageBaseException):
|
||||
"""Exception raised when registry does not support catalog listing."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11014, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageRegistryNetworkError(ImageBaseException):
|
||||
"""Exception raised when a network error occurs communicating with a registry."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11015, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageMaxImagesExceededError(ImageBaseException):
|
||||
"""Exception raised when discovered images exceed --max-images limit."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11016, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class ImageInvalidFilterError(ImageBaseException):
|
||||
"""Exception raised when an invalid regex filter pattern is provided."""
|
||||
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
11017, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -21,12 +22,14 @@ from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageFindingProcessingError,
|
||||
ImageInvalidConfigScannerError,
|
||||
ImageInvalidFilterError,
|
||||
ImageInvalidNameError,
|
||||
ImageInvalidScannerError,
|
||||
ImageInvalidSeverityError,
|
||||
ImageInvalidTimeoutError,
|
||||
ImageListFileNotFoundError,
|
||||
ImageListFileReadError,
|
||||
ImageMaxImagesExceededError,
|
||||
ImageNoImagesProvidedError,
|
||||
ImageScanError,
|
||||
ImageTrivyBinaryNotFoundError,
|
||||
@@ -36,6 +39,8 @@ from prowler.providers.image.lib.arguments.arguments import (
|
||||
SCANNERS_CHOICES,
|
||||
SEVERITY_CHOICES,
|
||||
)
|
||||
from prowler.providers.image.lib.registry.dockerhub_adapter import DockerHubAdapter
|
||||
from prowler.providers.image.lib.registry.factory import create_registry_adapter
|
||||
|
||||
|
||||
class ImageProvider(Provider):
|
||||
@@ -66,6 +71,15 @@ class ImageProvider(Provider):
|
||||
config_path: str | None = None,
|
||||
config_content: dict | None = None,
|
||||
fixer_config: dict | None = None,
|
||||
registry_username: str | None = None,
|
||||
registry_password: str | None = None,
|
||||
registry_token: str | None = None,
|
||||
registry: str | None = None,
|
||||
image_filter: str | None = None,
|
||||
tag_filter: str | None = None,
|
||||
max_images: int = 0,
|
||||
registry_insecure: bool = False,
|
||||
registry_list_images: bool = False,
|
||||
):
|
||||
logger.info("Instantiating Image Provider...")
|
||||
|
||||
@@ -82,7 +96,53 @@ class ImageProvider(Provider):
|
||||
self.audited_account = "image-scan"
|
||||
self._session = None
|
||||
self._identity = "prowler"
|
||||
self._auth_method = "No auth"
|
||||
self._listing_only = False
|
||||
|
||||
# Registry authentication (follows IaC pattern: explicit params, env vars internal)
|
||||
self.registry_username = registry_username or os.environ.get(
|
||||
"REGISTRY_USERNAME"
|
||||
)
|
||||
self.registry_password = registry_password or os.environ.get(
|
||||
"REGISTRY_PASSWORD"
|
||||
)
|
||||
self.registry_token = registry_token or os.environ.get("REGISTRY_TOKEN")
|
||||
|
||||
if self.registry_username and self.registry_password:
|
||||
self._auth_method = "Docker login"
|
||||
logger.info("Using docker login for registry authentication")
|
||||
elif self.registry_token:
|
||||
self._auth_method = "Registry token"
|
||||
logger.info("Using registry token for authentication")
|
||||
else:
|
||||
self._auth_method = "No auth"
|
||||
|
||||
# Registry scan mode
|
||||
self.registry = registry
|
||||
self.image_filter = image_filter
|
||||
self.tag_filter = tag_filter
|
||||
self.max_images = max_images
|
||||
self.registry_insecure = registry_insecure
|
||||
self.registry_list_images = registry_list_images
|
||||
|
||||
# Compile regex filters
|
||||
self._image_filter_re = None
|
||||
self._tag_filter_re = None
|
||||
if self.image_filter:
|
||||
try:
|
||||
self._image_filter_re = re.compile(self.image_filter)
|
||||
except re.error as exc:
|
||||
raise ImageInvalidFilterError(
|
||||
file=__file__,
|
||||
message=f"Invalid --image-filter regex '{self.image_filter}': {exc}",
|
||||
)
|
||||
if self.tag_filter:
|
||||
try:
|
||||
self._tag_filter_re = re.compile(self.tag_filter)
|
||||
except re.error as exc:
|
||||
raise ImageInvalidFilterError(
|
||||
file=__file__,
|
||||
message=f"Invalid --tag-filter regex '{self.tag_filter}': {exc}",
|
||||
)
|
||||
|
||||
self._validate_inputs()
|
||||
|
||||
@@ -90,6 +150,12 @@ class ImageProvider(Provider):
|
||||
if image_list_file:
|
||||
self._load_images_from_file(image_list_file)
|
||||
|
||||
# Registry scan mode: enumerate images from registry
|
||||
if self.registry:
|
||||
self._enumerate_registry()
|
||||
if self._listing_only:
|
||||
return
|
||||
|
||||
for image in self.images:
|
||||
self._validate_image_name(image)
|
||||
|
||||
@@ -245,37 +311,59 @@ class ImageProvider(Provider):
|
||||
"""Image provider doesn't need a session since it uses Trivy directly"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_registry(image: str) -> str | None:
|
||||
"""Extract registry hostname from an image reference.
|
||||
|
||||
Returns None for Docker Hub images (no registry prefix).
|
||||
"""
|
||||
parts = image.split("/")
|
||||
if len(parts) >= 2 and ("." in parts[0] or ":" in parts[0]):
|
||||
return parts[0]
|
||||
return None
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up any resources after scanning."""
|
||||
|
||||
def _process_finding(
|
||||
self, finding: dict, image_name: str, finding_type: str
|
||||
self,
|
||||
finding: dict,
|
||||
image: str,
|
||||
trivy_target: str,
|
||||
image_sha: str = "",
|
||||
) -> CheckReportImage:
|
||||
"""
|
||||
Process a single finding and create a CheckReportImage object.
|
||||
|
||||
Args:
|
||||
finding: The finding object from Trivy output
|
||||
image_name: The container image name being scanned
|
||||
finding_type: The type of finding (Vulnerability, Secret, etc.)
|
||||
image: The clean container image name (e.g., "alpine:3.18")
|
||||
trivy_target: The Trivy target string (e.g., "alpine:3.18 (alpine 3.18.0)")
|
||||
image_sha: Short SHA from Trivy Metadata.ImageID for resource uniqueness
|
||||
|
||||
Returns:
|
||||
CheckReportImage: The processed check report
|
||||
"""
|
||||
try:
|
||||
# Determine finding ID based on type
|
||||
# Determine finding ID and category based on type
|
||||
if "VulnerabilityID" in finding:
|
||||
finding_id = finding["VulnerabilityID"]
|
||||
finding_description = finding.get(
|
||||
"Description", finding.get("Title", "")
|
||||
)
|
||||
finding_status = "FAIL"
|
||||
finding_categories = ["vulnerability"]
|
||||
elif "RuleID" in finding:
|
||||
# Secret finding
|
||||
finding_id = finding["RuleID"]
|
||||
finding_description = finding.get("Title", "Secret detected")
|
||||
finding_status = "FAIL"
|
||||
finding_categories = ["secrets"]
|
||||
else:
|
||||
finding_id = finding.get("ID", "UNKNOWN")
|
||||
finding_description = finding.get("Description", "")
|
||||
finding_status = finding.get("Status", "FAIL")
|
||||
finding_categories = []
|
||||
|
||||
# Build remediation text for vulnerabilities
|
||||
remediation_text = ""
|
||||
@@ -294,7 +382,7 @@ class ImageProvider(Provider):
|
||||
"CheckID": finding_id,
|
||||
"CheckTitle": finding.get("Title", finding_id),
|
||||
"CheckType": ["Container Image Security"],
|
||||
"ServiceName": finding_type,
|
||||
"ServiceName": "container-image",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": trivy_severity,
|
||||
@@ -304,7 +392,7 @@ class ImageProvider(Provider):
|
||||
"Risk": finding.get(
|
||||
"Description", "Vulnerability detected in container image"
|
||||
),
|
||||
"RelatedUrl": finding.get("PrimaryURL", ""),
|
||||
"RelatedUrl": "",
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"NativeIaC": "",
|
||||
@@ -317,7 +405,7 @@ class ImageProvider(Provider):
|
||||
"Url": finding.get("PrimaryURL", ""),
|
||||
},
|
||||
},
|
||||
"Categories": [],
|
||||
"Categories": finding_categories,
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "",
|
||||
@@ -327,11 +415,13 @@ class ImageProvider(Provider):
|
||||
metadata = json.dumps(metadata_dict)
|
||||
|
||||
report = CheckReportImage(
|
||||
metadata=metadata, finding=finding, image_name=image_name
|
||||
metadata=metadata, finding=finding, image_name=image
|
||||
)
|
||||
report.status = finding_status
|
||||
report.status_extended = self._build_status_extended(finding)
|
||||
report.region = self.region
|
||||
report.image_sha = image_sha
|
||||
report.resource_details = trivy_target
|
||||
return report
|
||||
|
||||
except Exception as error:
|
||||
@@ -368,10 +458,36 @@ class ImageProvider(Provider):
|
||||
|
||||
def run(self) -> list[CheckReportImage]:
|
||||
"""Execute the container image scan."""
|
||||
reports = []
|
||||
for batch in self.run_scan():
|
||||
reports.extend(batch)
|
||||
return reports
|
||||
try:
|
||||
reports = []
|
||||
for batch in self.run_scan():
|
||||
reports.extend(batch)
|
||||
return reports
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
def scan_per_image(
|
||||
self,
|
||||
) -> Generator[tuple[str, list[CheckReportImage]], None, None]:
|
||||
"""Scan images one by one, yielding (image_name, findings) per image.
|
||||
|
||||
Unlike run() which returns all findings at once, this method yields
|
||||
after each image completes, enabling progress tracking.
|
||||
"""
|
||||
try:
|
||||
for image in self.images:
|
||||
try:
|
||||
image_findings = []
|
||||
for batch in self._scan_single_image(image):
|
||||
image_findings.extend(batch)
|
||||
yield (image, image_findings)
|
||||
except (ImageScanError, ImageTrivyBinaryNotFoundError):
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"Error scanning image {image}: {error}")
|
||||
continue
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
def run_scan(self) -> Generator[list[CheckReportImage], None, None]:
|
||||
"""
|
||||
@@ -454,6 +570,19 @@ class ImageProvider(Provider):
|
||||
logger.info(f"No findings for image: {image}")
|
||||
return
|
||||
|
||||
# Extract image digest for resource uniqueness
|
||||
trivy_metadata = output.get("Metadata", {})
|
||||
image_id = trivy_metadata.get("ImageID", "")
|
||||
if not image_id:
|
||||
repo_digests = trivy_metadata.get("RepoDigests", [])
|
||||
if repo_digests:
|
||||
image_id = (
|
||||
repo_digests[0].split("@")[-1]
|
||||
if "@" in repo_digests[0]
|
||||
else ""
|
||||
)
|
||||
short_sha = image_id.replace("sha256:", "")[:12] if image_id else ""
|
||||
|
||||
except json.JSONDecodeError as error:
|
||||
logger.error(f"Failed to parse Trivy output for {image}: {error}")
|
||||
logger.debug(f"Trivy stdout: {process.stdout[:500]}")
|
||||
@@ -464,11 +593,12 @@ class ImageProvider(Provider):
|
||||
|
||||
for result in results:
|
||||
target = result.get("Target", image)
|
||||
result_type = result.get("Type", "unknown")
|
||||
|
||||
# Process Vulnerabilities
|
||||
for vuln in result.get("Vulnerabilities", []):
|
||||
report = self._process_finding(vuln, target, result_type)
|
||||
report = self._process_finding(
|
||||
vuln, image, target, image_sha=short_sha
|
||||
)
|
||||
batch.append(report)
|
||||
if len(batch) >= self.FINDING_BATCH_SIZE:
|
||||
yield batch
|
||||
@@ -476,7 +606,9 @@ class ImageProvider(Provider):
|
||||
|
||||
# Process Secrets
|
||||
for secret in result.get("Secrets", []):
|
||||
report = self._process_finding(secret, target, "secret")
|
||||
report = self._process_finding(
|
||||
secret, image, target, image_sha=short_sha
|
||||
)
|
||||
batch.append(report)
|
||||
if len(batch) >= self.FINDING_BATCH_SIZE:
|
||||
yield batch
|
||||
@@ -485,7 +617,7 @@ class ImageProvider(Provider):
|
||||
# Process Misconfigurations (from Dockerfile)
|
||||
for misconfig in result.get("Misconfigurations", []):
|
||||
report = self._process_finding(
|
||||
misconfig, target, "misconfiguration"
|
||||
misconfig, image, target, image_sha=short_sha
|
||||
)
|
||||
batch.append(report)
|
||||
if len(batch) >= self.FINDING_BATCH_SIZE:
|
||||
@@ -507,8 +639,19 @@ class ImageProvider(Provider):
|
||||
)
|
||||
logger.error(f"Error scanning image {image}: {error}")
|
||||
|
||||
def _build_trivy_env(self) -> dict:
|
||||
"""Build environment variables for Trivy, injecting registry credentials."""
|
||||
env = dict(os.environ)
|
||||
if self.registry_username and self.registry_password:
|
||||
env["TRIVY_USERNAME"] = self.registry_username
|
||||
env["TRIVY_PASSWORD"] = self.registry_password
|
||||
elif self.registry_token:
|
||||
env["TRIVY_REGISTRY_TOKEN"] = self.registry_token
|
||||
return env
|
||||
|
||||
def _execute_trivy(self, command: list, image: str) -> subprocess.CompletedProcess:
|
||||
"""Execute Trivy command with optional progress bar."""
|
||||
env = self._build_trivy_env()
|
||||
try:
|
||||
if sys.stdout.isatty():
|
||||
with alive_bar(
|
||||
@@ -523,6 +666,7 @@ class ImageProvider(Provider):
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
bar.title = f"-> Scan completed for {image}"
|
||||
return process
|
||||
@@ -532,12 +676,13 @@ class ImageProvider(Provider):
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
logger.info(f"Scan completed for {image}")
|
||||
return process
|
||||
except (AttributeError, OSError):
|
||||
logger.info(f"Scanning {image}...")
|
||||
return subprocess.run(command, capture_output=True, text=True)
|
||||
return subprocess.run(command, capture_output=True, text=True, env=env)
|
||||
|
||||
def _log_trivy_stderr(self, stderr: str) -> None:
|
||||
"""Parse and log Trivy's stderr output."""
|
||||
@@ -596,6 +741,105 @@ class ImageProvider(Provider):
|
||||
|
||||
return error_msg
|
||||
|
||||
def _enumerate_registry(self) -> None:
|
||||
"""Enumerate images from a registry using the appropriate adapter."""
|
||||
verify_ssl = not self.registry_insecure
|
||||
adapter = create_registry_adapter(
|
||||
registry_url=self.registry,
|
||||
username=self.registry_username,
|
||||
password=self.registry_password,
|
||||
token=self.registry_token,
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
repositories = adapter.list_repositories()
|
||||
logger.info(
|
||||
f"Discovered {len(repositories)} repositories from registry {self.registry}"
|
||||
)
|
||||
|
||||
# Apply image filter
|
||||
if self._image_filter_re:
|
||||
repositories = [r for r in repositories if self._image_filter_re.search(r)]
|
||||
logger.info(
|
||||
f"{len(repositories)} repositories match --image-filter '{self.image_filter}'"
|
||||
)
|
||||
|
||||
if not repositories:
|
||||
logger.warning(
|
||||
f"No repositories found in registry {self.registry} (after filtering)"
|
||||
)
|
||||
return
|
||||
|
||||
# Determine if this is a Docker Hub adapter (for image reference format)
|
||||
is_dockerhub = isinstance(adapter, DockerHubAdapter)
|
||||
|
||||
discovered_images = []
|
||||
repos_tags: dict[str, list[str]] = {}
|
||||
for repo in repositories:
|
||||
tags = adapter.list_tags(repo)
|
||||
|
||||
# Apply tag filter
|
||||
if self._tag_filter_re:
|
||||
tags = [t for t in tags if self._tag_filter_re.search(t)]
|
||||
|
||||
if tags:
|
||||
repos_tags[repo] = tags
|
||||
|
||||
for tag in tags:
|
||||
if is_dockerhub:
|
||||
# Docker Hub images don't need a host prefix
|
||||
image_ref = f"{repo}:{tag}"
|
||||
else:
|
||||
# OCI registries need the full host/repo:tag reference
|
||||
registry_host = self.registry.rstrip("/")
|
||||
for prefix in ("https://", "http://"):
|
||||
if registry_host.startswith(prefix):
|
||||
registry_host = registry_host[len(prefix) :]
|
||||
break
|
||||
image_ref = f"{registry_host}/{repo}:{tag}"
|
||||
discovered_images.append(image_ref)
|
||||
|
||||
# Registry list mode: print listing and return early
|
||||
if self.registry_list_images:
|
||||
self._print_registry_listing(repos_tags, len(discovered_images))
|
||||
self._listing_only = True
|
||||
return
|
||||
|
||||
# Check max-images limit
|
||||
if self.max_images and len(discovered_images) > self.max_images:
|
||||
raise ImageMaxImagesExceededError(
|
||||
file=__file__,
|
||||
message=f"Discovered {len(discovered_images)} images, exceeding --max-images {self.max_images}. Use --image-filter or --tag-filter to narrow results.",
|
||||
)
|
||||
|
||||
# Deduplicate with explicit images
|
||||
existing = set(self.images)
|
||||
for img in discovered_images:
|
||||
if img not in existing:
|
||||
self.images.append(img)
|
||||
existing.add(img)
|
||||
|
||||
logger.info(
|
||||
f"Discovered {len(discovered_images)} images from registry {self.registry} "
|
||||
f"({len(repositories)} repositories). Total images to scan: {len(self.images)}"
|
||||
)
|
||||
|
||||
def _print_registry_listing(
|
||||
self, repos_tags: dict[str, list[str]], total_images: int
|
||||
) -> None:
|
||||
"""Print a structured listing of registry repositories and tags."""
|
||||
num_repos = len(repos_tags)
|
||||
print(
|
||||
f"\n{Style.BRIGHT}Registry:{Style.RESET_ALL} "
|
||||
f"{Fore.CYAN}{self.registry}{Style.RESET_ALL} "
|
||||
f"({num_repos} {'repository' if num_repos == 1 else 'repositories'}, "
|
||||
f"{total_images} {'image' if total_images == 1 else 'images'})\n"
|
||||
)
|
||||
for repo, tags in repos_tags.items():
|
||||
print(f" {Fore.YELLOW}{repo}{Style.RESET_ALL} " f"({len(tags)} tags)")
|
||||
print(f" {', '.join(tags)}")
|
||||
print()
|
||||
|
||||
def print_credentials(self) -> None:
|
||||
"""Print scan configuration."""
|
||||
report_title = f"{Style.BRIGHT}Scanning container images:{Style.RESET_ALL}"
|
||||
@@ -628,6 +872,23 @@ class ImageProvider(Provider):
|
||||
|
||||
report_lines.append(f"Timeout: {Fore.YELLOW}{self.timeout}{Style.RESET_ALL}")
|
||||
|
||||
report_lines.append(
|
||||
f"Authentication method: {Fore.YELLOW}{self.auth_method}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
if self.registry:
|
||||
report_lines.append(
|
||||
f"Registry: {Fore.YELLOW}{self.registry}{Style.RESET_ALL}"
|
||||
)
|
||||
if self.image_filter:
|
||||
report_lines.append(
|
||||
f"Image filter: {Fore.YELLOW}{self.image_filter}{Style.RESET_ALL}"
|
||||
)
|
||||
if self.tag_filter:
|
||||
report_lines.append(
|
||||
f"Tag filter: {Fore.YELLOW}{self.tag_filter}{Style.RESET_ALL}"
|
||||
)
|
||||
|
||||
print_boxes(report_lines, report_title)
|
||||
|
||||
@staticmethod
|
||||
@@ -635,6 +896,9 @@ class ImageProvider(Provider):
|
||||
image: str | None = None,
|
||||
raise_on_exception: bool = True,
|
||||
provider_id: str | None = None,
|
||||
registry_username: str | None = None,
|
||||
registry_password: str | None = None,
|
||||
registry_token: str | None = None,
|
||||
) -> "Connection":
|
||||
"""
|
||||
Test connection to container registry by attempting to inspect an image.
|
||||
@@ -643,6 +907,9 @@ class ImageProvider(Provider):
|
||||
image: Container image to test
|
||||
raise_on_exception: Whether to raise exceptions
|
||||
provider_id: Fallback for image name
|
||||
registry_username: Registry username for basic auth
|
||||
registry_password: Registry password for basic auth
|
||||
registry_token: Registry token for token-based auth
|
||||
|
||||
Returns:
|
||||
Connection: Connection object with success status
|
||||
@@ -654,6 +921,14 @@ class ImageProvider(Provider):
|
||||
if not image:
|
||||
return Connection(is_connected=False, error="Image name is required")
|
||||
|
||||
# Build env with registry credentials
|
||||
env = dict(os.environ)
|
||||
if registry_username and registry_password:
|
||||
env["TRIVY_USERNAME"] = registry_username
|
||||
env["TRIVY_PASSWORD"] = registry_password
|
||||
elif registry_token:
|
||||
env["TRIVY_REGISTRY_TOKEN"] = registry_token
|
||||
|
||||
# Test by running trivy with --skip-update to just test image access
|
||||
process = subprocess.run(
|
||||
[
|
||||
@@ -666,6 +941,7 @@ class ImageProvider(Provider):
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if process.returncode == 0:
|
||||
|
||||
@@ -88,16 +88,96 @@ def init_parser(self):
|
||||
help="Trivy scan timeout. Default: 5m. Examples: 10m, 1h",
|
||||
)
|
||||
|
||||
# Registry Scan Mode
|
||||
registry_group = image_parser.add_argument_group("Registry Scan Mode")
|
||||
registry_group.add_argument(
|
||||
"--registry",
|
||||
dest="registry",
|
||||
default=None,
|
||||
help="Registry URL to enumerate and scan all images. Examples: myregistry.io, docker.io/myorg, 123456789.dkr.ecr.us-east-1.amazonaws.com",
|
||||
)
|
||||
registry_group.add_argument(
|
||||
"--image-filter",
|
||||
dest="image_filter",
|
||||
default=None,
|
||||
help="Regex to filter repository names during registry enumeration (re.search). Example: '^prod/.*'",
|
||||
)
|
||||
registry_group.add_argument(
|
||||
"--tag-filter",
|
||||
dest="tag_filter",
|
||||
default=None,
|
||||
help=r"Regex to filter tags during registry enumeration (re.search). Example: '^(latest|v\d+\.\d+\.\d+)$'",
|
||||
)
|
||||
registry_group.add_argument(
|
||||
"--max-images",
|
||||
dest="max_images",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Maximum number of images to scan from registry. 0 = unlimited. Aborts if exceeded.",
|
||||
)
|
||||
registry_group.add_argument(
|
||||
"--registry-insecure",
|
||||
dest="registry_insecure",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="Skip TLS verification for registry connections (for self-signed certificates).",
|
||||
)
|
||||
registry_group.add_argument(
|
||||
"--registry-list",
|
||||
dest="registry_list_images",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="List all repositories and tags from the registry, then exit without scanning. Useful for discovering available images before building --image-filter or --tag-filter.",
|
||||
)
|
||||
|
||||
|
||||
def validate_arguments(arguments):
|
||||
"""Validate Image provider arguments."""
|
||||
images = getattr(arguments, "images", [])
|
||||
image_list_file = getattr(arguments, "image_list_file", None)
|
||||
registry = getattr(arguments, "registry", None)
|
||||
image_filter = getattr(arguments, "image_filter", None)
|
||||
tag_filter = getattr(arguments, "tag_filter", None)
|
||||
max_images = getattr(arguments, "max_images", 0)
|
||||
registry_insecure = getattr(arguments, "registry_insecure", False)
|
||||
registry_list_images = getattr(arguments, "registry_list_images", False)
|
||||
|
||||
if not images and not image_list_file:
|
||||
if registry_list_images and not registry:
|
||||
return (False, "--registry-list requires --registry.")
|
||||
|
||||
if not images and not image_list_file and not registry:
|
||||
return (
|
||||
False,
|
||||
"At least one image must be specified using --image (-I) or --image-list.",
|
||||
"At least one image source must be specified using --image (-I), --image-list, or --registry.",
|
||||
)
|
||||
|
||||
# Registry-only flags require --registry
|
||||
if not registry:
|
||||
if image_filter:
|
||||
return (False, "--image-filter requires --registry.")
|
||||
if tag_filter:
|
||||
return (False, "--tag-filter requires --registry.")
|
||||
if max_images:
|
||||
return (False, "--max-images requires --registry.")
|
||||
if registry_insecure:
|
||||
return (False, "--registry-insecure requires --registry.")
|
||||
|
||||
# Docker Hub namespace validation
|
||||
if registry:
|
||||
url = registry.rstrip("/")
|
||||
for prefix in ("https://", "http://"):
|
||||
if url.startswith(prefix):
|
||||
url = url[len(prefix) :]
|
||||
break
|
||||
stripped = url
|
||||
for prefix in ("registry-1.docker.io", "docker.io"):
|
||||
if stripped.startswith(prefix):
|
||||
stripped = stripped[len(prefix) :].lstrip("/")
|
||||
if not stripped:
|
||||
return (
|
||||
False,
|
||||
"Docker Hub requires a namespace. Use --registry docker.io/{org_or_user}.",
|
||||
)
|
||||
break
|
||||
|
||||
return (True, "")
|
||||
Whitespace-only changes.
@@ -0,0 +1,141 @@
|
||||
"""Registry adapter abstract base class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import requests
|
||||
|
||||
from prowler.config.config import prowler_version
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.image.exceptions.exceptions import ImageRegistryNetworkError
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
_BACKOFF_BASE = 1
|
||||
_USER_AGENT = f"Prowler/{prowler_version} (registry-adapter)"
|
||||
|
||||
|
||||
class RegistryAdapter(ABC):
|
||||
"""Abstract base class for registry adapters."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry_url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
token: str | None = None,
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
self.registry_url = registry_url
|
||||
self.username = username
|
||||
self._password = password
|
||||
self._token = token
|
||||
self.verify_ssl = verify_ssl
|
||||
|
||||
@property
|
||||
def password(self) -> str | None:
|
||||
return self._password
|
||||
|
||||
@property
|
||||
def token(self) -> str | None:
|
||||
return self._token
|
||||
|
||||
def __getstate__(self) -> dict:
|
||||
state = self.__dict__.copy()
|
||||
state["_password"] = "***" if state.get("_password") else None
|
||||
state["_token"] = "***" if state.get("_token") else None
|
||||
return state
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"{self.__class__.__name__}("
|
||||
f"registry_url={self.registry_url!r}, "
|
||||
f"username={self.username!r}, "
|
||||
f"password={'<redacted>' if self._password else None}, "
|
||||
f"token={'<redacted>' if self._token else None})"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def list_repositories(self) -> list[str]:
|
||||
"""Enumerate all repository names in the registry."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def list_tags(self, repository: str) -> list[str]:
|
||||
"""Enumerate all tags for a repository."""
|
||||
...
|
||||
|
||||
def _request_with_retry(self, method: str, url: str, **kwargs) -> requests.Response:
|
||||
context_label = kwargs.pop("context_label", None) or self.registry_url
|
||||
kwargs.setdefault("timeout", 30)
|
||||
kwargs.setdefault("verify", self.verify_ssl)
|
||||
headers = kwargs.get("headers", {})
|
||||
headers.setdefault("User-Agent", _USER_AGENT)
|
||||
kwargs["headers"] = headers
|
||||
last_exception = None
|
||||
last_status = None
|
||||
last_body = None
|
||||
for attempt in range(1, _MAX_RETRIES + 1):
|
||||
try:
|
||||
resp = requests.request(method, url, **kwargs)
|
||||
if resp.status_code == 429:
|
||||
last_status = 429
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Rate limited by {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
if resp.status_code >= 500:
|
||||
last_status = resp.status_code
|
||||
last_body = (resp.text or "")[:500]
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Server error from {context_label} (HTTP {resp.status_code}), "
|
||||
f"retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES}): {last_body}"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
return resp
|
||||
except requests.exceptions.ConnectionError as exc:
|
||||
last_exception = exc
|
||||
if attempt < _MAX_RETRIES:
|
||||
wait = _BACKOFF_BASE * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
f"Connection error to {context_label}, retrying in {wait}s (attempt {attempt}/{_MAX_RETRIES})"
|
||||
)
|
||||
time.sleep(wait)
|
||||
continue
|
||||
except requests.exceptions.Timeout as exc:
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Connection timed out to {context_label}.",
|
||||
original_exception=exc,
|
||||
)
|
||||
if last_status == 429:
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Rate limited by {context_label} after {_MAX_RETRIES} attempts.",
|
||||
)
|
||||
if last_status is not None and last_status >= 500:
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Server error from {context_label} (HTTP {last_status}) after {_MAX_RETRIES} attempts: {last_body}",
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Failed to connect to {context_label} after {_MAX_RETRIES} attempts.",
|
||||
original_exception=last_exception,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _next_page_url(resp: requests.Response) -> str | None:
|
||||
link_header = resp.headers.get("Link", "")
|
||||
if not link_header:
|
||||
return None
|
||||
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Docker Hub registry adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageRegistryAuthError,
|
||||
ImageRegistryCatalogError,
|
||||
ImageRegistryNetworkError,
|
||||
)
|
||||
from prowler.providers.image.lib.registry.base import RegistryAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import requests
|
||||
|
||||
_HUB_API = "https://hub.docker.com"
|
||||
_REGISTRY_HOST = "https://registry-1.docker.io"
|
||||
_AUTH_URL = "https://auth.docker.io/token"
|
||||
|
||||
|
||||
class DockerHubAdapter(RegistryAdapter):
|
||||
"""Adapter for Docker Hub using the Hub REST API + OCI tag listing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry_url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
token: str | None = None,
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
if not verify_ssl:
|
||||
logger.warning(
|
||||
"Docker Hub always uses TLS verification; --registry-insecure is ignored for Docker Hub registries."
|
||||
)
|
||||
super().__init__(registry_url, username, password, token, verify_ssl=True)
|
||||
self.namespace = self._extract_namespace(registry_url)
|
||||
self._hub_jwt: str | None = None
|
||||
self._registry_tokens: dict[str, str] = {}
|
||||
|
||||
@staticmethod
|
||||
def _extract_namespace(registry_url: str) -> str:
|
||||
url = registry_url.rstrip("/")
|
||||
for prefix in (
|
||||
"https://registry-1.docker.io",
|
||||
"http://registry-1.docker.io",
|
||||
"https://docker.io",
|
||||
"http://docker.io",
|
||||
"registry-1.docker.io",
|
||||
"docker.io",
|
||||
"https://",
|
||||
"http://",
|
||||
):
|
||||
if url.startswith(prefix):
|
||||
url = url[len(prefix) :]
|
||||
break
|
||||
url = url.lstrip("/")
|
||||
parts = url.split("/")
|
||||
namespace = parts[0] if parts and parts[0] else ""
|
||||
return namespace
|
||||
|
||||
def list_repositories(self) -> list[str]:
|
||||
if not self.namespace:
|
||||
raise ImageRegistryCatalogError(
|
||||
file=__file__,
|
||||
message="Docker Hub requires a namespace. Use --registry docker.io/{org_or_user}.",
|
||||
)
|
||||
self._hub_login()
|
||||
repositories: list[str] = []
|
||||
if self._hub_jwt:
|
||||
url = f"{_HUB_API}/v2/namespaces/{self.namespace}/repositories"
|
||||
else:
|
||||
url = f"{_HUB_API}/v2/repositories/{self.namespace}/"
|
||||
params: dict = {"page_size": 100}
|
||||
while url:
|
||||
resp = self._hub_request("GET", url, params=params)
|
||||
self._check_hub_response(resp, "repository listing")
|
||||
data = resp.json()
|
||||
for repo in data.get("results", []):
|
||||
name = repo.get("name", "")
|
||||
if name:
|
||||
repositories.append(f"{self.namespace}/{name}")
|
||||
url = data.get("next")
|
||||
params = {}
|
||||
return repositories
|
||||
|
||||
def list_tags(self, repository: str) -> list[str]:
|
||||
token = self._get_registry_token(repository)
|
||||
tags: list[str] = []
|
||||
url = f"{_REGISTRY_HOST}/v2/{repository}/tags/list"
|
||||
params: dict = {"n": 100}
|
||||
while url:
|
||||
resp = self._registry_request("GET", url, token, params=params)
|
||||
if resp.status_code in (401, 403):
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Authentication failed for tag listing of {repository} on Docker Hub. Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
f"Failed to list tags for {repository} (HTTP {resp.status_code}): {resp.text[:200]}"
|
||||
)
|
||||
break
|
||||
data = resp.json()
|
||||
tags.extend(data.get("tags", []) or [])
|
||||
url = self._next_tag_page_url(resp)
|
||||
params = {}
|
||||
return tags
|
||||
|
||||
def _hub_login(self) -> None:
|
||||
if self._hub_jwt:
|
||||
return
|
||||
if not self.username or not self.password:
|
||||
return
|
||||
logger.debug(f"Docker Hub login attempt for username: {self.username!r}")
|
||||
resp = self._request_with_retry(
|
||||
"POST",
|
||||
f"{_HUB_API}/v2/users/login",
|
||||
json={"username": self.username, "password": self.password},
|
||||
context_label="Docker Hub",
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
body_preview = resp.text[:200] if resp.text else "(empty body)"
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=(
|
||||
f"Docker Hub login failed (HTTP {resp.status_code}). "
|
||||
f"Check REGISTRY_USERNAME and REGISTRY_PASSWORD. "
|
||||
f"Response: {body_preview}"
|
||||
),
|
||||
)
|
||||
self._hub_jwt = resp.json().get("token")
|
||||
if not self._hub_jwt:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message="Docker Hub login returned an empty JWT token. Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
|
||||
def _get_registry_token(self, repository: str) -> str:
|
||||
if repository in self._registry_tokens:
|
||||
return self._registry_tokens[repository]
|
||||
params = {
|
||||
"service": "registry.docker.io",
|
||||
"scope": f"repository:{repository}:pull",
|
||||
}
|
||||
auth = None
|
||||
if self.username and self.password:
|
||||
auth = (self.username, self.password)
|
||||
resp = self._request_with_retry(
|
||||
"GET",
|
||||
_AUTH_URL,
|
||||
params=params,
|
||||
auth=auth,
|
||||
context_label="Docker Hub",
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Failed to obtain Docker Hub registry token for {repository} (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
token = resp.json().get("token", "")
|
||||
if not token:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Docker Hub registry token endpoint returned an empty token for {repository}. Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
self._registry_tokens[repository] = token
|
||||
return token
|
||||
|
||||
def _hub_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
||||
headers = kwargs.pop("headers", {})
|
||||
if self._hub_jwt:
|
||||
headers["Authorization"] = f"Bearer {self._hub_jwt}"
|
||||
kwargs["headers"] = headers
|
||||
return self._request_with_retry(
|
||||
method, url, context_label="Docker Hub", **kwargs
|
||||
)
|
||||
|
||||
def _registry_request(
|
||||
self, method: str, url: str, token: str, **kwargs
|
||||
) -> requests.Response:
|
||||
headers = kwargs.pop("headers", {})
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
kwargs["headers"] = headers
|
||||
return self._request_with_retry(
|
||||
method, url, context_label="Docker Hub", **kwargs
|
||||
)
|
||||
|
||||
def _check_hub_response(self, resp: requests.Response, context: str) -> None:
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
if resp.status_code in (401, 403):
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Authentication failed for {context} on Docker Hub (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD environment variables.",
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
raise ImageRegistryCatalogError(
|
||||
file=__file__,
|
||||
message=f"Namespace '{self.namespace}' not found on Docker Hub. Check the namespace in --registry docker.io/{{namespace}}.",
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Unexpected error during {context} on Docker Hub (HTTP {resp.status_code}): {resp.text[:200]}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _next_tag_page_url(resp: requests.Response) -> str | None:
|
||||
link_header = resp.headers.get("Link", "")
|
||||
if not link_header:
|
||||
return None
|
||||
match = re.search(r'<([^>]+)>;\s*rel="next"', link_header)
|
||||
if match:
|
||||
next_url = match.group(1)
|
||||
if next_url.startswith("/"):
|
||||
return f"{_REGISTRY_HOST}{next_url}"
|
||||
return next_url
|
||||
return None
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Factory for auto-detecting registry type and returning the appropriate adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from prowler.providers.image.lib.registry.base import RegistryAdapter
|
||||
from prowler.providers.image.lib.registry.dockerhub_adapter import DockerHubAdapter
|
||||
from prowler.providers.image.lib.registry.oci_adapter import OciRegistryAdapter
|
||||
|
||||
_DOCKER_HUB_PATTERN = re.compile(
|
||||
r"^(https?://)?(docker\.io|registry-1\.docker\.io)(/|$)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def create_registry_adapter(
|
||||
registry_url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
token: str | None = None,
|
||||
verify_ssl: bool = True,
|
||||
) -> RegistryAdapter:
|
||||
"""Auto-detect registry type from URL and return the appropriate adapter."""
|
||||
if _DOCKER_HUB_PATTERN.search(registry_url):
|
||||
return DockerHubAdapter(
|
||||
registry_url=registry_url,
|
||||
username=username,
|
||||
password=password,
|
||||
token=token,
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
# ECR and other non-Docker-Hub registries implement the OCI Distribution Spec,
|
||||
# so they are handled by the generic OCI adapter.
|
||||
return OciRegistryAdapter(
|
||||
registry_url=registry_url,
|
||||
username=username,
|
||||
password=password,
|
||||
token=token,
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Generic OCI Distribution Spec registry adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.image.exceptions.exceptions import (
|
||||
ImageRegistryAuthError,
|
||||
ImageRegistryCatalogError,
|
||||
ImageRegistryNetworkError,
|
||||
)
|
||||
from prowler.providers.image.lib.registry.base import RegistryAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import requests
|
||||
|
||||
|
||||
class OciRegistryAdapter(RegistryAdapter):
|
||||
"""Adapter for registries implementing OCI Distribution Spec."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
registry_url: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
token: str | None = None,
|
||||
verify_ssl: bool = True,
|
||||
) -> None:
|
||||
super().__init__(registry_url, username, password, token, verify_ssl)
|
||||
self._base_url = self._normalise_url(registry_url)
|
||||
self._bearer_token: str | None = None
|
||||
self._basic_auth_verified = False
|
||||
|
||||
@staticmethod
|
||||
def _normalise_url(url: str) -> str:
|
||||
url = url.rstrip("/")
|
||||
if not url.startswith(("http://", "https://")):
|
||||
url = f"https://{url}"
|
||||
return url
|
||||
|
||||
def list_repositories(self) -> list[str]:
|
||||
self._ensure_auth()
|
||||
repositories: list[str] = []
|
||||
url = f"{self._base_url}/v2/_catalog"
|
||||
params: dict = {"n": 200}
|
||||
while url:
|
||||
resp = self._authed_request("GET", url, params=params)
|
||||
if resp.status_code == 404:
|
||||
raise ImageRegistryCatalogError(
|
||||
file=__file__,
|
||||
message=f"Registry at {self.registry_url} does not support catalog listing (/_catalog returned 404). Use --image or --image-list instead.",
|
||||
)
|
||||
self._check_response(resp, "catalog listing")
|
||||
data = resp.json()
|
||||
repositories.extend(data.get("repositories", []))
|
||||
url = self._next_page_url(resp)
|
||||
params = {}
|
||||
return repositories
|
||||
|
||||
def list_tags(self, repository: str) -> list[str]:
|
||||
self._ensure_auth(repository=repository)
|
||||
tags: list[str] = []
|
||||
url = f"{self._base_url}/v2/{repository}/tags/list"
|
||||
params: dict = {"n": 200}
|
||||
while url:
|
||||
resp = self._authed_request("GET", url, params=params)
|
||||
self._check_response(resp, f"tag listing for {repository}")
|
||||
data = resp.json()
|
||||
tags.extend(data.get("tags", []) or [])
|
||||
url = self._next_page_url(resp)
|
||||
params = {}
|
||||
return tags
|
||||
|
||||
def _ensure_auth(self, repository: str | None = None) -> None:
|
||||
if self._bearer_token:
|
||||
return
|
||||
if self._basic_auth_verified:
|
||||
return
|
||||
if self.token:
|
||||
self._bearer_token = self.token
|
||||
return
|
||||
ping_url = f"{self._base_url}/v2/"
|
||||
resp = self._request_with_retry("GET", ping_url)
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
if resp.status_code == 401:
|
||||
www_auth = resp.headers.get("Www-Authenticate", "")
|
||||
|
||||
if not www_auth.lower().startswith("bearer"):
|
||||
# Basic auth challenge (e.g., AWS ECR)
|
||||
if self.username and self.password:
|
||||
self._basic_auth_verified = True
|
||||
return
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=(
|
||||
f"Registry {self.registry_url} requires authentication "
|
||||
f"but no credentials provided. "
|
||||
f"Set REGISTRY_USERNAME and REGISTRY_PASSWORD."
|
||||
),
|
||||
)
|
||||
|
||||
# Bearer token exchange (standard OCI flow)
|
||||
self._bearer_token = self._obtain_bearer_token(www_auth, repository)
|
||||
return
|
||||
if resp.status_code == 403:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Access denied to registry {self.registry_url} (HTTP 403). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Unexpected HTTP {resp.status_code} from registry {self.registry_url} during auth check.",
|
||||
)
|
||||
|
||||
def _obtain_bearer_token(
|
||||
self, www_authenticate: str, repository: str | None = None
|
||||
) -> str:
|
||||
match = re.search(r'realm="([^"]+)"', www_authenticate)
|
||||
if not match:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Cannot parse token endpoint from registry {self.registry_url}. Www-Authenticate: {www_authenticate[:200]}",
|
||||
)
|
||||
realm = match.group(1)
|
||||
self._validate_realm_url(realm)
|
||||
params: dict = {}
|
||||
service_match = re.search(r'service="([^"]+)"', www_authenticate)
|
||||
if service_match:
|
||||
params["service"] = service_match.group(1)
|
||||
scope_match = re.search(r'scope="([^"]+)"', www_authenticate)
|
||||
if scope_match:
|
||||
params["scope"] = scope_match.group(1)
|
||||
elif repository:
|
||||
params["scope"] = f"repository:{repository}:pull"
|
||||
auth = None
|
||||
if self.username and self.password:
|
||||
auth = (self.username, self.password)
|
||||
resp = self._request_with_retry("GET", realm, params=params, auth=auth)
|
||||
if resp.status_code != 200:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Failed to obtain bearer token from {realm} (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
data = resp.json()
|
||||
token = data.get("token") or data.get("access_token", "")
|
||||
if not token:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Token endpoint {realm} returned an empty token. Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _validate_realm_url(realm: str) -> None:
|
||||
parsed = urlparse(realm)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Bearer token realm has disallowed scheme: {parsed.scheme}. Only http/https are allowed.",
|
||||
)
|
||||
if parsed.scheme == "http":
|
||||
logger.warning(
|
||||
f"Bearer token realm uses HTTP (not HTTPS): {realm}"
|
||||
)
|
||||
hostname = parsed.hostname or ""
|
||||
try:
|
||||
addr = ipaddress.ip_address(hostname)
|
||||
if addr.is_private or addr.is_loopback or addr.is_link_local:
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Bearer token realm points to a private/loopback address: {hostname}. This may indicate an SSRF attempt.",
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def _resolve_basic_credentials(self) -> tuple[str | None, str | None]:
|
||||
"""Decode pre-encoded base64 auth tokens (e.g., from aws ecr get-authorization-token).
|
||||
|
||||
Returns (username, password) — decoded if the password is a base64 token
|
||||
containing 'username:real_password', otherwise returned as-is.
|
||||
"""
|
||||
if not self.password:
|
||||
return self.username, self.password
|
||||
try:
|
||||
decoded = base64.b64decode(self.password).decode("utf-8")
|
||||
if decoded.startswith(f"{self.username}:"):
|
||||
return self.username, decoded[len(self.username) + 1 :]
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
logger.debug("Password is not a base64-encoded auth token, using as-is")
|
||||
return self.username, self.password
|
||||
|
||||
def _authed_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
||||
resp = self._do_authed_request(method, url, **kwargs)
|
||||
if resp.status_code == 401 and self._bearer_token:
|
||||
logger.debug(
|
||||
f"Bearer token rejected (HTTP 401), re-authenticating to {self.registry_url}"
|
||||
)
|
||||
self._bearer_token = None
|
||||
self._ensure_auth()
|
||||
resp = self._do_authed_request(method, url, **kwargs)
|
||||
return resp
|
||||
|
||||
def _do_authed_request(self, method: str, url: str, **kwargs) -> requests.Response:
|
||||
headers = kwargs.pop("headers", {})
|
||||
if self._bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self._bearer_token}"
|
||||
elif self.username and self.password:
|
||||
user, pwd = self._resolve_basic_credentials()
|
||||
kwargs.setdefault("auth", (user, pwd))
|
||||
kwargs["headers"] = headers
|
||||
return self._request_with_retry(method, url, **kwargs)
|
||||
|
||||
def _check_response(self, resp: requests.Response, context: str) -> None:
|
||||
if resp.status_code == 200:
|
||||
return
|
||||
if resp.status_code in (401, 403):
|
||||
raise ImageRegistryAuthError(
|
||||
file=__file__,
|
||||
message=f"Authentication failed for {context} on {self.registry_url} (HTTP {resp.status_code}). Check REGISTRY_USERNAME and REGISTRY_PASSWORD.",
|
||||
)
|
||||
raise ImageRegistryNetworkError(
|
||||
file=__file__,
|
||||
message=f"Unexpected error during {context} on {self.registry_url} (HTTP {resp.status_code}): {resp.text[:200]}",
|
||||
)
|
||||
@@ -47,7 +47,6 @@ dependencies = [
|
||||
"cryptography==44.0.3",
|
||||
"dash==3.1.1",
|
||||
"dash-bootstrap-components==2.0.3",
|
||||
"defusedxml>=0.7.1",
|
||||
"detect-secrets==1.5.0",
|
||||
"dulwich==0.23.0",
|
||||
"google-api-python-client==2.163.0",
|
||||
|
||||
@@ -53,8 +53,6 @@ Reusable patterns for common technologies:
|
||||
| `nextjs-15` | App Router, Server Actions, streaming |
|
||||
| `tailwind-4` | cn() utility, Tailwind 4 patterns |
|
||||
| `playwright` | Page Object Model, selectors |
|
||||
| `vitest` | Unit testing, React Testing Library |
|
||||
| `tdd` | Test-Driven Development workflow |
|
||||
| `pytest` | Fixtures, mocking, markers |
|
||||
| `django-drf` | ViewSets, Serializers, Filters |
|
||||
| `zod-4` | Zod 4 API patterns |
|
||||
|
||||
@@ -1,371 +0,0 @@
|
||||
---
|
||||
name: tdd
|
||||
description: >
|
||||
Test-Driven Development workflow for ALL Prowler components (UI, SDK, API).
|
||||
Trigger: ALWAYS when implementing features, fixing bugs, or refactoring - regardless of component.
|
||||
This is a MANDATORY workflow, not optional.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
author: prowler-cloud
|
||||
version: "2.0"
|
||||
scope: [root, ui, api, prowler]
|
||||
auto_invoke:
|
||||
- "Implementing feature"
|
||||
- "Fixing bug"
|
||||
- "Refactoring code"
|
||||
- "Working on task"
|
||||
- "Modifying component"
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, Task
|
||||
---
|
||||
|
||||
## TDD Cycle (MANDATORY)
|
||||
|
||||
```
|
||||
+-----------------------------------------+
|
||||
| RED -> GREEN -> REFACTOR |
|
||||
| ^ | |
|
||||
| +------------------------+ |
|
||||
+-----------------------------------------+
|
||||
```
|
||||
|
||||
**The question is NOT "should I write tests?" but "what tests do I need?"**
|
||||
|
||||
---
|
||||
|
||||
## The Three Laws of TDD
|
||||
|
||||
1. **No production code** until you have a failing test
|
||||
2. **No more test** than necessary to fail
|
||||
3. **No more code** than necessary to pass
|
||||
|
||||
---
|
||||
|
||||
## Detect Your Stack
|
||||
|
||||
Before starting, identify which component you're working on:
|
||||
|
||||
| Working in | Stack | Runner | Test pattern | Details |
|
||||
|------------|-------|--------|-------------|---------|
|
||||
| `ui/` | TypeScript / React | Vitest + RTL | `*.test.{ts,tsx}` (co-located) | See `vitest` skill |
|
||||
| `prowler/` | Python | pytest + moto | `*_test.py` (suffix) in `tests/` | See `prowler-test-sdk` skill |
|
||||
| `api/` | Python / Django | pytest + django | `test_*.py` (prefix) in `api/src/backend/**/tests/` | See `prowler-test-api` skill |
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Assessment (ALWAYS FIRST)
|
||||
|
||||
Before writing ANY code:
|
||||
|
||||
### UI (`ui/`)
|
||||
|
||||
```bash
|
||||
# 1. Find existing tests
|
||||
fd "*.test.tsx" ui/components/feature/
|
||||
|
||||
# 2. Check coverage
|
||||
pnpm test:coverage -- components/feature/
|
||||
|
||||
# 3. Read existing tests
|
||||
```
|
||||
|
||||
### SDK (`prowler/`)
|
||||
|
||||
```bash
|
||||
# 1. Find existing tests
|
||||
fd "*_test.py" tests/providers/aws/services/ec2/
|
||||
|
||||
# 2. Run specific test
|
||||
poetry run pytest tests/providers/aws/services/ec2/ec2_ami_public/ -v
|
||||
|
||||
# 3. Read existing tests
|
||||
```
|
||||
|
||||
### API (`api/`)
|
||||
|
||||
```bash
|
||||
# 1. Find existing tests
|
||||
fd "test_*.py" api/src/backend/api/tests/
|
||||
|
||||
# 2. Run specific test
|
||||
poetry run pytest api/src/backend/api/tests/test_models.py -v
|
||||
|
||||
# 3. Read existing tests
|
||||
```
|
||||
|
||||
### Decision Tree (All Stacks)
|
||||
|
||||
```
|
||||
+------------------------------------------+
|
||||
| Does test file exist for this code? |
|
||||
+----------+-----------------------+-------+
|
||||
| NO | YES
|
||||
v v
|
||||
+------------------+ +------------------+
|
||||
| CREATE test file | | Check coverage |
|
||||
| -> Phase 1: RED | | for your change |
|
||||
+------------------+ +--------+---------+
|
||||
|
|
||||
+--------+--------+
|
||||
| Missing cases? |
|
||||
+---+---------+---+
|
||||
| YES | NO
|
||||
v v
|
||||
+-----------+ +-----------+
|
||||
| ADD tests | | Proceed |
|
||||
| Phase 1 | | Phase 2 |
|
||||
+-----------+ +-----------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: RED - Write Failing Tests
|
||||
|
||||
### For NEW Functionality
|
||||
|
||||
**UI (Vitest)**
|
||||
|
||||
```typescript
|
||||
describe("PriceCalculator", () => {
|
||||
it("should return 0 for quantities below threshold", () => {
|
||||
// Given
|
||||
const quantity = 3;
|
||||
|
||||
// When
|
||||
const result = calculateDiscount(quantity);
|
||||
|
||||
// Then
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**SDK (pytest)**
|
||||
|
||||
```python
|
||||
class Test_ec2_ami_public:
|
||||
@mock_aws
|
||||
def test_no_public_amis(self):
|
||||
# Given - No AMIs exist
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with mock.patch("prowler...ec2_service", new=EC2(aws_provider)):
|
||||
from prowler...ec2_ami_public import ec2_ami_public
|
||||
|
||||
# When
|
||||
check = ec2_ami_public()
|
||||
result = check.execute()
|
||||
|
||||
# Then
|
||||
assert len(result) == 0
|
||||
```
|
||||
|
||||
**API (pytest-django)**
|
||||
|
||||
```python
|
||||
@pytest.mark.django_db
|
||||
class TestResourceModel:
|
||||
def test_create_resource_with_tags(self, providers_fixture):
|
||||
# Given
|
||||
provider, *_ = providers_fixture
|
||||
tenant_id = provider.tenant_id
|
||||
|
||||
# When
|
||||
resource = Resource.objects.create(
|
||||
tenant_id=tenant_id, provider=provider,
|
||||
uid="arn:aws:ec2:us-east-1:123456789:instance/i-1234",
|
||||
name="test", region="us-east-1", service="ec2", type="instance",
|
||||
)
|
||||
|
||||
# Then
|
||||
assert resource.uid == "arn:aws:ec2:us-east-1:123456789:instance/i-1234"
|
||||
```
|
||||
|
||||
**Run -> MUST fail:** Test references code that doesn't exist yet.
|
||||
|
||||
### For BUG FIXES
|
||||
|
||||
Write a test that **reproduces the bug** first:
|
||||
|
||||
**UI:** `expect(() => render(<DatePicker value={null} />)).not.toThrow();`
|
||||
|
||||
**SDK:** `assert result[0].status == "FAIL" # Currently returns PASS incorrectly`
|
||||
|
||||
**API:** `assert response.status_code == 403 # Currently returns 200`
|
||||
|
||||
**Run -> Should FAIL (reproducing the bug)**
|
||||
|
||||
### For REFACTORING
|
||||
|
||||
Capture ALL current behavior BEFORE refactoring:
|
||||
|
||||
```
|
||||
# Any stack: run ALL existing tests, they should PASS
|
||||
# This is your safety net - if any fail after refactoring, you broke something
|
||||
```
|
||||
|
||||
**Run -> All should PASS (baseline)**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: GREEN - Minimum Code
|
||||
|
||||
Write the MINIMUM code to make the test pass. Hardcoding is valid for the first test.
|
||||
|
||||
**UI:**
|
||||
|
||||
```typescript
|
||||
// Test expects calculateDiscount(100, 10) === 10
|
||||
function calculateDiscount() {
|
||||
return 10; // FAKE IT - hardcoded is valid for first test
|
||||
}
|
||||
```
|
||||
|
||||
**Python (SDK/API):**
|
||||
|
||||
```python
|
||||
# Test expects check.execute() returns 0 results
|
||||
def execute(self):
|
||||
return [] # FAKE IT - hardcoded is valid for first test
|
||||
```
|
||||
|
||||
**This passes. But we're not done...**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Triangulation (CRITICAL)
|
||||
|
||||
**One test allows faking. Multiple tests FORCE real logic.**
|
||||
|
||||
Add tests with different inputs that break the hardcoded value:
|
||||
|
||||
| Scenario | Required? |
|
||||
|----------|-----------|
|
||||
| Happy path | YES |
|
||||
| Zero/empty values | YES |
|
||||
| Boundary values | YES |
|
||||
| Different valid inputs | YES (breaks fake) |
|
||||
| Error conditions | YES |
|
||||
|
||||
**UI:**
|
||||
|
||||
```typescript
|
||||
it("should calculate 10% discount", () => {
|
||||
expect(calculateDiscount(100, 10)).toBe(10);
|
||||
});
|
||||
|
||||
// ADD - breaks the fake:
|
||||
it("should calculate 15% on 200", () => {
|
||||
expect(calculateDiscount(200, 15)).toBe(30);
|
||||
});
|
||||
|
||||
it("should return 0 for 0% rate", () => {
|
||||
expect(calculateDiscount(100, 0)).toBe(0);
|
||||
});
|
||||
```
|
||||
|
||||
**Python:**
|
||||
|
||||
```python
|
||||
def test_single_public_ami(self):
|
||||
# Different input -> breaks hardcoded empty list
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
|
||||
def test_private_ami(self):
|
||||
assert result[0].status == "PASS"
|
||||
```
|
||||
|
||||
**Now fake BREAKS -> Real implementation required.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: REFACTOR
|
||||
|
||||
Tests GREEN -> Improve code quality WITHOUT changing behavior.
|
||||
|
||||
- Extract functions/methods
|
||||
- Improve naming
|
||||
- Add types/validation
|
||||
- Reduce duplication
|
||||
|
||||
**Run tests after EACH change -> Must stay GREEN**
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```
|
||||
+------------------------------------------------+
|
||||
| TDD WORKFLOW |
|
||||
+------------------------------------------------+
|
||||
| 0. ASSESS: What tests exist? What's missing? |
|
||||
| |
|
||||
| 1. RED: Write ONE failing test |
|
||||
| +-- Run -> Must fail with clear error |
|
||||
| |
|
||||
| 2. GREEN: Write MINIMUM code to pass |
|
||||
| +-- Fake It is valid for first test |
|
||||
| |
|
||||
| 3. TRIANGULATE: Add tests that break the fake |
|
||||
| +-- Different inputs, edge cases |
|
||||
| |
|
||||
| 4. REFACTOR: Improve with confidence |
|
||||
| +-- Tests stay green throughout |
|
||||
| |
|
||||
| 5. REPEAT: Next behavior/requirement |
|
||||
+------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (NEVER DO)
|
||||
|
||||
```
|
||||
# ANY language:
|
||||
|
||||
# 1. Code first, tests after
|
||||
def new_feature(): ... # Then writing tests = USELESS
|
||||
|
||||
# 2. Skip triangulation
|
||||
# Single test allows faking forever
|
||||
|
||||
# 3. Test implementation details
|
||||
assert component.state.is_loading == True # BAD - test behavior, not internals
|
||||
assert mock_service.call_count == 3 # BAD - brittle coupling
|
||||
|
||||
# 4. All tests at once before any code
|
||||
# Write ONE test, make it pass, THEN write the next
|
||||
|
||||
# 5. Giant test methods
|
||||
# Each test should verify ONE behavior
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commands by Stack
|
||||
|
||||
### UI (`ui/`)
|
||||
|
||||
```bash
|
||||
pnpm test # Watch mode
|
||||
pnpm test:run # Single run (CI)
|
||||
pnpm test:coverage # Coverage report
|
||||
pnpm test ComponentName # Filter by name
|
||||
```
|
||||
|
||||
### SDK (`prowler/`)
|
||||
|
||||
```bash
|
||||
poetry run pytest tests/path/ -v # Run specific tests
|
||||
poetry run pytest tests/path/ -v -k "test_name" # Filter by name
|
||||
poetry run pytest -n auto tests/ # Parallel run
|
||||
poetry run pytest --cov=./prowler tests/ # Coverage
|
||||
```
|
||||
|
||||
### API (`api/`)
|
||||
|
||||
```bash
|
||||
poetry run pytest -x --tb=short # Run all (stop on first fail)
|
||||
poetry run pytest api/src/backend/api/tests/test_file.py # Specific file
|
||||
poetry run pytest -k "test_name" -v # Filter by name
|
||||
```
|
||||
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user