Compare commits

...
Author SHA1 Message Date
Andoni A. cbf27b1e2c Merge remote-tracking branch 'origin/master' into feat/PROWLER-940-stage-2-a-image-provider-api-v2 2026-02-19 14:14:03 +01:00
Andoni A. 293a416e0d chore(image): remove test notebooks 2026-02-18 16:00:19 +01:00
Andoni A. 6be6f558c1 refactor(image): make image the resource instead of individual findings
- Add image_sha field to CheckReportImage from Trivy Metadata.ImageID
- Build resource_uid as "{image_name}:{short_sha}" in both CLI and API paths
- Set ServiceName to "container-image" for all image findings
- Pass clean image name as resource_name, Trivy target as resource_details
2026-02-18 14:47:37 +01:00
Andoni A. acbfdfa4b2 fix(image): address PR review items for image provider
- Remove dead docker login/pull/logout code and exceptions
- Fix finding metadata: empty RelatedUrl, Categories per finding type
- Replace SystemExit(0) with _listing_only flag in registry listing
- Use dynamic prowler_version in User-Agent header
- Normalize image UID by stripping protocol prefix on save
- Skip compliance and attack surface tasks for Image and IaC providers
- Add tests for scan_per_image and IMAGE scan path
2026-02-18 12:24:35 +01:00
Andoni A. 7bd13cdf39 fix(image): set custom User-Agent to avoid Cloudflare bot blocking
Cloudflare bot management on Docker Hub blocks the default
python-requests User-Agent from container IPs, returning HTTP 500.
Add a Prowler/1.0 User-Agent header to all registry requests and
include truncated response body in 5xx retry logs for diagnostics.
2026-02-18 11:08:33 +01:00
Andoni A. 34cbacc105 feat(image): add IMAGE scan path with per-image progress
- Add scan_per_image() generator to ImageProvider for per-image yields
- Replace single-shot IMAGE execution in Scan.scan() with per-image progress
- Extend checks_loader bypass to include IMAGE provider
2026-02-18 10:19:12 +01:00
Andoni A. 12f1a4f3bb fix(image): add 5xx retry and better diagnostics for Docker Hub auth
- Add server error retry with exponential backoff in _request_with_retry
- Include truncated response body in Docker Hub login errors
- Add debug logging for login attempts
2026-02-18 10:16:41 +01:00
Andoni A. 923ee82830 chore(api): bump Trivy to 0.69.1 2026-02-18 09:42:18 +01:00
Andoni A. a5e5217e60 fix(image): use Trivy native auth instead of docker login/pull
- Pass TRIVY_USERNAME/TRIVY_PASSWORD env vars for registry auth
- Remove docker login, docker pull, and docker logout dependencies
- Eliminates need for Docker binary inside the worker container
2026-02-18 09:24:43 +01:00
Andoni A. 707b3b484e chore(api): update poetry.lock for image provider SDK branch 2026-02-18 08:59:45 +01:00
Andoni A. 348808b37f feat(image): add image provider scan support to Scan class and API layer
- Add image provider handling in Scan class alongside IaC (skip check loading, run Trivy directly)
- Add image provider API models, serializers, and connection check logic
- Point API SDK dependency to this feature branch
- Add test notebook for image provider API workflow
2026-02-18 08:59:13 +01:00
Andoni A. 15557b9f6c test(image): add tests for SSRF, credential redaction, empty tokens, and DockerHub enumeration
- SSRF: reject file/ftp schemes, private/loopback/link-local IPs, accept public URLs
- Credential redaction: __getstate__ redacts password/token, properties still work
- Empty token guards: OCI bearer, DockerHub JWT, DockerHub registry token
- Narrow except: invalid UTF-8 base64 falls through correctly
- Fix namespace test to expect ImageRegistryCatalogError instead of AuthError
- Add registry_list_images=False to all Namespace objects missing it
- Add DockerHub enumeration integration test verifying repo:tag format
2026-02-17 11:40:31 +01:00
Andoni A. a692351f93 fix(image): harden registry adapter layer against SSRF, credential leaks, and edge cases
- Add SSRF protection for bearer token realm URLs (reject private/loopback IPs, non-http(s) schemes)
- Protect credentials from serialization via __getstate__ redaction and private attrs with properties
- Change missing DockerHub namespace exception from AuthError to CatalogError
- Add empty-token guards in OCI bearer flow and DockerHub JWT/registry token endpoints
- Narrow except Exception to except (ValueError, UnicodeDecodeError) in base64 decode
2026-02-17 11:25:38 +01:00
Andoni A. ed97fcd059 fix(image): add None guard, top-level import, and 401-retry to registry adapters
Add early return in _resolve_basic_credentials when password is None
to prevent TypeError on base64.b64decode. Move DockerHubAdapter import
from local scope to top-level in image_provider.py per PEP 8. Add
one-time 401 retry with token refresh in _authed_request to handle
per-repository-scoped bearer tokens on strict OCI registries.
2026-02-17 10:59:49 +01:00
Andoni A. b9060bebb0 style(image): apply black formatting to image provider files 2026-02-17 10:33:48 +01:00
Andoni A. e8ab331dff refactor(image): remove dead code from registry adapter layer
Remove unused detect_registry_type() function and _ECR_PATTERN constant
from factory.py, along with their corresponding tests. Move requests
import behind TYPE_CHECKING guard in oci_adapter.py and
dockerhub_adapter.py since it is only used for type annotations.
2026-02-17 10:17:46 +01:00
Andoni A. d9e451489d fix(image): harden registry adapter layer
- Fix shared dict mutation in ImageBaseException (thread-safety)
- Handle unexpected status codes in OCI adapter auth flow
- Force TLS verification for Docker Hub endpoints
- Add credential-safe __repr__ to RegistryAdapter
- Rename _next_page_url override to _next_tag_page_url in DockerHubAdapter
- Distinguish rate-limit from connection errors in retry logic
- Add type hints to all public adapter methods
- Remove unused imports in test files (flake8)
2026-02-17 10:01:35 +01:00
Andoni A. b00c76acf2 test(image): update mock patch targets after base class refactor
Update test patches from adapter-level to base-level module paths since
requests and time are now imported in base.py. Consolidate separate
requests.post/requests.get patches into requests.request since all HTTP
calls now go through _request_with_retry.
2026-02-17 09:41:57 +01:00
Andoni A. 46e55a3351 test(image): add --registry-list validation tests and factory comment
- Add test_registry_list_without_registry_fails: validates that
  --registry-list without --registry returns an error
- Add test_registry_list_with_registry_passes: validates the happy path
- Add clarifying comment in factory.py about ECR using OCI adapter
2026-02-17 09:39:52 +01:00
Andoni A. 103f71c215 fix(image): use correct singular/plural in registry listing output
Handle "1 repository" vs "2 repositories" and "1 image" vs "2 images"
in _print_registry_listing output.
2026-02-17 09:39:29 +01:00
Andoni A. 310a32e391 refactor(image): deduplicate retry logic into registry adapter base class
- Move _request_with_retry, _next_page_url, _MAX_RETRIES, _BACKOFF_BASE
  from OciRegistryAdapter and DockerHubAdapter into RegistryAdapter base
- Add context_label parameter to _request_with_retry for contextual
  error/warning messages (defaults to self.registry_url)
- Route _hub_login and _get_registry_token through retry logic
- Change 404 exception in _check_hub_response from ImageRegistryAuthError
  to ImageRegistryCatalogError (missing namespace is not an auth failure)
- Remove redundant time, requests imports from leaf adapters
2026-02-17 09:39:15 +01:00
Andoni Alonso 402b33516b Merge branch 'master' into feat/PROWLER-939-stage-2-registry-auth 2026-02-17 09:17:03 +01:00
Andoni A. 5e2ed6a37c docs(image): remove redundant version badges from subsections
All features ship in 5.19.0 with the provider; subsection badges are
unnecessary since the top-level Prowler CLI badge already covers it.
2026-02-17 09:10:06 +01:00
Andoni A. 8ef231faae docs(image): update docs and changelog for public Docker Hub support
- Add note about public namespace scanning without credentials to docs
- Update changelog entry to mention public Docker Hub support
2026-02-17 09:06:38 +01:00
Andoni A. 304fa25f25 feat(image): support public Docker Hub registry scan without credentials
- Use public /v2/repositories/{ns}/ endpoint when unauthenticated
- Fall back to namespace API when JWT is available for private repos
- Add test for unauthenticated public namespace listing
2026-02-17 08:43:01 +01:00
Andoni A. ffeaeb760e docs(image): document registry scan mode and --registry-list flag
Add Registry Scan Mode section covering --registry, --image-filter,
--tag-filter, --max-images, --registry-insecure, and the new
--registry-list flag for image discovery.
2026-02-17 08:02:41 +01:00
Andoni A. 876702fd22 feat(image): add --registry-list flag for image discovery
Add --registry-list flag that enumerates registry repositories and tags,
then prints a structured listing and exits without scanning. This helps
users discover available images before building --image-filter or
--tag-filter regexes.
2026-02-17 07:44:41 +01:00
Andoni A. 326def4a17 fix(image): handle Basic auth challenge and pre-encoded ECR credentials
- Detect Basic auth challenges (non-Bearer) in _ensure_auth()
- Decode base64-wrapped credentials from aws ecr get-authorization-token
- Add unit tests for credential resolution and Basic auth flow
2026-02-16 17:50:05 +01:00
Andoni A. aa284b8b24 test(image): add comprehensive registry scan mode unit tests
69 tests covering:
- Factory auto-detection (OCI, Docker Hub, ECR URL patterns)
- OCI adapter (auth flows, pagination, retry, error handling)
- Docker Hub adapter (namespace extraction, Hub API, token exchange)
- Argument validation (registry flags, Docker Hub namespace)
- Provider integration (enumeration, filtering, max-images, dedup)
2026-02-16 15:42:02 +01:00
Andoni A. ace36fe76e feat(image): add registry scan mode with OCI, Docker Hub, and ECR support
Add pluggable registry adapter pattern for enumerating and scanning all
container images in a private registry. Supports:
- Generic OCI registries (Harbor, GitLab, JFrog) via /v2/_catalog
- Docker Hub via Hub REST API with namespace-based enumeration
- AWS ECR via standard OCI endpoints with ECR auth token

New CLI flags: --registry, --image-filter, --tag-filter, --max-images,
--registry-insecure
2026-02-16 15:35:55 +01:00
Andoni A. 3a2960fd09 Merge remote-tracking branch 'origin/master' into feat/PROWLER-939-stage-2-registry-auth 2026-02-13 13:31:17 +01:00
Andoni A. 6b0a139361 Merge remote-tracking branch 'origin/master' into feat/PROWLER-939-stage-2-registry-auth
# Conflicts:
#	prowler/CHANGELOG.md
#	prowler/lib/check/models.py
#	prowler/providers/common/provider.py
#	prowler/providers/image/exceptions/exceptions.py
#	prowler/providers/image/image_provider.py
#	tests/providers/image/image_provider_test.py
2026-02-13 13:09:51 +01:00
Andoni A. 429f807f8c docs(image): document registry authentication methods
- Cover basic auth, token-based auth, and manual docker login
- Document auth method priority and automatic cleanup behavior
- Update troubleshooting section with environment variable references
2026-02-13 10:59:17 +01:00
Andoni A. 56535e5b24 fix(image): correct type annotation and remove dead exception handler
- Fix CheckReportImage metadata parameter type from dict to str
- Remove redundant re-raise of already-handled exceptions
2026-02-13 10:40:28 +01:00
Andoni A. 4af9bc99bf fix(image): use getattr for registry credentials to avoid AttributeError
- Use getattr with None defaults for registry auth arguments
- Credentials fall back to TRIVY_USERNAME/TRIVY_PASSWORD env vars in ImageProvider
2026-02-13 08:56:38 +01:00
Andoni A. 0589721505 fix(image): include missing merge changes from stage-1 and docs branches
The pre-commit hook stash/restore cycle during the first merge caused
some auto-merged changes to be excluded from the merge commits. This
commit includes the remaining changes: error code renumbering (9000→11000
range), input validation, image config scanners, and docs branch updates.
2026-02-12 22:38:11 +01:00
Andoni A. f93cbbdb38 Merge remote-tracking branch 'origin/feat/PROWLER-941-stage-2-b-image-provider-cli-docs' into feat/PROWLER-939-stage-2-registry-auth
# Conflicts:
#	prowler/CHANGELOG.md
#	skills/prowler-attack-paths-query/SKILL.md
#	ui/CHANGELOG.md
#	ui/components/scans/table/scans/scans-table-with-polling.tsx
2026-02-12 16:34:29 +01:00
Andoni A. 89d2a14468 Merge remote-tracking branch 'origin/feat/PROWLER-939-stage-1-image-provider-mvp-for-cli' into feat/PROWLER-939-stage-2-registry-auth
# Conflicts:
#	prowler/providers/image/exceptions/exceptions.py
#	prowler/providers/image/image_provider.py
#	tests/providers/image/image_provider_test.py
2026-02-12 15:57:55 +01:00
Andoni A. 51c5fda202 Merge remote-tracking branch 'origin/master' into feat/PROWLER-941-stage-2-b-image-provider-cli-docs 2026-02-12 12:04:02 +01:00
Andoni A. bfd8218032 fix(image): address PR review feedback
- Change exception error codes from 9000 to 11000 range to avoid Cloudflare conflict
- Add EXTERNAL_TOOL_PROVIDERS constant to replace hardcoded provider tuples
- Add resource_id field to CheckReportImage populated from Trivy finding IDs
- Use resource_id for resource_uid in finding output
- Join image names in summary table instead of showing count
- Clear exceptions __init__.py re-exports
2026-02-12 09:20:26 +01:00
Andoni A. e4268b9893 docs(image): restructure getting-started guide to match IaC pattern
Reorganize the image provider documentation to follow the same
structure as the IaC provider doc. Add "How It Works" section,
flatten hierarchy, fix undefined acronyms (OCI), add bold headers
to bullet points, use Tabs for install methods, remove redundant
sections, rename headers for SEO, and fix passive voice.
2026-02-09 16:17:56 +01:00
Andoni A. acb88bee8b docs: review with docs styleguide 2026-02-09 16:15:56 +01:00
Andoni A. 0ac647f22a docs(image): document --image-config-scanners option 2026-02-09 15:57:11 +01:00
Andoni A. e40b18eb99 Merge branch 'feat/PROWLER-939-stage-1-image-provider-mvp-for-cli' of https://github.com/prowler-cloud/prowler into feat/PROWLER-941-stage-2-b-image-provider-cli-docs 2026-02-09 15:56:10 +01:00
Andoni A. 43ad2f705c feat(image): add --image-config-scanners support for Trivy
- Add CLI argument for image config scanners (misconfig, secret)
- Add validation and ImageInvalidConfigScannerError exception
- Append --image-config-scanners to Trivy command when set
- Add tests for defaults, validation, and command construction
2026-02-09 15:55:15 +01:00
Andoni A. 15c7101e0b docs(image): update getting-started guide for input validation and v5.19.0
- Update version badge and prerequisite to 5.19.0
- Add image name validation requirements and OCI format reference
- Document image list file limits
- Add troubleshooting section for common Trivy errors
2026-02-09 15:40:40 +01:00
Andoni A. a328f69fed Merge branch 'feat/PROWLER-939-stage-1-image-provider-mvp-for-cli' of https://github.com/prowler-cloud/prowler into feat/PROWLER-941-stage-2-b-image-provider-cli-docs 2026-02-09 14:51:54 +01:00
Andoni A. b1ebefb26b chore(image): move changelog entry to 5.19.0 unreleased section 2026-02-09 14:50:24 +01:00
Andoni A. 9948e4fb81 Merge remote-tracking branch 'origin/master' into feat/PROWLER-939-stage-1-image-provider-mvp-for-cli 2026-02-09 14:48:51 +01:00
Andoni A. c1c1f07c20 Merge branch 'feat/PROWLER-939-stage-1-image-provider-mvp-for-cli' of https://github.com/prowler-cloud/prowler into feat/PROWLER-941-stage-2-b-image-provider-cli-docs 2026-02-09 14:48:41 +01:00
Andoni A. 132be2c728 fix(image): add input validation and file size limits
- Add IMAGE to Provider enum for runtime routing
- Validate image names against shell metacharacters and OCI format
- Enforce max line count and name length on image list files
- Add tests for name validation and file size limits
2026-02-09 14:43:11 +01:00
Andoni A. a3db22defc fix(image): remove redundant resource assignment in CheckReportImage
The parent class Check_Report.__init__ already sets self.resource
when the resource argument is a dict, making the assignment in
CheckReportImage.__init__ redundant. This resolves the CodeQL
"Overwriting attribute in super-class" alert.
2026-02-09 14:33:36 +01:00
Andoni A. 74ad1186cf fix(image): harden input validation and fix traceback edge case
- Add timeout, scanner, and severity input validation with custom exceptions
- Fix potential None dereference on error.__traceback__
- Modernize typing imports (List → list)
- Remove PoC label from CLI help text
- Update CHANGELOG entry with PR reference
2026-02-09 14:00:42 +01:00
Andoni A. cc1197d632 fix(image): add image provider to parser test usage string and mock
The hardcoded usage string and mock_get_available_providers in
parser_test.py did not include the image provider, causing 7 test
failures in CI.
2026-02-09 10:07:46 +01:00
Andoni A. ea7a93193e docs(image): add Image provider getting-started guide
- Add getting-started page with prerequisites, installation, and usage
- Register Image provider section in docs navigation
2026-02-06 15:22:11 +01:00
Andoni A. f7adbc2dcf feat(image): add docker login and pull for private registry authentication
Trivy's remote source cannot authenticate against Docker Hub (and some
other registries) even after docker login. This adds a docker login +
docker pull flow before scanning so Trivy can access private images
from the local Docker daemon.

- Add _docker_login, _docker_pull, _docker_logout, cleanup methods
- Add _extract_registry to determine registry from image reference
- Wrap run() in try/finally to ensure cleanup on success or error
- Wire registry credentials from CLI args to ImageProvider
- Add ImageDockerLoginError and ImageDockerNotFoundError exceptions
2026-02-06 15:08:42 +01:00
Andoni A. 41e972e088 chore(image): remove POC mention from CHANGELOG and drop provider README 2026-02-06 14:04:18 +01:00
Andoni A. 31d9a23225 feat(image): add container image provider for CLI scanning
Add a new Image provider that uses Trivy for container image vulnerability
and secret scanning, integrated into the Prowler CLI.

- ImageProvider class with Trivy integration for vuln/secret/misconfig scanning
- CLI support via `prowler image -I <image>` with severity filters, timeout,
  ignore-unfixed, and image-list-file options
- CheckReportImage model for image-specific findings
- Custom exceptions (9000-9005) with clear remediation messages
- Error handling for Trivy failures (non-zero exit, binary not found)
- Batch processing of findings with progress bar
- test_connection() for registry accessibility checks
- Comprehensive test coverage
2026-02-06 13:59:48 +01:00
169 changed files with 6031 additions and 7753 deletions

No files matched your search

-50
View File
@@ -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
+1 -14
View File
@@ -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` |
---
-5
View File
@@ -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
View File
@@ -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
View File
@@ -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
+366 -12
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -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),
]
+26 -7
View File
@@ -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
View File
@@ -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
+147
View File
@@ -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"
+110 -125
View File
@@ -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",
+44 -4
View File
@@ -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).",
},
},
},
]
}
)
+33 -1
View File
@@ -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()
+2 -63
View File
@@ -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"
}
)
+7
View File
@@ -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"
+17 -40
View File
@@ -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)
+4
View File
@@ -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),
+2 -118
View File
@@ -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
-474
View File
@@ -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"{'&nbsp;&nbsp;|&nbsp;&nbsp;'.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 = "&nbsp;&nbsp;|&nbsp;&nbsp;".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
+39 -22
View File
@@ -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
+7 -108
View File
@@ -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
-8
View File
@@ -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"
-8
View File
@@ -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"
+11 -5
View File
@@ -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)
![GitHub Personal Access Token Permissions](/images/providers/github-pat-permissions.png)
| 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
![GitHub Fine-Grained Token Permissions](/images/providers/github-pat-permissions.png)
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"
![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click **Add Cloud Provider**
3. Click "Add Cloud Provider"
![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png)
4. Select **GitHub**
4. Select "GitHub"
![Select GitHub](/images/providers/select-github.png)
### 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"
![Add GitHub Account ID](/images/providers/add-github-account-id.png)
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:
![Select auth method](/images/providers/select-auth-method.png)
7. Configure the authentication method:
<Tabs>
<Tab title="Personal Access Token (Recommended)">
<Tab title="Personal Access Token">
![Configure Personal Access Token](/images/providers/auth-pat.png)
**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">
![Configure OAuth App Token](/images/providers/auth-oauth.png)
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">
![Configure GitHub App](/images/providers/auth-github-app.png)
<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
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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
+2
View File
@@ -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,
+5 -3
View File
@@ -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(
+71 -8
View File
@@ -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
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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": ""
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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": ""
@@ -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": [],
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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": ""
@@ -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": ""
@@ -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": ""
@@ -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": ""
@@ -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."
+13
View File
@@ -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(
@@ -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
)
+294 -18
View File
@@ -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]}",
)
-1
View File
@@ -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",
-2
View File
@@ -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 |
-371
View File
@@ -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