Compare commits

..

37 Commits

Author SHA1 Message Date
renovate[bot] e3100be8bd chore(ci): update github-actions 2026-06-10 16:29:20 +00:00
Pedro Martín 3c8fde25ee chore(cli): add banner about Prowler Cloud (#11528) 2026-06-10 18:19:50 +02:00
Aryan Bhaskar ec0bb53839 feat(bedrock): add bedrock_agent_role_least_privilege check (#11335)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-10 12:40:54 +02:00
Pedro Martín bfb3fcea4c fix(e2e): use branch SDK changes to create the container (#11522) 2026-06-10 11:34:35 +02:00
Pedro Martín 61cd4aea3f feat(compliance): add Okta IDaaS STIG V1R2 framework (#11428)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-10 11:22:42 +02:00
StylusFrost 01b49f0743 feat(dashboard): render dynamic-provider compliance frameworks (#11503)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-06-10 11:16:39 +02:00
Pedro Martín 4a5a49b5bb fix(api): store and refresh Resource.name on every scan (#11476)
Co-authored-by: Josema Camacho <josema@prowler.com>
2026-06-10 10:55:31 +02:00
Alan Buscaglia a21cb64a94 fix(ui): extend integration poll timeouts to 60s (#11519) 2026-06-10 10:34:50 +02:00
Hugo Pereira Brito 9a50dffaa0 feat(gcp): split kms_key_rotation_enabled into enabled and max-90-days checks (#11516) 2026-06-09 16:52:49 +02:00
Jasmine e710ebff1c feat(m365): add exchange_mailbox_primary_smtp_custom_domain check (#11215)
Co-authored-by: Jasmine Sullivan <20147180@tafe.wa.edu.au>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-09 16:24:25 +02:00
Hugo Pereira Brito b3caee88e4 fix(m365): skip future hires in MFA capable check (#11511) 2026-06-09 15:42:06 +02:00
Hugo Pereira Brito d9f90e50b8 fix(m365): paginate admincenter group enumeration (#11510) 2026-06-09 15:23:35 +02:00
Alan Buscaglia 58efb719fa docs(skills): correct setup symlink paths in README (#11514) 2026-06-09 14:41:18 +02:00
Alan Buscaglia 355b7071aa docs: add skills installation and usage guide (#11513) 2026-06-09 14:41:13 +02:00
Pepe Fagoaga b994b0b14e chore(ui): rename customer support to support desk (#11508) 2026-06-09 13:53:21 +02:00
StylusFrost 6c559fbb8d feat(sdk): discover external universal compliance frameworks via entry points (#11490) 2026-06-09 13:45:34 +02:00
César Arroba b2d74711d9 chore(deps): bump dulwich to 1.2.5 and pyjwt to 2.13.0 for osv-scanner (#11499) 2026-06-09 13:01:46 +02:00
Ashishraymajhi 7e60e8f8da feat(m365): add entra_service_prinicipal_privileged_role_no_owners_check (#11189)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-09 11:29:03 +02:00
Hugo Pereira Brito 62955dd16b feat(okta): add authenticator STIG checks (#11465)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-09 10:17:23 +02:00
Adrián Peña 1f7caa6394 feat(api): make orphan-task recovery configurable and drop the Jira idempotency table (#11472) 2026-06-09 09:16:48 +02:00
Pepe Fagoaga 662e7e9e18 chore(changelog): prepare for v5.29.3 (#11505) 2026-06-09 08:13:12 +02:00
StylusFrost e3013d9918 feat(sdk): Dynamic provider loading and compliance framework (#10700)
Co-authored-by: Pedro Martín <pedromarting3@gmail.com>
2026-06-08 17:47:22 +02:00
Hugo Pereira Brito 0ea2f6d67e feat(okta): add API token STIG checks (#11464)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-08 17:11:54 +02:00
Hugo Pereira Brito 7692a1d76a feat(okta): add network zone STIG check (#11463)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-08 16:51:58 +02:00
Aline Almeida 1c9afc714e fix(gcp): honour org-aggregated sinks in metric-filter checks (#11488)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-08 16:46:48 +02:00
Daniel Barranquero 466f1a3d73 feat(okta): add user, systemlog, and idp services with DISA STIG checks (#11496)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-06-08 14:59:50 +02:00
César Arroba 061fbaa7bb feat(api): label Postgres connections with application_name per component and alias (#11494) 2026-06-08 13:45:06 +02:00
Josema Camacho 28b045302f fix(api): create Neo4j driver lazily so an outage can't block API startup (#11491) 2026-06-08 13:30:18 +02:00
Alejandro Bailo 5a2226c02c fix(ui): preserve active tab styling with tooltips (#11493) 2026-06-08 11:54:51 +02:00
potato-20 6f172a5c19 feat(elbv2): add elbv2_alb_drop_invalid_header_fields_enabled check (FSBP ELB.4) (#11471)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-05 14:26:07 +02:00
Pedro Martín a7d180ea5b feat(dashboard): add AWS AI Security Framework compliance view (#11475) 2026-06-05 13:28:31 +02:00
Pedro Martín d4bbc8b5ad fix(jira): avoid 400 INVALID_INPUT on findings with empty field (#11474) 2026-06-05 13:26:28 +02:00
Aline Almeida a5bc226f11 fix(gcp): pass iam_service_account_unused for disabled service accounts (#11467) 2026-06-05 12:07:30 +02:00
Pablo Fernandez Guerra (PFE) 3a3d9d6146 chore(ui): type process.env via ambient NodeJS.ProcessEnv (#11328)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
2026-06-05 08:31:16 +02:00
Oleksandr_Sanin bcd282d3d0 fix(gcp): honour org-level aggregated sinks in logging_sink_created check (#11355)
Signed-off-by: Oleksandr Sanin <alexaaander.sanin@gmail.com>
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-06-04 12:07:01 +02:00
Pedro Martín eb7949c884 fix(ui): show delete user action only for the current user (#11447)
Co-authored-by: Pepe Fagoaga <pepe@prowler.com>
2026-06-03 17:03:12 +02:00
Alejandro Bailo e60a4462e5 fix(ui): refine add-provider wizard flow between scans and providers (#11424) 2026-06-03 16:08:06 +02:00
356 changed files with 22073 additions and 2760 deletions
@@ -150,7 +150,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push API container for ${{ matrix.arch }}
id: container-push
@@ -215,7 +215,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build container
if: steps.check-changes.outputs.any_changed == 'true'
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
services:
postgres:
image: postgres:17@sha256:2cd82735a36356842d5eb1ef80db3ae8f1154172f0f653db48fde079b2a0b7f7
image: postgres:17@sha256:0027bef26712baaee437a4ea48fdf3d2d2e2bc5f0d81615374408ca320f3c7e3
env:
POSTGRES_HOST: ${{ env.POSTGRES_HOST }}
POSTGRES_PORT: ${{ env.POSTGRES_PORT }}
@@ -63,7 +63,7 @@ jobs:
--health-timeout 5s
--health-retries 5
valkey:
image: valkey/valkey:7-alpine3.19
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
env:
VALKEY_HOST: ${{ env.VALKEY_HOST }}
VALKEY_PORT: ${{ env.VALKEY_PORT }}
@@ -132,7 +132,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push MCP container for ${{ matrix.arch }}
id: container-push
@@ -206,7 +206,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build MCP container
if: steps.check-changes.outputs.any_changed == 'true'
@@ -58,7 +58,7 @@ jobs:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build ${{ matrix.component }} container (linux/arm64)
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
@@ -183,7 +183,7 @@ jobs:
AWS_REGION: ${{ env.AWS_REGION }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push SDK container for ${{ matrix.arch }}
id: container-push
@@ -299,7 +299,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -1
View File
@@ -134,7 +134,7 @@ jobs:
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build SDK container
if: steps.check-changes.outputs.any_changed == 'true'
+28 -1
View File
@@ -540,7 +540,7 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-vercel
files: ./vercel_coverage.xml
# Scaleway Provider
- name: Check if Scaleway files changed
if: steps.check-changes.outputs.any_changed == 'true'
@@ -588,7 +588,34 @@ jobs:
with:
flags: prowler-py${{ matrix.python-version }}-stackit
files: ./stackit_coverage.xml
# External Provider (dynamic loading)
- name: Check if External Provider files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-external
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
files: |
./prowler/providers/common/**
./prowler/config/**
./prowler/lib/**
./tests/providers/external/**
./uv.lock
- name: Run External Provider tests
if: steps.changed-external.outputs.any_changed == 'true'
run: uv run pytest -n auto --cov=./prowler/providers/common --cov=./prowler/config --cov=./prowler/lib --cov-report=xml:external_coverage.xml tests/providers/external
- name: Upload External Provider coverage to Codecov
if: steps.changed-external.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-external
files: ./external_coverage.xml
# Lib
- name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true'
+1 -1
View File
@@ -73,7 +73,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
python-version: '3.12.13'
- name: Install PyYAML
run: pip install pyyaml
@@ -136,7 +136,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build and push UI container for ${{ matrix.arch }}
id: container-push
@@ -205,7 +205,7 @@ jobs:
- name: Install regctl
if: always()
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
uses: regclient/actions/regctl-installer@14f9d37db17b5dc41fefd1ffdd1af4b9e2490560 # main
- name: Cleanup intermediate architecture tags
if: always()
+1 -1
View File
@@ -110,7 +110,7 @@ jobs:
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build UI container
if: steps.check-changes.outputs.any_changed == 'true'
+11 -1
View File
@@ -134,7 +134,17 @@ jobs:
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
# which lags behind PR changes; build locally so E2E exercises the API image
# produced by this PR.
run: docker build -t prowlercloud/prowler-api:latest ./api
#
# The image installs the SDK from git@master (api/uv.lock), so a PR changing BOTH the SDK
# and the API would run against the OLD SDK and crash on startup. Overlay the checkout's
# SDK source so both run together. New SDK dependencies still need an api/uv.lock bump.
run: |
docker build -t prowlercloud/prowler-api:pr-base ./api
docker build -t prowlercloud/prowler-api:latest -f - prowler <<'DOCKERFILE'
FROM prowlercloud/prowler-api:pr-base
RUN rm -rf /home/prowler/.venv/lib/python3.12/site-packages/prowler
COPY --chown=prowler:prowler . /home/prowler/.venv/lib/python3.12/site-packages/prowler
DOCKERFILE
- name: Start API services
run: |
+16 -3
View File
@@ -6,18 +6,31 @@ All notable changes to the **Prowler API** are documented in this file.
### 🚀 Added
- Automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: stuck scan and summary tasks are detected and re-run instead of staying pending forever, with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Jira integration no longer creates duplicate issues on a retried send; findings already ticketed are skipped [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Opt-in automatic recovery of allowlisted idempotent background tasks whose worker died during a deploy or crash: when enabled via `DJANGO_TASK_RECOVERY_ENABLED` (off by default), stuck summary and deletion tasks are detected and re-run instead of staying pending forever (scan and Jira tasks are excluded), with a `reconcile_orphan_tasks` management command for on-demand recovery [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- DORA compliance framework support [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Label Postgres connections with `application_name="<component>:<alias>"` (component injected per process via `DJANGO_APP_COMPONENT`) so connections are attributable by component in `pg_stat_activity` [(#11494)](https://github.com/prowler-cloud/prowler/pull/11494)
- DISA Okta IDaaS STIG V1R2 compliance framework export support for the Okta provider [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
### 🔄 Changed
- Allowlisted idempotent background tasks are no longer lost when a worker is stopped or crashes mid-task; tasks with external side effects are marked terminal instead of blindly re-running [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- A recovered scan rewrites its findings, summaries, attack surface, and compliance data instead of appending to the previous run, so recovery never leaves stale or duplicate materialized rows [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
### 🐞 Fixed
- Workers now shut down gracefully on deploy or restart, finishing or re-queueing in-flight tasks instead of being force-killed and leaving them stuck [(#11416)](https://github.com/prowler-cloud/prowler/pull/11416)
- Resource `name` is now stored and refreshed on every scan, so resources no longer keep an empty name [(#11476)](https://github.com/prowler-cloud/prowler/pull/11476)
### 🔐 Security
- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner in `api/uv.lock` [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
---
## [1.30.3] (Prowler v5.29.3)
### 🐞 Fixed
- API startup no longer crashes when Neo4j is unreachable, as the Neo4j driver now connects lazily on first use rather than during app initialization [(#11491)](https://github.com/prowler-cloud/prowler/pull/11491)
---
+9
View File
@@ -68,6 +68,15 @@ manage_db_partitions() {
fi
}
# Identify this process to Postgres (application_name=<component>:<alias>) so
# connections are attributable by component in pg_stat_activity. Web tiers
# report "api"; everything else uses the launch subcommand.
case "$1" in
prod|dev) DJANGO_APP_COMPONENT="api" ;;
*) DJANGO_APP_COMPONENT="$1" ;;
esac
export DJANGO_APP_COMPONENT
case "$1" in
dev)
apply_migrations
+37 -18
View File
@@ -1,10 +1,11 @@
# Orphan Celery task recovery
When a worker is terminated mid-task (a deploy, an OOM kill, a node eviction), the
task it was running can be left non-terminal forever: the `Scan` stays `EXECUTING`,
the `TaskResult` stays `STARTED`, and nothing re-runs it. This page describes the
mechanisms that detect and recover allowlisted idempotent orphans so users never
see a stuck scan and pending-task alerts do not fire.
task it was running can be left non-terminal forever: the `TaskResult` stays
`STARTED` and nothing re-runs it. This page describes the mechanisms that detect and
recover allowlisted idempotent orphans so pending-task alerts do not fire. Scan tasks
are not auto-recovered (re-running a scan is not safe to do automatically); the
watchdog covers the summary/aggregation and deletion tasks.
## How recovery works
@@ -13,29 +14,35 @@ see a stuck scan and pending-task alerts do not fire.
(`worker_prefetch_multiplier = 1`), and an abruptly-lost worker re-queues its task
(`task_reject_on_worker_lost`). On `SIGTERM` the worker is given a soft-shutdown
window (`worker_soft_shutdown_timeout`) to finish or re-queue in-flight work
before it is force-killed.
before it is force-killed. `scan-perform`, `scan-perform-scheduled` and
`integration-jira` opt out of redelivery with `acks_late=False`, so a crash drops
them rather than re-running and duplicating findings or Jira issues. Other
non-recovered side-effect tasks keep `acks_late=True`, so the broker can still
re-deliver them after a worker loss: the S3 upload rebuilds from worker-local files
that did not survive the crash and so no-ops, but Security Hub re-reads findings from
the DB and re-sends them to AWS.
2. **Periodic watchdog.** A Beat task, `reconcile-orphan-tasks`, runs every couple of
minutes (a `django_celery_beat` periodic task created by migration). For each
in-flight task result with an allowlisted idempotent task name, it pings the
worker recorded on the task's `TaskResult`:
- worker responds -> the task is still running, leave it alone;
- worker is gone (and the scan started before a short grace window) -> it is a
- worker is gone (and the task started before a short grace window) -> it is a
real orphan: the stale task is revoked and marked terminal (clearing the
pending/started alert), and the scan is re-enqueued from scratch.
pending/started alert), and the task is re-enqueued from its stored name and
kwargs.
The re-run is safe because only tasks with proven idempotency are allowlisted.
Scan persistence, for example, clears the scan's prior findings and materialized
summary/compliance rows before re-writing them. Jira sends are allowlisted too:
each finding is reserved in a dispatch table before the external call, so a re-run
skips already-ticketed findings (the worst case is one finding missed if a worker
is hard-killed mid-send, never a duplicate issue). Other external side effects stay
terminal: the S3 upload rebuilds from worker-local files that do not survive a
crash, and report/Security Hub recovery is out of scope.
The re-run is safe because only tasks with proven idempotency are allowlisted: the
summary/aggregation tasks clear and re-write their own rows, and deletions are
idempotent. Scan tasks and external side effects are excluded: re-running a scan is
not safe to do automatically, Jira sends would create duplicate issues, the S3
upload rebuilds from worker-local files that do not survive a crash, and
report/Security Hub recovery is out of scope.
3. **Recovery cap.** Each automatic re-enqueue increments `Scan.recovery_count`.
After `--max-attempts` recoveries (default 3) the scan is marked `FAILED` instead
of re-enqueued, so a task that repeatedly kills its worker cannot loop forever.
3. **Recovery cap.** A per-task Valkey counter limits how often the same task is
re-enqueued. After `--max-attempts` recoveries (default 3) the orphan is marked
terminal instead of re-enqueued, so a task that repeatedly kills its worker cannot
loop forever.
A Postgres advisory lock ensures that, even with multiple API/worker replicas, only
one reconciliation runs at a time; the others no-op.
@@ -63,6 +70,18 @@ All settings have safe defaults; override via environment variables.
| `DJANGO_CELERY_TASK_SOFT_TIME_LIMIT` | hard - 600 | Soft limit; raises `SoftTimeLimitExceeded` for cleanup. |
| `DJANGO_CELERY_LONG_TASK_TIME_LIMIT` | `172800` (48h) | Hard limit for scans and provider/tenant deletions, which can legitimately run for more than a day. |
| `DJANGO_CELERY_LONG_TASK_SOFT_TIME_LIMIT` | long hard - 600 | Soft limit for the long-running tasks above. |
| `DJANGO_TASK_RECOVERY_ENABLED` | `false` | Master switch for orphan-task recovery, disabled by default (opt-in); set to `true` to enable. When off, no orphan is detected, marked terminal, or re-enqueued (attack-paths stale cleanup still runs). |
| `DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED` | `true` | Auto re-enqueue orphaned scan summary/aggregation tasks. |
| `DJANGO_TASK_RECOVERY_DELETIONS_ENABLED` | `true` | Auto re-enqueue orphaned provider/tenant deletion tasks. |
Recovery is opt-in: with the master flag off (the default) the sweep does nothing.
Once enabled, the per-group flags default to on, so every group recovers unless you
turn one off; a task whose group flag is off is marked terminal instead of
re-enqueued.
Turning recovery off only disables this watchdog sweep; it does not change Celery's
broker-level redelivery (`task_acks_late`/`task_reject_on_worker_lost`), which still
re-delivers tasks that keep `acks_late=True` on worker loss, independently of this flag.
`task_acks_late` and `task_reject_on_worker_lost` are enabled in `config/celery.py`.
+13 -3
View File
@@ -226,7 +226,7 @@ constraint-dependencies = [
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"dulwich==0.23.0",
"dulwich==1.2.5",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
@@ -354,7 +354,7 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.12.1",
"pyjwt==2.13.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
@@ -443,7 +443,17 @@ constraint-dependencies = [
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
#
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# against the SDK's hard pins, so override them to the patched versions until the SDK
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
"microsoft-kiota-abstractions==1.9.9"
"microsoft-kiota-abstractions==1.9.9",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
]
+6 -34
View File
@@ -1,12 +1,14 @@
import logging
import os
import sys
from pathlib import Path
from django.apps import AppConfig
from django.conf import settings
from config.custom_logging import BackendLogger
from config.env import env
from django.apps import AppConfig
from django.conf import settings
logger = logging.getLogger(BackendLogger.API)
@@ -30,7 +32,6 @@ class ApiConfig(AppConfig):
def ready(self):
from api import schema_extensions # noqa: F401
from api import signals # noqa: F401
from api.attack_paths import database as graph_database
# Generate required cryptographic keys if not present, but only if:
# `"manage.py" not in sys.argv[0]`: If an external server (e.g., Gunicorn) is running the app
@@ -41,37 +42,8 @@ class ApiConfig(AppConfig):
):
self._ensure_crypto_keys()
# Commands that don't need Neo4j
SKIP_NEO4J_DJANGO_COMMANDS = [
"makemigrations",
"migrate",
"pgpartition",
"check",
"help",
"showmigrations",
"check_and_fix_socialaccount_sites_migration",
]
# Skip eager Neo4j init for tests, some Django commands, and Celery (prefork pool: driver must stay lazy, no post_fork hook)
if getattr(settings, "TESTING", False) or (
len(sys.argv) > 1
and (
(
"manage.py" in sys.argv[0]
and sys.argv[1] in SKIP_NEO4J_DJANGO_COMMANDS
)
or "celery" in sys.argv[0]
)
):
logger.info(
"Skipping eager Neo4j init: tests, some Django commands, or Celery prefork pool (driver stays lazy)"
)
else:
graph_database.init_driver()
# Neo4j driver is initialized at API startup (see api.attack_paths.database)
# It remains lazy for Celery workers and selected Django commands
# Neo4j driver is created lazily on first use (see api.attack_paths.database).
# App init never contacts Neo4j, so a Neo4j outage cannot block API startup.
def _ensure_crypto_keys(self):
"""
+18 -4
View File
@@ -1,22 +1,24 @@
import atexit
import logging
import threading
from contextlib import contextmanager
from typing import Any, Iterator
from uuid import UUID
import neo4j
import neo4j.exceptions
from config.env import env
from django.conf import settings
from api.attack_paths.retryable_session import RetryableSession
from tasks.jobs.attack_paths.config import (
BATCH_SIZE,
PROVIDER_RESOURCE_LABEL,
get_provider_label,
)
from api.attack_paths.retryable_session import RetryableSession
# Without this Celery goes crazy with Neo4j logging
logging.getLogger("neo4j").setLevel(logging.ERROR)
logging.getLogger("neo4j").propagate = False
@@ -28,6 +30,9 @@ READ_QUERY_TIMEOUT_SECONDS = env.int(
"ATTACK_PATHS_READ_QUERY_TIMEOUT_SECONDS", default=30
)
MAX_CUSTOM_QUERY_NODES = env.int("ATTACK_PATHS_MAX_CUSTOM_QUERY_NODES", default=250)
# Shorter than CONN_ACQUISITION_TIMEOUT — the driver requires acquisition to be
# the longer of the two (it may include opening a new connection).
CONNECTION_TIMEOUT = env.int("NEO4J_CONNECTION_TIMEOUT", default=5)
CONN_ACQUISITION_TIMEOUT = env.int("NEO4J_CONN_ACQUISITION_TIMEOUT", default=15)
READ_EXCEPTION_CODES = [
"Neo.ClientError.Statement.AccessMode",
@@ -58,15 +63,24 @@ def init_driver() -> neo4j.Driver:
uri = get_uri()
config = settings.DATABASES["neo4j"]
_driver = neo4j.GraphDatabase.driver(
driver = neo4j.GraphDatabase.driver(
uri,
auth=(config["USER"], config["PASSWORD"]),
keep_alive=True,
max_connection_lifetime=7200,
connection_timeout=CONNECTION_TIMEOUT,
connection_acquisition_timeout=CONN_ACQUISITION_TIMEOUT,
max_connection_pool_size=50,
)
_driver.verify_connectivity()
# Publish the singleton only after connectivity is verified so a
# failed probe does not leave an unverified driver behind. Close the
# driver on failure so a repeatedly-probed outage cannot leak pools.
try:
driver.verify_connectivity()
except Exception:
driver.close()
raise
_driver = driver
# Register cleanup handler (only runs once since we're inside the _driver is None block)
atexit.register(close_driver)
@@ -20,7 +20,7 @@ class Command(BaseCommand):
"--max-attempts",
type=int,
default=3,
help="Give up re-running a task after this many recovery attempts (scans are marked FAILED).",
help="Give up re-running a task after this many recovery attempts; it is then left terminal instead of re-enqueued.",
)
parser.add_argument(
"--dry-run",
@@ -39,6 +39,16 @@ class Command(BaseCommand):
self.stdout.write("Reconcile skipped: another run holds the lock.")
return
if result.get("enabled") is False:
message = (
"Task recovery is disabled (DJANGO_TASK_RECOVERY_ENABLED is off); "
"no orphans were recovered."
)
if result.get("attack_paths") is not None:
message += " Attack-paths stale cleanup still ran."
self.stdout.write(message)
return
self.stdout.write(
self.style.SUCCESS(
"Orphan reconcile complete: "
@@ -1,17 +0,0 @@
# Generated by Django 5.1.15 on 2026-05-30 17:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0093_okta_provider"),
]
operations = [
migrations.AddField(
model_name="scan",
name="recovery_count",
field=models.IntegerField(default=0),
),
]
@@ -40,7 +40,7 @@ def delete_periodic_task(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("api", "0094_scan_recovery_count"),
("api", "0093_okta_provider"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
@@ -1,64 +0,0 @@
import uuid
import django.db.models.deletion
from django.db import migrations, models
import api.rls
class Migration(migrations.Migration):
dependencies = [
("api", "0095_reconcile_orphan_tasks_periodic_task"),
]
operations = [
migrations.CreateModel(
name="JiraIssueDispatch",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
("finding_id", models.UUIDField()),
(
"integration",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="jira_dispatches",
to="api.integration",
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "jira_issue_dispatches",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="jiraissuedispatch",
constraint=models.UniqueConstraint(
fields=("tenant_id", "integration_id", "finding_id"),
name="unique_jira_issue_dispatch",
),
),
migrations.AddConstraint(
model_name="jiraissuedispatch",
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_jiraissuedispatch",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
-32
View File
@@ -666,9 +666,6 @@ class Scan(RowLevelSecurityProtectedModel):
state = StateEnumField(choices=StateChoices.choices, default=StateChoices.AVAILABLE)
unique_resource_count = models.IntegerField(default=0)
progress = models.IntegerField(default=0)
# Incremented by the scan-specific orphan-recovery path each time this scan is
# re-pointed to a fresh task; for observability (the retry cap is a Valkey counter).
recovery_count = models.IntegerField(default=0)
scanner_args = models.JSONField(default=dict)
duration = models.IntegerField(null=True, blank=True)
scheduled_at = models.DateTimeField(null=True, blank=True)
@@ -2001,35 +1998,6 @@ class IntegrationProviderRelationship(RowLevelSecurityProtectedModel):
]
class JiraIssueDispatch(RowLevelSecurityProtectedModel):
"""Tracks findings already sent to a Jira integration.
Lets the Jira task be re-run safely (e.g. by orphan recovery): findings with
an existing dispatch row are skipped, so no duplicate issues are created.
"""
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
integration = models.ForeignKey(
Integration, on_delete=models.CASCADE, related_name="jira_dispatches"
)
finding_id = models.UUIDField()
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "jira_issue_dispatches"
constraints = [
models.UniqueConstraint(
fields=["tenant_id", "integration_id", "finding_id"],
name="unique_jira_issue_dispatch",
),
RowLevelSecurityConstraint(
field="tenant_id",
name="rls_on_%(class)s",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
]
class SAMLToken(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
inserted_at = models.DateTimeField(auto_now_add=True, editable=False)
+12 -44
View File
@@ -182,23 +182,19 @@ def _make_app():
return ApiConfig("api", api)
def test_ready_initializes_driver_for_api_process(monkeypatch):
@pytest.mark.parametrize(
"argv",
[
["gunicorn"],
["celery", "-A", "api"],
["manage.py", "migrate"],
],
ids=["api", "celery", "manage_py"],
)
def test_ready_never_eagerly_initializes_neo4j_driver(monkeypatch, argv):
"""ready() must never contact Neo4j; the driver is created lazily on first use."""
config = _make_app()
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, False)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_called_once()
def test_ready_skips_driver_for_celery(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["celery", "-A", "api"])
_set_argv(monkeypatch, argv)
_set_testing(monkeypatch, False)
with (
@@ -208,31 +204,3 @@ def test_ready_skips_driver_for_celery(monkeypatch):
config.ready()
init_driver.assert_not_called()
def test_ready_skips_driver_for_manage_py_skip_command(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["manage.py", "migrate"])
_set_testing(monkeypatch, False)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
def test_ready_skips_driver_when_testing(monkeypatch):
config = _make_app()
_set_argv(monkeypatch, ["gunicorn"])
_set_testing(monkeypatch, True)
with (
patch.object(ApiConfig, "_ensure_crypto_keys", return_value=None),
patch("api.attack_paths.database.init_driver") as init_driver,
):
config.ready()
init_driver.assert_not_called()
@@ -1,15 +1,16 @@
"""
Tests for Neo4j database lazy initialization.
The Neo4j driver connects on first use by default. API processes may
eagerly initialize the driver during app startup, while Celery workers
remain lazy. These tests validate the database module behavior itself.
The Neo4j driver is created on first use for every process type; app startup
never contacts Neo4j. These tests validate the database module behavior itself.
"""
import threading
from unittest.mock import MagicMock, patch
import neo4j
import neo4j.exceptions
import pytest
import api.attack_paths.database as db_module
@@ -59,6 +60,32 @@ class TestLazyInitialization:
assert result is mock_driver
assert db_module._driver is mock_driver
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_leaves_driver_none_when_verify_fails(
self, mock_driver_factory, mock_settings
):
"""A failed verify_connectivity() must not publish or leak the driver."""
mock_driver = MagicMock()
mock_driver.verify_connectivity.side_effect = (
neo4j.exceptions.ServiceUnavailable("down")
)
mock_driver_factory.return_value = mock_driver
mock_settings.DATABASES = {
"neo4j": {
"HOST": "localhost",
"PORT": 7687,
"USER": "neo4j",
"PASSWORD": "password",
}
}
with pytest.raises(neo4j.exceptions.ServiceUnavailable):
db_module.init_driver()
assert db_module._driver is None
mock_driver.close.assert_called_once()
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_init_driver_returns_cached_driver_on_subsequent_calls(
@@ -116,21 +143,23 @@ class TestConnectionAcquisitionTimeout:
@pytest.fixture(autouse=True)
def reset_module_state(self):
original_driver = db_module._driver
original_timeout = db_module.CONN_ACQUISITION_TIMEOUT
original_acq_timeout = db_module.CONN_ACQUISITION_TIMEOUT
original_conn_timeout = db_module.CONNECTION_TIMEOUT
db_module._driver = None
yield
db_module._driver = original_driver
db_module.CONN_ACQUISITION_TIMEOUT = original_timeout
db_module.CONN_ACQUISITION_TIMEOUT = original_acq_timeout
db_module.CONNECTION_TIMEOUT = original_conn_timeout
@patch("api.attack_paths.database.settings")
@patch("api.attack_paths.database.neo4j.GraphDatabase.driver")
def test_driver_receives_configured_timeout(
self, mock_driver_factory, mock_settings
):
"""init_driver() should pass CONN_ACQUISITION_TIMEOUT to the neo4j driver."""
"""init_driver() should pass the configured timeouts to the neo4j driver."""
mock_driver_factory.return_value = MagicMock()
mock_settings.DATABASES = {
"neo4j": {
@@ -141,11 +170,13 @@ class TestConnectionAcquisitionTimeout:
}
}
db_module.CONN_ACQUISITION_TIMEOUT = 42
db_module.CONNECTION_TIMEOUT = 7
db_module.init_driver()
_, kwargs = mock_driver_factory.call_args
assert kwargs["connection_acquisition_timeout"] == 42
assert kwargs["connection_timeout"] == 7
class TestAtexitRegistration:
@@ -0,0 +1,55 @@
from config.django.base import label_postgres_connections
class TestLabelPostgresConnections:
def test_labels_postgres_and_skips_neo4j(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "scan")
databases = {
"default": {"ENGINE": "psqlextra.backend"},
"neo4j": {"HOST": "neo4j", "PORT": "7687"},
}
label_postgres_connections(databases)
assert databases["default"]["OPTIONS"]["application_name"] == "scan:default"
assert "OPTIONS" not in databases["neo4j"]
def test_labels_plain_postgresql_backend(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "api")
databases = {"saas": {"ENGINE": "django.db.backends.postgresql"}}
label_postgres_connections(databases)
assert databases["saas"]["OPTIONS"]["application_name"] == "api:saas"
def test_defaults_component_to_api_when_unset(self, monkeypatch):
monkeypatch.delenv("DJANGO_APP_COMPONENT", raising=False)
databases = {"default": {"ENGINE": "psqlextra.backend"}}
label_postgres_connections(databases)
assert databases["default"]["OPTIONS"]["application_name"] == "api:default"
def test_preserves_existing_options(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "worker")
databases = {
"replica": {
"ENGINE": "psqlextra.backend",
"OPTIONS": {"sslmode": "require"},
}
}
label_postgres_connections(databases)
assert databases["replica"]["OPTIONS"] == {
"sslmode": "require",
"application_name": "worker:replica",
}
def test_truncates_application_name_to_63_bytes(self, monkeypatch):
monkeypatch.setenv("DJANGO_APP_COMPONENT", "c" * 80)
databases = {"default": {"ENGINE": "psqlextra.backend"}}
label_postgres_connections(databases)
assert len(databases["default"]["OPTIONS"]["application_name"]) == 63
+29
View File
@@ -306,3 +306,32 @@ SESSION_COOKIE_SECURE = True
ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES = env.int(
"ATTACK_PATHS_SCAN_STALE_THRESHOLD_MINUTES", 2880
) # 48h
# Orphan task recovery feature flags. The master switch is OFF by default, so task
# recovery is opt-in; enable it with DJANGO_TASK_RECOVERY_ENABLED=true. The per-group
# toggles default to enabled, so once the master is on every group recovers unless a
# group is explicitly turned off.
TASK_RECOVERY_ENABLED = env.bool("DJANGO_TASK_RECOVERY_ENABLED", False)
TASK_RECOVERY_SUMMARIES_ENABLED = env.bool(
"DJANGO_TASK_RECOVERY_SUMMARIES_ENABLED", True
)
TASK_RECOVERY_DELETIONS_ENABLED = env.bool(
"DJANGO_TASK_RECOVERY_DELETIONS_ENABLED", True
)
def label_postgres_connections(databases):
"""Tag each Postgres connection with ``application_name="<component>:<alias>"``
so connections are attributable by component in ``pg_stat_activity`` (and any
tooling that surfaces ``application_name``). The component (api / worker /
scan / ...) is injected per process by the container entrypoint via
``DJANGO_APP_COMPONENT``; the alias distinguishes which pool inside the
process owns the connection. The neo4j entry is skipped (not a Postgres
backend). Postgres truncates ``application_name`` at 63 bytes.
"""
component = env.str("DJANGO_APP_COMPONENT", default="api")
for alias, config in databases.items():
engine = config.get("ENGINE", "")
if engine.startswith("psqlextra") or "postgresql" in engine:
name = f"{component}:{alias}"[:63]
config.setdefault("OPTIONS", {})["application_name"] = name
+2
View File
@@ -54,6 +54,8 @@ DATABASES = {
DATABASES["default"] = DATABASES["prowler_user"]
label_postgres_connections(DATABASES) # noqa: F405
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = tuple( # noqa: F405
render_class
for render_class in REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] # noqa: F405
@@ -58,3 +58,5 @@ DATABASES = {
}
DATABASES["default"] = DATABASES["prowler_user"]
label_postgres_connections(DATABASES) # noqa: F405
+5
View File
@@ -34,3 +34,8 @@ DRF_API_KEY = {
# JWT
SIMPLE_JWT["ALGORITHM"] = "HS256" # noqa: F405
# pyjwt >= 2.13.0 rejects an empty HMAC signing key, so HS256 tests need a real
# key (>= 32 bytes also avoids the InsecureKeyLengthWarning). Production uses RS256.
SIMPLE_JWT["SIGNING_KEY"] = env.str( # noqa: F405
"DJANGO_TOKEN_SIGNING_KEY", "insecure-testing-jwt-signing-key-do-not-use-in-prod"
)
-9
View File
@@ -11,7 +11,6 @@ from api.db_utils import batch_delete, rls_transaction
from api.models import (
AttackPathsScan,
Finding,
JiraIssueDispatch,
Provider,
ProviderComplianceScore,
Resource,
@@ -81,14 +80,6 @@ def delete_provider(tenant_id: str, pk: str):
deletion_steps = [
("Scan Summaries", ScanSummary.all_objects.filter(scan__provider=instance)),
(
"Jira Issue Dispatches",
JiraIssueDispatch.objects.filter(
finding_id__in=Finding.all_objects.filter(
scan__provider=instance
).values_list("id", flat=True)
),
),
("Findings", Finding.all_objects.filter(scan__provider=instance)),
("Resources", Resource.all_objects.filter(provider=instance)),
("Scans", Scan.all_objects.filter(provider=instance)),
+6
View File
@@ -58,6 +58,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
OktaIDaaSSTIG,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -152,6 +155,9 @@ COMPLIANCE_CLASS_MAP = {
ProwlerThreatScoreAlibaba,
),
],
"okta": [
(lambda name: name.startswith("okta_idaas_stig"), OktaIDaaSSTIG),
],
}
+51 -100
View File
@@ -9,7 +9,7 @@ from tasks.utils import batched
from api.db_router import READ_REPLICA_ALIAS, MainRouter
from api.db_utils import REPLICA_MAX_ATTEMPTS, REPLICA_RETRY_BASE_DELAY, rls_transaction
from api.models import Finding, Integration, JiraIssueDispatch, Provider
from api.models import Finding, Integration, Provider
from api.utils import initialize_prowler_integration, initialize_prowler_provider
from prowler.lib.outputs.asff.asff import ASFF
from prowler.lib.outputs.compliance.generic.generic import GenericCompliance
@@ -482,115 +482,66 @@ def send_findings_to_jira(
with rls_transaction(tenant_id):
integration = Integration.objects.get(id=integration_id)
jira_integration = initialize_prowler_integration(integration)
# Idempotency: findings already ticketed for this integration must not be
# sent again on a re-run (e.g. orphan recovery), to avoid duplicate issues
already_sent = {
str(fid)
for fid in JiraIssueDispatch.objects.filter(
integration_id=integration_id, finding_id__in=finding_ids
).values_list("finding_id", flat=True)
}
num_tickets_created = 0
skipped_count = 0
for finding_id in finding_ids:
if str(finding_id) in already_sent:
skipped_count += 1
continue
# Reserve the finding BEFORE the external call. The unique constraint on
# (tenant, integration, finding) makes the dispatch row the single source of
# truth, so a concurrent run or a retry that raced past the bulk pre-check
# cannot create a duplicate issue: created=False means another run already
# claimed it. The reservation is released below if the send does not succeed.
with rls_transaction(tenant_id):
_, created = JiraIssueDispatch.objects.get_or_create(
tenant_id=tenant_id,
integration_id=integration_id,
finding_id=finding_id,
finding_instance = (
Finding.all_objects.select_related("scan__provider")
.prefetch_related("resources")
.get(id=finding_id)
)
if not created:
skipped_count += 1
continue
sent = False
try:
with rls_transaction(tenant_id):
finding_instance = (
Finding.all_objects.select_related("scan__provider")
.prefetch_related("resources")
.get(id=finding_id)
)
# Extract resource information
resource = (
finding_instance.resources.first()
if finding_instance.resources.exists()
else None
)
resource_uid = resource.uid if resource else ""
resource_name = resource.name if resource else ""
resource_tags = {}
if resource and hasattr(resource, "tags"):
resource_tags = resource.get_tags(tenant_id)
# Extract resource information
resource = (
finding_instance.resources.first()
if finding_instance.resources.exists()
else None
)
resource_uid = resource.uid if resource else ""
resource_name = resource.name if resource else ""
resource_tags = {}
if resource and hasattr(resource, "tags"):
resource_tags = resource.get_tags(tenant_id)
# Get region
region = resource.region if resource and resource.region else ""
# Get region
region = resource.region if resource and resource.region else ""
# Extract remediation information from check_metadata
check_metadata = finding_instance.check_metadata
remediation = check_metadata.get("remediation", {})
recommendation = remediation.get("recommendation", {})
remediation_code = remediation.get("code", {})
# Extract remediation information from check_metadata
check_metadata = finding_instance.check_metadata
remediation = check_metadata.get("remediation", {})
recommendation = remediation.get("recommendation", {})
remediation_code = remediation.get("code", {})
# Send the individual finding to Jira
sent = bool(
jira_integration.send_finding(
check_id=finding_instance.check_id,
check_title=check_metadata.get("checktitle", ""),
severity=finding_instance.severity,
status=finding_instance.status,
status_extended=finding_instance.status_extended or "",
provider=finding_instance.scan.provider.provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=check_metadata.get("risk", ""),
recommendation_text=recommendation.get("text", ""),
recommendation_url=recommendation.get("url", ""),
remediation_code_native_iac=remediation_code.get(
"nativeiac", ""
),
remediation_code_terraform=remediation_code.get(
"terraform", ""
),
remediation_code_cli=remediation_code.get("cli", ""),
remediation_code_other=remediation_code.get("other", ""),
resource_tags=resource_tags,
compliance=finding_instance.compliance or {},
project_key=project_key,
issue_type=issue_type,
)
)
finally:
if not sent:
# Release the reservation so a later run can retry this finding: it
# was not ticketed (send failed or raised), so the row must not block
# a future legitimate send.
with rls_transaction(tenant_id):
JiraIssueDispatch.objects.filter(
tenant_id=tenant_id,
integration_id=integration_id,
finding_id=finding_id,
).delete()
if sent:
num_tickets_created += 1
else:
logger.error(f"Failed to send finding {finding_id} to Jira")
# Send the individual finding to Jira
result = jira_integration.send_finding(
check_id=finding_instance.check_id,
check_title=check_metadata.get("checktitle", ""),
severity=finding_instance.severity,
status=finding_instance.status,
status_extended=finding_instance.status_extended or "",
provider=finding_instance.scan.provider.provider,
region=region,
resource_uid=resource_uid,
resource_name=resource_name,
risk=check_metadata.get("risk", ""),
recommendation_text=recommendation.get("text", ""),
recommendation_url=recommendation.get("url", ""),
remediation_code_native_iac=remediation_code.get("nativeiac", ""),
remediation_code_terraform=remediation_code.get("terraform", ""),
remediation_code_cli=remediation_code.get("cli", ""),
remediation_code_other=remediation_code.get("other", ""),
resource_tags=resource_tags,
compliance=finding_instance.compliance or {},
project_key=project_key,
issue_type=issue_type,
)
if result:
num_tickets_created += 1
else:
logger.error(f"Failed to send finding {finding_id} to Jira")
return {
"created_count": num_tickets_created,
"failed_count": len(finding_ids) - num_tickets_created - skipped_count,
"skipped_count": skipped_count,
"failed_count": len(finding_ids) - num_tickets_created,
}
+75 -131
View File
@@ -37,35 +37,52 @@ ORPHAN_RECOVERY_LOCK_KEY = 0x70726F77 # "prow"
# Non-terminal states that mean "a worker had this and may have died with it".
IN_FLIGHT_STATES = (states.STARTED, states.RECEIVED)
# Scan tasks are recovered by re-running scan-perform on the EXISTING scan row,
# not by re-enqueuing the original task: re-enqueuing scan-perform-scheduled would
# hit its "a scan is already executing" guard and no-op, leaving the scan stuck.
_SCAN_TASKS = ("scan-perform", "scan-perform-scheduled")
# Tasks with proven idempotency are auto re-enqueued. Scans/summaries clear and
# rewrite their own rows. integration-jira is safe too: each finding is reserved in
# JiraIssueDispatch before the external call, so a re-run skips already-ticketed
# findings (worst case one finding missed on a mid-send crash, never a duplicate).
# Other external side effects stay terminal: integration-s3 rebuilds its upload from
# worker-local files that do not survive a crash, and report/Security Hub recovery is
# out of scope.
REENQUEUEABLE_TASKS = {
*_SCAN_TASKS,
"provider-deletion",
"tenant-deletion",
"scan-summary",
"scan-compliance-overviews",
"scan-provider-compliance-scores",
"scan-daily-severity",
"scan-finding-group-summaries",
"scan-reset-ephemeral-resources",
"integration-jira",
# Tasks with proven idempotency are eligible for auto re-enqueue, grouped so each
# group can be toggled independently by a feature flag (see config.django.base).
# Summaries clear and rewrite their own rows and deletions are idempotent. Tasks with
# external side effects are never eligible: integration-jira would create duplicate
# issues, integration-s3 rebuilds its upload from worker-local files that do not
# survive a crash, and report/Security Hub recovery is out of scope.
RECOVERY_TASK_GROUPS = {
"summaries": {
"scan-summary",
"scan-compliance-overviews",
"scan-provider-compliance-scores",
"scan-daily-severity",
"scan-finding-group-summaries",
"scan-reset-ephemeral-resources",
},
"deletions": {"provider-deletion", "tenant-deletion"},
}
# Tasks excluded from generic recovery: attack-paths scans are handled by their own
# stale-cleanup (which also drops the temp Neo4j db), and the maintenance tasks must
# not self-recover (they run again on their own schedule).
def reenqueueable_tasks() -> set[str]:
"""Task names eligible for auto re-enqueue, honoring the per-group feature flags.
A group whose flag is disabled is dropped, so its orphaned tasks are marked
terminal instead of re-enqueued.
"""
from django.conf import settings
group_enabled = {
"summaries": settings.TASK_RECOVERY_SUMMARIES_ENABLED,
"deletions": settings.TASK_RECOVERY_DELETIONS_ENABLED,
}
return {
task
for group, tasks in RECOVERY_TASK_GROUPS.items()
if group_enabled[group]
for task in tasks
}
# Tasks the watchdog ignores entirely (not even marked terminal): scan tasks are not
# auto-recovered, since re-running a scan is not safe to do automatically; attack-paths
# scans are handled by their own stale-cleanup (which also drops the temp Neo4j db);
# and the maintenance tasks must not self-recover (they run again on their own schedule).
_SKIP_RECOVERY = {
"scan-perform",
"scan-perform-scheduled",
"attack-paths-scan-perform",
"attack-paths-cleanup-stale-scans",
"reconcile-orphan-tasks",
@@ -166,15 +183,22 @@ def reconcile_orphans(
logger.info("Orphan reconcile skipped: another run holds the lock")
return {"acquired": False}
# Populate the task registry so we can re-enqueue any task by name.
import tasks.tasks # noqa: F401
from django.conf import settings
result = _reconcile_task_results(
grace_minutes=grace_minutes,
max_attempts=max_attempts,
window_hours=window_hours,
dry_run=dry_run,
)
if settings.TASK_RECOVERY_ENABLED:
# Populate the task registry so we can re-enqueue any task by name.
import tasks.tasks # noqa: F401
result = _reconcile_task_results(
grace_minutes=grace_minutes,
max_attempts=max_attempts,
window_hours=window_hours,
dry_run=dry_run,
)
result["enabled"] = True
else:
logger.info("Orphan task recovery disabled by feature flag")
result = {"recovered": [], "failed": [], "skipped": [], "enabled": False}
if not dry_run:
from tasks.jobs.attack_paths.cleanup import cleanup_stale_attack_paths_scans
@@ -264,34 +288,27 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
task_result.date_done = now
task_result.save(update_fields=["status", "date_done"])
attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
if name not in REENQUEUEABLE_TASKS or attempt > max_attempts:
reason = (
f"{name} is not allowlisted for auto recovery"
if name not in REENQUEUEABLE_TASKS
else f"recovery cap reached ({attempt}/{max_attempts})"
)
_fail_domain_row(task_result.task_id, name, now)
if name not in reenqueueable_tasks():
logger.warning(
"Orphan %s (%s) not re-enqueued: %s", task_result.task_id, name, reason
"Orphan %s (%s) not re-enqueued: not allowlisted for auto recovery",
task_result.task_id,
name,
)
return "failed"
# Scan tasks: re-run the EXISTING scan row directly via scan-perform, so the
# scheduled-scan "already executing" guard cannot turn recovery into a no-op.
# Falls through to the generic path only if no scan is linked yet (e.g. a
# scheduled task that died before creating one), where re-running it creates one.
if name in _SCAN_TASKS:
scan = _scan_for_task(task_result.task_id)
if scan is not None:
if not _reenqueue_scan(task_result.task_id, scan):
return "failed"
logger.info(
"Re-enqueued orphaned scan %s (was task %s)",
scan.id,
task_result.task_id,
)
return "recovered"
# Count the attempt only once the task is allowlisted, so a task sitting in a
# disabled group does not burn its recovery budget while the flag is off (and is
# not already over the cap the moment the group is re-enabled).
attempt = _recovery_attempt_count(name, kwargs_repr, window_hours)
if attempt > max_attempts:
logger.warning(
"Orphan %s (%s) not re-enqueued: recovery cap reached (%d/%d)",
task_result.task_id,
name,
attempt,
max_attempts,
)
return "failed"
task_obj = current_app.tasks.get(name)
if task_obj is None:
@@ -311,7 +328,6 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
task_result.task_id,
name,
)
_fail_domain_row(task_result.task_id, name, now)
return "failed"
new_task_id = str(uuid4())
task_obj.apply_async(
@@ -323,75 +339,3 @@ def _recover_task(task_result, max_attempts: int, window_hours: int) -> str:
"Re-enqueued orphan %s (%s) as %s", task_result.task_id, name, new_task_id
)
return "recovered"
def _scan_for_task(task_id: str):
"""Return the Scan linked to a Celery task id, or None (read across tenants)."""
from api.db_router import MainRouter
from api.models import Scan
return Scan.all_objects.using(MainRouter.admin_db).filter(task_id=task_id).first()
def _reenqueue_scan(old_task_id: str, scan) -> bool:
"""Re-run an orphaned scan via scan-perform on the existing row.
Pre-provisions the new task linkage (TaskResult + api.Task) and relinks the
Scan before enqueuing, so the FK is valid and a worker can never outrun the DB.
The relink is conditional on the scan still pointing at the old task, so a stale
orphan can never clobber a newer linkage.
"""
from django_celery_results.models import TaskResult
from api.db_utils import rls_transaction
from api.models import Scan
from api.models import Task as APITask
from tasks.tasks import perform_scan_task
tenant_id = str(scan.tenant_id)
new_task_id = str(uuid4())
with rls_transaction(tenant_id):
locked_scan = Scan.all_objects.select_for_update().filter(id=scan.id).first()
if locked_scan is None or str(locked_scan.task_id) != old_task_id:
logger.info(
"Scan %s no longer points at task %s; skipping recovery re-enqueue",
scan.id,
old_task_id,
)
return False
task_result_new, _ = TaskResult.objects.get_or_create(
task_id=new_task_id,
defaults={"status": states.PENDING, "task_name": "scan-perform"},
)
APITask.objects.update_or_create(
id=new_task_id,
tenant_id=tenant_id,
defaults={"task_runner_task": task_result_new},
)
locked_scan.task_id = new_task_id
locked_scan.recovery_count = (locked_scan.recovery_count or 0) + 1
locked_scan.save(update_fields=["task_id", "recovery_count", "updated_at"])
perform_scan_task.apply_async(
kwargs={
"tenant_id": tenant_id,
"scan_id": str(scan.id),
"provider_id": str(scan.provider_id),
},
task_id=new_task_id,
)
return True
def _fail_domain_row(old_task_id: str, name: str, now: datetime) -> None:
"""Mark a scan terminal when its task is capped/denylisted instead of re-run."""
from api.db_utils import rls_transaction
from api.models import Scan, StateChoices
if name in _SCAN_TASKS:
scan = _scan_for_task(old_task_id)
if scan is not None:
with rls_transaction(str(scan.tenant_id)):
Scan.all_objects.filter(id=scan.id, task_id=old_task_id).update(
state=StateChoices.FAILED, completed_at=now
)
+12 -18
View File
@@ -118,19 +118,6 @@ ATTACK_SURFACE_PROVIDER_COMPATIBILITY = {
_ATTACK_SURFACE_MAPPING_CACHE: dict[str, dict] = {}
def _clear_scan_rerun_state(tenant_id: str, scan_id: str) -> None:
"""Remove rows derived from a previous execution of this scan."""
with rls_transaction(tenant_id):
Finding.all_objects.filter(scan_id=scan_id).delete()
ResourceScanSummary.objects.filter(scan_id=scan_id).delete()
ScanCategorySummary.objects.filter(scan_id=scan_id).delete()
ScanGroupSummary.objects.filter(scan_id=scan_id).delete()
ScanSummary.objects.filter(scan_id=scan_id).delete()
AttackSurfaceOverview.objects.filter(scan_id=scan_id).delete()
ComplianceRequirementOverview.objects.filter(scan_id=scan_id).delete()
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
def aggregate_category_counts(
categories: list[str],
severity: str,
@@ -282,6 +269,7 @@ def _store_resources(
provider=provider_instance,
uid=finding.resource_uid,
defaults={
"name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -289,6 +277,7 @@ def _store_resources(
)
if not created:
resource_instance.name = finding.resource_name
resource_instance.region = finding.region
resource_instance.service = finding.service_name
resource_instance.type = finding.resource_type
@@ -489,10 +478,9 @@ def _create_compliance_summaries(
)
)
# Idempotent re-run: clear this scan's prior summaries before re-inserting, so
# a recovered scan's summary always reflects its own (re-derived) requirement
# rows rather than keeping a stale row (bulk_create ignore_conflicts alone would
# keep the old one).
# Idempotent re-run: clear this scan's prior summaries before re-inserting, so a
# recovered scan-compliance-overviews run reflects its own re-derived rows instead
# of keeping a stale one (bulk_create ignore_conflicts alone would keep the old).
with rls_transaction(tenant_id):
ComplianceOverviewSummary.objects.filter(scan_id=scan_id).delete()
if summary_objects:
@@ -718,6 +706,12 @@ def _process_finding_micro_batch(
if finding.region and resource_instance.region != finding.region:
resource_instance.region = finding.region
updated = True
if (
finding.resource_name
and resource_instance.name != finding.resource_name
):
resource_instance.name = finding.resource_name
updated = True
if resource_instance.service != finding.service_name:
resource_instance.service = finding.service_name
updated = True
@@ -959,6 +953,7 @@ def _process_finding_micro_batch(
Resource.objects.bulk_update(
resources_to_bulk_update,
[
"name",
"metadata",
"details",
"partition",
@@ -1039,7 +1034,6 @@ def perform_prowler_scan(
scan_instance.state = StateChoices.EXECUTING
scan_instance.started_at = datetime.now(tz=timezone.utc)
scan_instance.save(update_fields=["state", "started_at", "updated_at"])
_clear_scan_rerun_state(tenant_id, scan_id)
# Find the mutelist processor if it exists
with rls_transaction(tenant_id, using=READ_REPLICA_ALIAS):
+14 -2
View File
@@ -260,7 +260,9 @@ def delete_provider_task(provider_id: str, tenant_id: str):
return delete_provider(tenant_id=tenant_id, pk=provider_id)
@shared_task(base=RLSTask, name="scan-perform", queue="scans")
# acks_late=False: a re-run would duplicate findings and the task is not auto-recovered,
# so a crashed scan is dropped rather than redelivered by the broker (as before #11416).
@shared_task(base=RLSTask, name="scan-perform", queue="scans", acks_late=False)
@handle_provider_deletion
def perform_scan_task(
tenant_id: str, scan_id: str, provider_id: str, checks_to_execute: list[str] = None
@@ -304,7 +306,14 @@ def perform_scan_task(
return result
@shared_task(base=RLSTask, bind=True, name="scan-perform-scheduled", queue="scans")
# acks_late=False: like scan-perform; a dropped run is re-fired by Beat on the next tick.
@shared_task(
base=RLSTask,
bind=True,
name="scan-perform-scheduled",
queue="scans",
acks_late=False,
)
@handle_provider_deletion
def perform_scheduled_scan_task(self, tenant_id: str, provider_id: str):
"""
@@ -1151,10 +1160,13 @@ def security_hub_integration_task(
return upload_security_hub_integration(tenant_id, provider_id, scan_id)
# acks_late=False: Jira sends are not deduplicated and the task is not auto-recovered,
# so a crashed send is dropped rather than redelivered (avoids duplicate Jira issues).
@shared_task(
base=RLSTask,
name="integration-jira",
queue="integrations",
acks_late=False,
)
def jira_integration_task(
tenant_id: str,
+1 -39
View File
@@ -1,12 +1,11 @@
from unittest.mock import call, patch
from uuid import uuid4
import pytest
from django.core.exceptions import ObjectDoesNotExist
from tasks.jobs.deletion import delete_provider, delete_tenant
from api.attack_paths import database as graph_database
from api.models import JiraIssueDispatch, Provider, Tenant, TenantComplianceSummary
from api.models import Provider, Tenant, TenantComplianceSummary
@pytest.mark.django_db
@@ -35,43 +34,6 @@ class TestDeleteProvider:
str(instance.id),
)
def test_delete_provider_removes_jira_dispatches(
self,
providers_fixture,
findings_fixture,
integrations_fixture,
):
"""Deleting a provider removes JiraIssueDispatch rows for its findings only."""
instance = providers_fixture[0]
tenant_id = str(instance.tenant_id)
finding = findings_fixture[0]
integration = integrations_fixture[0]
# Dispatch for one of the provider's findings: must be removed with it.
JiraIssueDispatch.objects.create(
tenant_id=tenant_id,
integration=integration,
finding_id=finding.id,
)
# Dispatch for an unrelated finding: must survive the provider deletion.
unrelated = JiraIssueDispatch.objects.create(
tenant_id=tenant_id,
integration=integration,
finding_id=uuid4(),
)
with (
patch(
"tasks.jobs.deletion.graph_database.get_database_name",
return_value="tenant-db",
),
patch("tasks.jobs.deletion.graph_database.drop_subgraph"),
):
delete_provider(tenant_id, instance.id)
assert not JiraIssueDispatch.objects.filter(finding_id=finding.id).exists()
assert JiraIssueDispatch.objects.filter(pk=unrelated.pk).exists()
def test_delete_provider_does_not_exist(self, tenants_fixture):
with (
patch(
@@ -1640,74 +1640,14 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_skips_already_dispatched(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""A re-run skips findings already ticketed (no duplicate Jira issues)."""
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
mock_integration_model.objects.get.return_value = MagicMock()
# finding-1 was already dispatched in a prior run; finding-2 is new.
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = [
"finding-1"
]
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
mock_jira_integration = MagicMock()
mock_jira_integration.send_finding.return_value = True
mock_initialize_integration.return_value = mock_jira_integration
finding2 = MagicMock()
finding2.id = "finding-2"
finding2.check_id = "check_002"
finding2.severity = "low"
finding2.status = "FAIL"
finding2.status_extended = ""
finding2.compliance = {}
finding2.resources.exists.return_value = False
finding2.resources.first.return_value = None
finding2.scan.provider.provider = "aws"
finding2.check_metadata = {
"checktitle": "C2",
"risk": "",
"remediation": {"recommendation": {}, "code": {}},
}
mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding2
result = send_findings_to_jira(
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1", "finding-2"]
)
# finding-1 skipped (already sent); only finding-2 sent -> no duplicate.
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 1}
mock_jira_integration.send_finding.assert_called_once()
assert (
mock_jira_integration.send_finding.call_args.kwargs["check_id"]
== "check_002"
)
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_success(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test successful sending of findings to Jira using send_finding method"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1799,7 +1739,7 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 2, "failed_count": 0, "skipped_count": 0}
assert result == {"created_count": 2, "failed_count": 0}
# Verify Jira integration was initialized
mock_initialize_integration.assert_called_once_with(integration)
@@ -1831,10 +1771,8 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.logger")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_partial_failure(
self,
mock_jira_dispatch,
mock_logger,
mock_initialize_integration,
mock_integration_model,
@@ -1842,8 +1780,6 @@ class TestJiraIntegration:
mock_rls_transaction,
):
"""Test partial failure when sending findings to Jira"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1897,35 +1833,23 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 2, "failed_count": 1, "skipped_count": 0}
assert result == {"created_count": 2, "failed_count": 1}
# Verify error was logged for the failed finding
mock_logger.error.assert_called_with("Failed to send finding finding-2 to Jira")
# The failed finding's reservation is released so a later run can retry it.
mock_jira_dispatch.objects.filter.assert_any_call(
tenant_id=tenant_id,
integration_id=integration_id,
finding_id="finding-2",
)
mock_jira_dispatch.objects.filter.return_value.delete.assert_called_once()
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_no_resources(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test sending findings to Jira when finding has no resources"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -1983,7 +1907,7 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
assert result == {"created_count": 1, "failed_count": 0}
# Verify send_finding was called with empty resource fields
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
@@ -1996,18 +1920,14 @@ class TestJiraIntegration:
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_with_empty_check_metadata(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""Test sending findings to Jira when check_metadata is empty or missing fields"""
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), True)
tenant_id = "tenant-123"
integration_id = "integration-456"
project_key = "PROJ"
@@ -2050,7 +1970,7 @@ class TestJiraIntegration:
)
# Assertions
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
assert result == {"created_count": 1, "failed_count": 0}
# Verify send_finding was called with default/empty values
call_kwargs = mock_jira_integration.send_finding.call_args.kwargs
@@ -2063,94 +1983,3 @@ class TestJiraIntegration:
assert call_kwargs["remediation_code_cli"] == ""
assert call_kwargs["remediation_code_other"] == ""
assert call_kwargs["compliance"] == {}
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_reserves_before_sending(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""The dispatch row is reserved before the external Jira call (reserve-then-act)."""
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
mock_integration_model.objects.get.return_value = MagicMock()
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
order = []
mock_jira_dispatch.objects.get_or_create.side_effect = lambda **kw: (
order.append(("reserve", kw)) or (MagicMock(), True)
)
mock_jira_integration = MagicMock()
mock_jira_integration.send_finding.side_effect = lambda **kw: (
order.append(("send", kw)) or True
)
mock_initialize_integration.return_value = mock_jira_integration
finding = MagicMock()
finding.id = "finding-1"
finding.check_id = "check_001"
finding.severity = "low"
finding.status = "FAIL"
finding.status_extended = ""
finding.compliance = {}
finding.resources.exists.return_value = False
finding.resources.first.return_value = None
finding.scan.provider.provider = "aws"
finding.check_metadata = {
"checktitle": "C1",
"risk": "",
"remediation": {"recommendation": {}, "code": {}},
}
mock_finding_model.all_objects.select_related.return_value.prefetch_related.return_value.get.return_value = finding
result = send_findings_to_jira(
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
)
assert result == {"created_count": 1, "failed_count": 0, "skipped_count": 0}
# Reservation must precede the external send.
assert [entry[0] for entry in order] == ["reserve", "send"]
# A successful send keeps the reservation (no rollback delete).
mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
@patch("tasks.jobs.integrations.rls_transaction")
@patch("tasks.jobs.integrations.Finding")
@patch("tasks.jobs.integrations.Integration")
@patch("tasks.jobs.integrations.initialize_prowler_integration")
@patch("tasks.jobs.integrations.JiraIssueDispatch")
def test_send_findings_to_jira_skips_when_already_reserved(
self,
mock_jira_dispatch,
mock_initialize_integration,
mock_integration_model,
mock_finding_model,
mock_rls_transaction,
):
"""A finding that races past the bulk pre-check but loses the reservation
(created=False) is skipped without a second issue, leaving the row intact."""
mock_rls_transaction.return_value.__enter__ = MagicMock()
mock_rls_transaction.return_value.__exit__ = MagicMock()
mock_integration_model.objects.get.return_value = MagicMock()
mock_jira_dispatch.objects.filter.return_value.values_list.return_value = []
# Another concurrent run already created the dispatch row.
mock_jira_dispatch.objects.get_or_create.return_value = (MagicMock(), False)
mock_jira_integration = MagicMock()
mock_initialize_integration.return_value = mock_jira_integration
result = send_findings_to_jira(
"tenant-123", "integration-456", "PROJ", "Task", ["finding-1"]
)
assert result == {"created_count": 0, "failed_count": 0, "skipped_count": 1}
mock_jira_integration.send_finding.assert_not_called()
# The reservation belongs to the run that won the race; do not delete it.
mock_jira_dispatch.objects.filter.return_value.delete.assert_not_called()
@@ -4,17 +4,17 @@ from uuid import uuid4
import pytest
from celery import states
from django.test import override_settings
from django_celery_results.models import TaskResult
from api.models import Scan, StateChoices
from api.models import Task as APITask
from tasks.jobs.orphan_recovery import (
_decode_celery_field,
_reconcile_task_results,
_recovery_attempt_count,
_reenqueue_scan,
advisory_lock,
is_worker_alive,
reconcile_orphans,
reenqueueable_tasks,
)
@@ -130,9 +130,83 @@ class TestReconcileTaskResults:
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
def test_jira_integration_task_is_reenqueued(self, tenants_fixture):
"""integration-jira is re-enqueued: its JiraIssueDispatch reservation makes a
re-run skip already-ticketed findings, so recovery cannot duplicate issues."""
@override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
def test_disabled_group_task_is_not_reenqueued(self, tenants_fixture):
"""A task whose group feature flag is off stays terminal, not re-enqueued."""
tr = _orphan_result(
name="scan-summary",
kwargs={
"tenant_id": str(tenants_fixture[0].id),
"scan_id": str(uuid4()),
},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_task.apply_async.assert_not_called()
@override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
def test_disabled_group_task_does_not_consume_recovery_attempt(
self, tenants_fixture
):
"""A disabled-group task is failed without incrementing its Valkey attempt
counter, so re-enabling the group does not start it at the cap."""
tr = _orphan_result(
name="scan-summary",
kwargs={"tenant_id": str(tenants_fixture[0].id), "scan_id": str(uuid4())},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with (
p_alive,
p_revoke,
p_app,
patch("tasks.jobs.orphan_recovery._recovery_attempt_count") as mock_count,
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["failed"]
mock_count.assert_not_called()
def test_scan_task_is_skipped_entirely(self, tenants_fixture):
"""Scan tasks are excluded from recovery: the watchdog never touches them."""
tr = _orphan_result(
name="scan-perform",
kwargs={
"tenant_id": str(tenants_fixture[0].id),
"scan_id": str(uuid4()),
},
worker="dead@gone",
created_minutes_ago=60,
)
p_alive, p_revoke, p_app, mock_task = self._patches(alive=False)
with p_alive, p_revoke, p_app:
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id not in result["recovered"]
assert tr.task_id not in result["failed"]
assert tr.task_id not in result["skipped"]
mock_task.apply_async.assert_not_called()
def test_jira_integration_task_is_not_reenqueued(self, tenants_fixture):
"""integration-jira stays terminal: re-running it would create duplicate Jira
issues, so an orphaned send is failed instead of re-enqueued."""
tenant = tenants_fixture[0]
kwargs = {
"tenant_id": str(tenant.id),
@@ -158,13 +232,10 @@ class TestReconcileTaskResults:
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert tr.task_id in result["recovered"]
assert tr.task_id in result["failed"]
tr.refresh_from_db()
assert tr.status == states.REVOKED # stale result cleared (no pending alert)
mock_task.apply_async.assert_called_once()
call = mock_task.apply_async.call_args.kwargs
assert call["kwargs"] == kwargs
assert call["task_id"] != tr.task_id # fresh task id
mock_task.apply_async.assert_not_called()
def test_skips_live_worker(self, tenants_fixture):
tr = _orphan_result(
@@ -246,98 +317,6 @@ class TestReconcileTaskResults:
mock_task.apply_async.assert_not_called()
@pytest.mark.django_db
class TestScanRecovery:
"""Scans are recovered by re-running scan-perform on the EXISTING scan row,
so even a scheduled-scan orphan (whose own task would no-op on its guard) is
actually re-executed."""
def _scan_orphan(self, tenant, provider, name):
old_id = str(uuid4())
tr = TaskResult.objects.create(
task_id=old_id,
status=states.STARTED,
task_name=name,
worker="dead@gone",
task_kwargs=repr(
{"tenant_id": str(tenant.id), "provider_id": str(provider.id)}
),
task_args=repr([]),
)
TaskResult.objects.filter(pk=tr.pk).update(
date_created=datetime.now(tz=timezone.utc) - timedelta(minutes=60)
)
APITask.objects.create(id=old_id, tenant_id=tenant.id, task_runner_task=tr)
scan = Scan.objects.create(
name="scan-orphan",
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.EXECUTING,
tenant_id=tenant.id,
task_id=old_id,
recovery_count=0,
)
return old_id, scan
@pytest.mark.parametrize("name", ["scan-perform", "scan-perform-scheduled"])
def test_scan_recovered_via_scan_perform(
self, tenants_fixture, providers_fixture, name
):
tenant, provider = tenants_fixture[0], providers_fixture[0]
old_id, scan = self._scan_orphan(tenant, provider, name)
with (
patch("tasks.jobs.orphan_recovery.is_worker_alive", return_value=False),
patch("tasks.jobs.orphan_recovery.revoke_task"),
patch("tasks.jobs.orphan_recovery._recovery_attempt_count", return_value=1),
patch("tasks.tasks.perform_scan_task") as mock_scan_task,
):
result = _reconcile_task_results(
grace_minutes=2, max_attempts=3, window_hours=6, dry_run=False
)
assert old_id in result["recovered"]
scan.refresh_from_db()
assert str(scan.task_id) != old_id # relinked to a fresh task
assert scan.recovery_count == 1
assert TaskResult.objects.get(task_id=old_id).status == states.REVOKED
# Recovered by re-running scan-perform on the existing scan row (so the
# scheduled guard cannot no-op it), regardless of the original task name.
mock_scan_task.apply_async.assert_called_once()
assert mock_scan_task.apply_async.call_args.kwargs["kwargs"]["scan_id"] == str(
scan.id
)
def test_reenqueue_skips_when_scan_already_repointed(
self, tenants_fixture, providers_fixture
):
# The scan already points at a newer task, so a stale orphan must not relink
# it or launch a second concurrent run against the same scan row.
tenant, provider = tenants_fixture[0], providers_fixture[0]
newer_id = str(uuid4())
tr = TaskResult.objects.create(
task_id=newer_id, status=states.STARTED, task_name="scan-perform"
)
APITask.objects.create(id=newer_id, tenant_id=tenant.id, task_runner_task=tr)
scan = Scan.objects.create(
name="scan-orphan",
provider=provider,
trigger=Scan.TriggerChoices.SCHEDULED,
state=StateChoices.EXECUTING,
tenant_id=tenant.id,
task_id=newer_id,
recovery_count=0,
)
with patch("tasks.tasks.perform_scan_task") as mock_scan_task:
recovered = _reenqueue_scan(str(uuid4()), scan)
assert recovered is False
mock_scan_task.apply_async.assert_not_called()
scan.refresh_from_db()
assert scan.recovery_count == 0
@pytest.mark.django_db
class TestOrphanRecoveryHelpers:
def test_advisory_lock_acquires_and_releases(self):
@@ -370,3 +349,60 @@ class TestOrphanRecoveryHelpers:
with patch("redis.from_url", return_value=redis_client):
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 1
assert _recovery_attempt_count("probe-task", kwargs_repr, 6) == 2
class TestRecoveryFeatureFlags:
def test_all_groups_enabled_by_default(self):
tasks = reenqueueable_tasks()
assert "scan-summary" in tasks
assert {"provider-deletion", "tenant-deletion"} <= tasks
@override_settings(TASK_RECOVERY_SUMMARIES_ENABLED=False)
def test_summaries_group_flag_excludes_summary_tasks(self):
tasks = reenqueueable_tasks()
assert "scan-summary" not in tasks
assert "scan-compliance-overviews" not in tasks
assert "provider-deletion" in tasks
@override_settings(TASK_RECOVERY_DELETIONS_ENABLED=False)
def test_deletions_group_flag_excludes_deletion_tasks(self):
tasks = reenqueueable_tasks()
assert "provider-deletion" not in tasks
assert "tenant-deletion" not in tasks
assert "scan-summary" in tasks
@pytest.mark.django_db
class TestRecoveryMasterFlag:
@override_settings(TASK_RECOVERY_ENABLED=False)
def test_master_flag_disables_task_recovery(self):
with (
patch(
"tasks.jobs.orphan_recovery._reconcile_task_results"
) as mock_reconcile,
patch(
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
return_value={},
),
):
result = reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
mock_reconcile.assert_not_called()
assert result["acquired"] is True
assert result["enabled"] is False
@override_settings(TASK_RECOVERY_ENABLED=True)
def test_master_flag_enabled_runs_task_recovery(self):
with (
patch(
"tasks.jobs.orphan_recovery._reconcile_task_results",
return_value={"recovered": [], "failed": [], "skipped": []},
) as mock_reconcile,
patch(
"tasks.jobs.attack_paths.cleanup.cleanup_stale_attack_paths_scans",
return_value={},
),
):
reconcile_orphans(grace_minutes=2, max_attempts=3, dry_run=False)
mock_reconcile.assert_called_once()
+73 -128
View File
@@ -32,15 +32,12 @@ from tasks.utils import CustomEncoder
from api.db_router import MainRouter
from api.exceptions import ProviderConnectionError
from api.models import (
AttackSurfaceOverview,
Finding,
MuteRule,
Provider,
Resource,
ResourceScanSummary,
Scan,
ScanCategorySummary,
ScanGroupSummary,
ScanSummary,
StateChoices,
StatusChoices,
@@ -232,131 +229,6 @@ class TestPerformScan:
# Assert that failed_findings_count is 0 (finding is PASS and muted)
assert scan_resource.failed_findings_count == 0
def test_perform_prowler_scan_idempotent_on_rerun(
self,
tenants_fixture,
scans_fixture,
providers_fixture,
):
"""Re-running a scan for the same scan_id must not duplicate findings."""
with (
patch("api.db_utils.rls_transaction"),
patch(
"tasks.jobs.scan.initialize_prowler_provider"
) as mock_initialize_prowler_provider,
patch("tasks.jobs.scan.ProwlerScan") as mock_prowler_scan_class,
patch(
"tasks.jobs.scan.PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE",
new_callable=dict,
),
patch("api.compliance.PROWLER_CHECKS", new_callable=dict) as mock_checks,
):
mock_checks["aws"] = {"check1": {"compliance1"}}
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = providers_fixture[0]
provider.provider = Provider.ProviderChoices.AWS
provider.save()
tenant_id = str(tenant.id)
scan_id = str(scan.id)
provider_id = str(provider.id)
stale_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="stale_resource_uid",
name="stale",
region="stale-region",
service="stale-service",
type="stale-type",
)
ResourceScanSummary.objects.create(
tenant_id=tenant.id,
scan_id=scan.id,
resource_id=stale_resource.id,
service="stale-service",
region="stale-region",
resource_type="stale-type",
)
ScanCategorySummary.objects.create(
tenant_id=tenant.id,
scan=scan,
category="stale-category",
severity=Severity.medium,
total_findings=1,
)
ScanGroupSummary.objects.create(
tenant_id=tenant.id,
scan=scan,
resource_group="stale-group",
severity=Severity.medium,
total_findings=1,
)
ScanSummary.objects.create(
tenant_id=tenant.id,
scan=scan,
check_id="stale_check",
service="stale-service",
severity=Severity.medium,
region="stale-region",
total=1,
)
AttackSurfaceOverview.objects.create(
tenant_id=tenant.id,
scan=scan,
attack_surface_type=AttackSurfaceOverview.AttackSurfaceTypeChoices.SECRETS,
total_findings=1,
)
finding = MagicMock()
finding.uid = "dup_probe_finding"
finding.status = StatusChoices.PASS
finding.status_extended = "x"
finding.severity = Severity.medium
finding.check_id = "check1"
finding.get_metadata.return_value = {"key": "value"}
finding.resource_uid = "resource_uid"
finding.resource_name = "resource_name"
finding.region = "region"
finding.service_name = "service_name"
finding.resource_type = "resource_type"
finding.resource_tags = {}
finding.muted = False
finding.raw = {}
finding.resource_metadata = {}
finding.resource_details = {}
finding.partition = "partition"
finding.compliance = {}
mock_scan_instance = MagicMock()
mock_scan_instance.scan.return_value = [(100, [finding])]
mock_prowler_scan_class.return_value = mock_scan_instance
mock_provider_instance = MagicMock()
mock_provider_instance.get_regions.return_value = ["region"]
mock_initialize_prowler_provider.return_value = mock_provider_instance
# Run the same scan twice (simulating an orphan-recovery re-run).
perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
perform_prowler_scan(tenant_id, scan_id, provider_id, ["check1"])
# Neither findings nor resources are duplicated by the re-run: findings are
# scope-deleted before re-insert; resources are upserted by (tenant, provider, uid).
assert Finding.objects.filter(scan=scan).count() == 1
assert Resource.objects.filter(provider=provider).count() == 2
assert ResourceScanSummary.objects.filter(scan_id=scan.id).count() == 1
assert not ResourceScanSummary.objects.filter(
scan_id=scan.id, resource_id=stale_resource.id
).exists()
assert not ScanCategorySummary.objects.filter(scan=scan).exists()
assert not ScanGroupSummary.objects.filter(scan=scan).exists()
assert not ScanSummary.objects.filter(
scan=scan, check_id="stale_check"
).exists()
assert not AttackSurfaceOverview.objects.filter(scan=scan).exists()
@patch("tasks.jobs.scan.ProwlerScan")
@patch(
"tasks.jobs.scan.initialize_prowler_provider",
@@ -443,6 +315,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
"name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -476,6 +349,7 @@ class TestPerformScan:
resource_instance = MagicMock()
resource_instance.uid = finding.resource_uid
resource_instance.name = "old_name"
resource_instance.region = "us-west-1"
resource_instance.service = "old_service"
resource_instance.type = "old_type"
@@ -494,6 +368,7 @@ class TestPerformScan:
provider=provider_instance,
uid=finding.resource_uid,
defaults={
"name": finding.resource_name,
"region": finding.region,
"service": finding.service_name,
"type": finding.resource_type,
@@ -501,6 +376,7 @@ class TestPerformScan:
)
# Check that resource fields were updated
assert resource_instance.name == finding.resource_name
assert resource_instance.region == finding.region
assert resource_instance.service == finding.service_name
assert resource_instance.type == finding.resource_type
@@ -1693,6 +1569,75 @@ class TestProcessFindingMicroBatch:
assert resource_cache[finding.resource_uid].service == finding.service_name
assert tag_cache.keys() == {("team", "devsec")}
def test_process_finding_micro_batch_refreshes_empty_resource_name(
self, tenants_fixture, scans_fixture
):
tenant = tenants_fixture[0]
scan = scans_fixture[0]
provider = scan.provider
# Old resource stored before names were persisted: empty name.
existing_resource = Resource.objects.create(
tenant_id=tenant.id,
provider=provider,
uid="arn:aws:s3:::my-bucket",
name="",
region="us-east-1",
service="s3",
type="bucket",
)
finding = FakeFinding(
uid="finding-empty-name",
status=StatusChoices.PASS,
status_extended="passing",
severity=Severity.low,
check_id="s3_bucket_public_access",
resource_uid=existing_resource.uid,
resource_name="my-bucket",
region="us-east-1",
service_name="s3",
resource_type="bucket",
partition="aws",
raw={"status": "PASS"},
metadata={"source": "prowler"},
)
resource_cache = {existing_resource.uid: existing_resource}
tag_cache = {}
last_status_cache = {}
resource_failed_findings_cache = {existing_resource.uid: 0}
unique_resources: set[tuple[str, str]] = set()
scan_resource_cache: set[tuple[str, str, str, str]] = set()
mute_rules_cache = {}
scan_categories_cache: dict[tuple[str, str], dict[str, int]] = {}
scan_resource_groups_cache: dict[tuple[str, str], dict[str, int]] = {}
group_resources_cache: dict[str, set] = {}
with (
patch("tasks.jobs.scan.rls_transaction", new=noop_rls_transaction),
patch("api.db_utils.rls_transaction", new=noop_rls_transaction),
):
_process_finding_micro_batch(
str(tenant.id),
[finding],
scan,
provider,
resource_cache,
tag_cache,
last_status_cache,
resource_failed_findings_cache,
unique_resources,
scan_resource_cache,
mute_rules_cache,
scan_categories_cache,
scan_resource_groups_cache,
group_resources_cache,
)
existing_resource.refresh_from_db()
assert existing_resource.name == finding.resource_name
def test_process_finding_micro_batch_skips_long_uid(
self, tenants_fixture, scans_fixture
):
Generated
+28 -23
View File
@@ -163,7 +163,7 @@ constraints = [
{ name = "drf-simple-apikey", specifier = "==2.2.1" },
{ name = "drf-spectacular", specifier = "==0.27.2" },
{ name = "drf-spectacular-jsonapi", specifier = "==0.5.1" },
{ name = "dulwich", specifier = "==0.23.0" },
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "duo-client", specifier = "==5.5.0" },
{ name = "durationpy", specifier = "==0.10" },
{ name = "email-validator", specifier = "==2.2.0" },
@@ -291,7 +291,7 @@ constraints = [
{ name = "pydantic-core", specifier = "==2.41.5" },
{ name = "pygithub", specifier = "==2.8.0" },
{ name = "pygments", specifier = "==2.20.0" },
{ name = "pyjwt", specifier = "==2.12.1" },
{ name = "pyjwt", specifier = "==2.13.0" },
{ name = "pylint", specifier = "==3.2.5" },
{ name = "pymsalruntime", specifier = "==0.18.1" },
{ name = "pynacl", specifier = "==1.6.2" },
@@ -374,8 +374,10 @@ constraints = [
{ name = "zstd", specifier = "==1.5.7.3" },
]
overrides = [
{ name = "dulwich", specifier = "==1.2.5" },
{ name = "microsoft-kiota-abstractions", specifier = "==1.9.9" },
{ name = "okta", specifier = "==3.4.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" },
]
[[package]]
@@ -393,7 +395,7 @@ version = "1.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "requests" },
]
@@ -1074,7 +1076,7 @@ dependencies = [
{ name = "pkginfo" },
{ name = "psutil", marker = "sys_platform != 'cygwin'" },
{ name = "py-deviceid" },
{ name = "pyjwt" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "pyopenssl" },
{ name = "requests", extra = ["socks"] },
]
@@ -2457,7 +2459,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "django" },
{ name = "djangorestframework" },
{ name = "pyjwt" },
{ name = "pyjwt", extra = ["crypto"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
wheels = [
@@ -2576,24 +2578,27 @@ wheels = [
[[package]]
name = "dulwich"
version = "0.23.0"
version = "1.2.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/ac/ba58cf420640c7bc77ae8e1b31e174d83c9117750c63cf9ea3b5e202e5c4/dulwich-0.23.0.tar.gz", hash = "sha256:0aa6c2489dd5e978b27e9b75983b7331a66c999f0efc54ebe37cab808ed322ae", size = 575116, upload-time = "2025-06-21T17:56:47.494Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7f/85/ceb8ecff5cdeee4ceeebb86b599476dee559041dacc6c2c50cc0d4711549/dulwich-1.2.5.tar.gz", hash = "sha256:0395b2c8924c3424bafe2d9c1edd5348cc4b21ce9c1d6655bf01f9a5c47164c8", size = 1253230, upload-time = "2026-05-28T22:27:55.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/11/f6bbba8583f69cf19ef4bd7f5fde1a6b5ccaf8b6951781cec8db247116f4/dulwich-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d68498fdda13ab00791b483daab3bcfe9f9721c037aa458695e6ad81640c57cc", size = 972658, upload-time = "2025-06-21T17:56:13.505Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9d/2720e0ab58666378a33c752a61543f936cd6b06dfe5d84a2215ddc0914b0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:cb7bb930b12471a1cfcea4b3d25a671dc0ad32573f0ad25684684298959a1527", size = 1049813, upload-time = "2025-06-21T17:56:14.884Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f3/81d8075141dfcc0a0449c2093596e58d3e11444e3af54e819eca63b84dd0/dulwich-0.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2abbce32fd2bc7902bcc5f69b10bf22576810de21651baaa864b78fd7aec261", size = 1051639, upload-time = "2025-06-21T17:56:16.437Z" },
{ url = "https://files.pythonhosted.org/packages/4f/0d/c06ccb227b096aef5906142fe78b5c79f9070a0ea6152fc219941186d540/dulwich-0.23.0-cp311-cp311-win32.whl", hash = "sha256:9e3151f10ce2a9ff91bca64c74345217f53bdd947dc958032343822009832f7a", size = 642918, upload-time = "2025-06-21T17:56:18.373Z" },
{ url = "https://files.pythonhosted.org/packages/d7/1c/1e99aa34c9aead9e641b2d9934f0a3d00257f75027cf5cdecc8a1a6c18ae/dulwich-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:3ae9f1d9dc92d4e9a3f89ba2c55221f7b6442c5dd93b3f6f539a3c9eb3f37bdd", size = 659010, upload-time = "2025-06-21T17:56:19.947Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d7/1e6fba0235babe912e8467b036062e37d11672cbbeb0d8074f9d4559057b/dulwich-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:52cdef66a7994d29528ca79ca59452518bbba3fd56a9c61c61f6c467c1c7956e", size = 960292, upload-time = "2025-06-21T17:56:21.308Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6a/23f0c487ec03f2752600cab4a8e0dedb38186246c475bf3fa90a8db830d5/dulwich-0.23.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d473888a6ab9ed5d4a4c3f053cbe5b77f72d54b6efdf5688fed76094316e571e", size = 1047892, upload-time = "2025-06-21T17:56:22.989Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e2/8f3d216be5fd0ee1180d917b59b34b54b9896384cf139f319b5d3a8f16b4/dulwich-0.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:19fcf20224c641a61c774da92f098fbaae9938c7e17a52841e64092adf7e78f9", size = 1048699, upload-time = "2025-06-21T17:56:24.602Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c4/18e6223cd4ad1ae9334eb4e6aa5952fd8f5c3d75762918eb90c209fec4ba/dulwich-0.23.0-cp312-cp312-win32.whl", hash = "sha256:7fc8b76b704ef35cd001e993e3aa4e1d666a2064bf467c07c560f12b2959dcaf", size = 641268, upload-time = "2025-06-21T17:56:26.18Z" },
{ url = "https://files.pythonhosted.org/packages/b8/9c/65bfbbac62d8a2967e13f6a1512371c5eb6b906a61fb6dead992669cad0e/dulwich-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:cb0566b888b578325350b4d67c61a0de35d417e9877560e3a6df88cae4576a59", size = 657837, upload-time = "2025-06-21T17:56:27.821Z" },
{ url = "https://files.pythonhosted.org/packages/35/31/49318ee9db4b402e6d8b9b01bd4cae9298f59e1bb9bd56cf4a94e48fa069/dulwich-0.23.0-py3-none-any.whl", hash = "sha256:d8da6694ca332bb48775e35ee2215aa4673821164a91b83062f699c69f7cd135", size = 313776, upload-time = "2025-06-21T17:56:46.221Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/654ae1671610fdf6b65a64586ad67ddd8550d4d08a632b2a4b9614754b6d/dulwich-1.2.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:556593fd11637f80f6018bee1916b1a84f5b420423b470ebb3f1a782ad6ef081", size = 1399277, upload-time = "2026-05-28T22:27:00.801Z" },
{ url = "https://files.pythonhosted.org/packages/85/d8/06ee3bc8eded4bd7adf8adf0c9ea5f19bf96f7e5e626bfaf7311cde4208a/dulwich-1.2.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a70477c991e96cfe8fdd7c866e7251faf71b38bfeb51d6f27554c9cce1caabf3", size = 1382310, upload-time = "2026-05-28T22:27:02.216Z" },
{ url = "https://files.pythonhosted.org/packages/07/17/a03adf50b9095f9f5d863393f21d585dea39bdc4fdf60788ff3a9407a512/dulwich-1.2.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9008ef25cabd379cda4fa86000fc38ca14b72afe17db798a8c85c0b2b7ce4d1e", size = 1470993, upload-time = "2026-05-28T22:27:04.075Z" },
{ url = "https://files.pythonhosted.org/packages/60/58/1dc352d2a5e80befe4338af7208febb44bcfd7496b0dde5ac6dacb07b031/dulwich-1.2.5-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a5549f4afc973e0a15ea6b0244d57f848d3f3ee13dac557eb311024aebebf128", size = 1497820, upload-time = "2026-05-28T22:27:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/c1/a8/e058959a87e7df7753b112ef66a43ccbc57338c1bbdc23a0edf3833396df/dulwich-1.2.5-cp311-cp311-win32.whl", hash = "sha256:5108acead814d1de8b6262d6d8fb90af7e82f5a4d83788b6b48e39d01800a92f", size = 1066549, upload-time = "2026-05-28T22:27:06.832Z" },
{ url = "https://files.pythonhosted.org/packages/33/91/ff0b444f686718635348986bd73dfce42e947912417893de35de399b878b/dulwich-1.2.5-cp311-cp311-win_amd64.whl", hash = "sha256:5e067b7feceb7034bc99e7c7143a704f1d97d4be7027d9a0aa5a83c0657ff091", size = 1079481, upload-time = "2026-05-28T22:27:08.33Z" },
{ url = "https://files.pythonhosted.org/packages/19/22/4f75770bbe5521cac61c4820ef46d4fbf8c2175d3519ba3d0378d4ba798e/dulwich-1.2.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:701a9ecf7a8a44f5e2459e46befa93530cf36a8b1ae3140aefc007db1d7d0207", size = 1396522, upload-time = "2026-05-28T22:27:09.997Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b1/c07c347681c0cf6acd4b189bf6e8d6207c71a1347b7a1e865eb40faa46b9/dulwich-1.2.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f90d68bfa97c4ca71de7507984365aefe27b6d248cb28dc99644d0f3ae8c60b", size = 1334826, upload-time = "2026-05-28T22:27:11.582Z" },
{ url = "https://files.pythonhosted.org/packages/13/80/6818eb7ce492e18ab2efa92ab901d173b4b0b159e5681c1424f329600c40/dulwich-1.2.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:00b54a1d56ddbacdd8eadd6d4787a51b3a05fefa30eadbf9165fd283a00b90ed", size = 1416616, upload-time = "2026-05-28T22:27:13.195Z" },
{ url = "https://files.pythonhosted.org/packages/14/a7/9790e60d19870f6554f7583722bb324c1355784316f20aeda1c0b5b1491a/dulwich-1.2.5-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d8f7ea8f47e38e5b0de3fab97e07e9c9161ffddc90b3964512cab2b7749df4e6", size = 1441354, upload-time = "2026-05-28T22:27:14.683Z" },
{ url = "https://files.pythonhosted.org/packages/91/44/0ea8a69c24aa1254ff5996d682eae2eab287d471b937dcdb26d9ea9720b4/dulwich-1.2.5-cp312-cp312-win32.whl", hash = "sha256:8929134acf4ff967203df7600b38535f9b5b590462067a7e30dbce01acb97af9", size = 1017058, upload-time = "2026-05-28T22:27:16.121Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/2fcddda7faec3bae52db7c64bfcb5dc756f597f33fae90e8d4e4b4d3b39b/dulwich-1.2.5-cp312-cp312-win_amd64.whl", hash = "sha256:9693d2c9e226b2ea855c1dc3a87e2f4d972f7523fc0f7924e5997e9f4c23d97f", size = 1031731, upload-time = "2026-05-28T22:27:17.633Z" },
{ url = "https://files.pythonhosted.org/packages/07/4b/4a18a59ad230581cd0ef460e96001f90762e566dc2dfdba22aa358eb5a0e/dulwich-1.2.5-py3-none-any.whl", hash = "sha256:1679b376433a0fc7f36586afda1d4ed7427afa7a79d4bf17e5014474eea69fa4", size = 686745, upload-time = "2026-05-28T22:27:53.695Z" },
]
[[package]]
@@ -4031,7 +4036,7 @@ dependencies = [
{ name = "pycryptodomex" },
{ name = "pydantic" },
{ name = "pydash" },
{ name = "pyjwt" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
@@ -4873,11 +4878,11 @@ wheels = [
[[package]]
name = "pyjwt"
version = "2.12.1"
version = "2.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
]
[package.optional-dependencies]
@@ -5785,7 +5790,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "httpx" },
{ name = "pyjwt" },
{ name = "pyjwt", extra = ["crypto"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/3c/2f/99fb8718274116c5c146c745755620fd5c5943f78ca52ca9b17e94348286/workos-6.0.4.tar.gz", hash = "sha256:b0bfe8fd212b8567422c4ea3732eb33608794033eb3a69900c6b04db183c32d6", size = 172217, upload-time = "2026-04-16T03:09:28.583Z" }
wheels = [
+180
View File
@@ -1538,6 +1538,186 @@ def get_section_container_iso(data, section_1, section_2):
return html.Div(section_containers, className="compliance-data-layout")
def _status_bar(success, failed, classname):
"""Build the stacked PASS/FAIL bar shown next to an accordion title."""
fig = go.Figure(
data=[
go.Bar(
name="Failed",
x=[failed],
y=[""],
orientation="h",
marker=dict(color="#e77676"),
width=[0.8],
),
go.Bar(
name="Success",
x=[success],
y=[""],
orientation="h",
marker=dict(color="#45cc6e"),
width=[0.8],
),
]
)
fig.update_layout(
barmode="stack",
margin=dict(l=10, r=10, t=10, b=10),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
showlegend=False,
width=350,
height=30,
xaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
yaxis=dict(showticklabels=False, showgrid=False, zeroline=False),
annotations=[
dict(
x=success + failed,
y=0,
xref="x",
yref="y",
text=str(success),
showarrow=False,
font=dict(color="#45cc6e", size=14),
xanchor="left",
yanchor="middle",
),
dict(
x=0,
y=0,
xref="x",
yref="y",
text=str(failed),
showarrow=False,
font=dict(color="#e77676", size=14),
xanchor="right",
yanchor="middle",
),
],
)
fig.add_annotation(
x=failed,
y=0.3,
text="|",
showarrow=False,
xanchor="center",
yanchor="middle",
font=dict(size=20),
)
return dcc.Graph(figure=fig, config={"staticPlot": True}, className=classname)
def get_section_containers_generic(data, section_col, id_col):
"""Two-level view: section -> requirement id (+ description) -> checks.
Sorts lexicographically so arbitrary requirement IDs never crash the
version-aware sort used by the CIS renderer.
"""
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
data[section_col] = data[section_col].astype(str)
data[id_col] = data[id_col].astype(str)
data.sort_values(by=[section_col, id_col], inplace=True)
counts_section = data.groupby([section_col, "STATUS"]).size().unstack(fill_value=0)
counts_id = (
data.groupby([section_col, id_col, "STATUS"]).size().unstack(fill_value=0)
)
def count(counts, key, emoji):
return counts.loc[key, emoji] if emoji in counts.columns else 0
has_description = "REQUIREMENTS_DESCRIPTION" in data.columns
table_cols = ["CHECKID", "STATUS", "REGION", "ACCOUNTID", "RESOURCEID"]
section_containers = []
for section in data[section_col].unique():
graph_div = html.Div(
_status_bar(
count(counts_section, section, pass_emoji),
count(counts_section, section, fail_emoji),
"info-bar",
),
className="graph-section",
)
internal_items = []
for req_id in data[data[section_col] == section][id_col].unique():
specific_data = data[
(data[section_col] == section) & (data[id_col] == req_id)
]
data_table = dash_table.DataTable(
data=specific_data.to_dict("records"),
columns=[
{"name": i, "id": i}
for i in table_cols
if i in specific_data.columns
],
style_table={"overflowX": "auto"},
style_as_list_view=True,
style_cell={"textAlign": "left", "padding": "5px"},
)
graph_div_req = html.Div(
_status_bar(
count(counts_id, (section, req_id), pass_emoji),
count(counts_id, (section, req_id), fail_emoji),
"info-bar-child",
),
className="graph-section-req",
)
title = req_id
if has_description:
title = (
f"{req_id} - {specific_data['REQUIREMENTS_DESCRIPTION'].iloc[0]}"
)
if len(title) > 130:
title = title[:130] + " ..."
internal_items.append(
html.Div(
[
graph_div_req,
dbc.Accordion(
[
dbc.AccordionItem(
title=title,
children=[
html.Div(
[data_table],
className="inner-accordion-content",
)
],
)
],
start_collapsed=True,
flush=True,
),
],
className="accordion-inner--child",
)
)
section_containers.append(
html.Div(
[
graph_div,
dbc.Accordion(
[
dbc.AccordionItem(
title=f"{section}", children=internal_items
)
],
start_collapsed=True,
flush=True,
),
],
className="accordion-inner",
)
)
return html.Div(section_containers, className="compliance-data-layout")
def get_section_containers_format4(data, section_1):
data["STATUS"] = data["STATUS"].apply(map_status_to_icon)
@@ -0,0 +1,27 @@
import warnings
from dashboard.common_methods import get_section_containers_3_levels
warnings.filterwarnings("ignore")
def get_table(data):
aux = data[
[
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"NAME",
"CHECKID",
"STATUS",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
]
return get_section_containers_3_levels(
aux,
"REQUIREMENTS_ATTRIBUTES_SECTION",
"REQUIREMENTS_ATTRIBUTES_SUBSECTION",
"NAME",
)
+44
View File
@@ -0,0 +1,44 @@
import warnings
from dashboard.common_methods import (
get_section_containers_format4,
get_section_containers_generic,
)
warnings.filterwarnings("ignore")
def get_table(data):
# Discover REQUIREMENTS_ATTRIBUTES_* columns at runtime.
attr_cols = [c for c in data.columns if c.startswith("REQUIREMENTS_ATTRIBUTES_")]
# Section column (in priority order):
# 1. REQUIREMENTS_ATTRIBUTES_SECTION — most common convention
# 2. First discovered attribute column — covers novel schemas
# 3. None — no section, group flat by requirement id
if "REQUIREMENTS_ATTRIBUTES_SECTION" in attr_cols:
section_col = "REQUIREMENTS_ATTRIBUTES_SECTION"
elif attr_cols:
section_col = attr_cols[0]
else:
section_col = None
base_cols = [
"REQUIREMENTS_ID",
"REQUIREMENTS_DESCRIPTION",
"STATUS",
"CHECKID",
"REGION",
"ACCOUNTID",
"RESOURCEID",
]
# Two levels (section -> requirement id) when a section distinct from the
# id exists; otherwise group flat by requirement id.
if section_col and section_col != "REQUIREMENTS_ID":
needed = [section_col] + base_cols
aux = data[[c for c in needed if c in data.columns]].copy()
return get_section_containers_generic(aux, section_col, "REQUIREMENTS_ID")
aux = data[[c for c in base_cols if c in data.columns]].copy()
return get_section_containers_format4(aux, "REQUIREMENTS_ID")
+1 -1
View File
@@ -156,7 +156,7 @@ def create_layout_compliance(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
href="https://prowler.pro/",
href="https://cloud.prowler.com/",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
+57 -31
View File
@@ -215,6 +215,58 @@ else:
)
def _ensure_scope_columns(data):
"""Guarantee ACCOUNTID and REGION exist.
Scope columns always sit between DESCRIPTION and ASSESSMENTDATE, so derive
them positionally for any provider (e.g. Okta's ORGANIZATIONDOMAIN) and
fall back to "-" to avoid a KeyError.
"""
cols = list(data.columns)
scope = []
if "DESCRIPTION" in cols and "ASSESSMENTDATE" in cols:
start, end = cols.index("DESCRIPTION") + 1, cols.index("ASSESSMENTDATE")
scope = [c for c in cols[start:end] if c not in ("ACCOUNTID", "REGION")]
if "ACCOUNTID" not in data.columns:
if scope:
data.rename(columns={scope.pop(0): "ACCOUNTID"}, inplace=True)
else:
data["ACCOUNTID"] = "-"
if "REGION" not in data.columns:
if scope:
data.rename(columns={scope.pop(0): "REGION"}, inplace=True)
else:
data["REGION"] = "-"
return data
def _dispatch_compliance_renderer(data, analytics_input):
"""Resolve the compliance renderer module and return (table, deduped_data).
Tries to import the framework-specific builtin module. On
ModuleNotFoundError (dynamic/external provider with no dedicated module),
falls back to the generic renderer. Any other ImportError is re-raised.
get_table() is called OUTSIDE the try block so errors inside the renderer
surface as real exceptions rather than being swallowed.
"""
current = analytics_input.replace(".", "_")
target = f"dashboard.compliance.{current}"
try:
module = importlib.import_module(target)
except ModuleNotFoundError as exc:
if exc.name != target:
raise
from dashboard.compliance import generic as module
dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
if "MUTED" in data.columns:
dedup_columns.insert(2, "MUTED")
data = data.drop_duplicates(subset=dedup_columns)
if "threatscore" in analytics_input:
data = get_threatscore_mean_by_pillar(data)
return module.get_table(data), data
@callback(
[
Output("output", "children"),
@@ -292,7 +344,7 @@ def display_data(
data.rename(columns={"TENANCYID": "ACCOUNTID"}, inplace=True)
# Filter the chosen level of the CIS
if is_level_1:
if is_level_1 and "REQUIREMENTS_ATTRIBUTES_PROFILE" in data.columns:
data = data[data["REQUIREMENTS_ATTRIBUTES_PROFILE"].str.contains("Level 1")]
# Rename the column PROJECTID to ACCOUNTID for GCP
@@ -314,6 +366,9 @@ def display_data(
data.rename(columns={"SUBSCRIPTION": "ACCOUNTID"}, inplace=True)
data["REGION"] = "-"
# Normalize scope columns for any remaining (e.g. dynamic) provider.
data = _ensure_scope_columns(data)
# Filter ACCOUNT
if account_filter == ["All"]:
updated_cloud_account_values = data["ACCOUNTID"].unique()
@@ -409,36 +464,7 @@ def display_data(
# Check cases where the compliance start with AWS_
if "aws_" in analytics_input:
analytics_input = analytics_input + "_aws"
try:
current = analytics_input.replace(".", "_")
compliance_module = importlib.import_module(
f"dashboard.compliance.{current}"
)
# Build subset list based on available columns
dedup_columns = ["CHECKID", "STATUS", "RESOURCEID", "STATUSEXTENDED"]
if "MUTED" in data.columns:
dedup_columns.insert(2, "MUTED")
data = data.drop_duplicates(subset=dedup_columns)
if "threatscore" in analytics_input:
data = get_threatscore_mean_by_pillar(data)
table = compliance_module.get_table(data)
except ModuleNotFoundError:
table = html.Div(
[
html.H5(
"No data found for this compliance",
className="card-title",
style={"text-align": "left", "color": "black"},
)
],
style={
"width": "99%",
"margin-right": "0.8%",
"margin-bottom": "10px",
},
)
table, data = _dispatch_compliance_renderer(data, analytics_input)
df = data.copy()
# Remove Muted rows
+1 -1
View File
@@ -1538,7 +1538,7 @@ def filter_data(
html.Img(src="assets/favicon.ico", className="w-5 mr-3"),
html.Span("Subscribe to Prowler Cloud"),
],
href="https://prowler.pro/",
href="https://cloud.prowler.com/",
target="_blank",
className="text-prowler-stone-900 inline-flex px-4 py-2 text-xs font-bold uppercase transition-all rounded-lg text-gray-900 hover:bg-prowler-stone-900/10 border-solid border-1 hover:border-prowler-stone-900/10 hover:border-solid hover:border-1 border-prowler-stone-900/10",
),
+75 -36
View File
@@ -8,7 +8,77 @@ This guide explains the AI Skills system that provides on-demand context and pat
**What are AI Skills?** Skills are structured instructions that help AI agents (Claude Code, Cursor, Copilot, etc.) understand Prowler's conventions, patterns, and best practices.
</Info>
## Architecture Overview
Skills live in the [`skills/`](https://github.com/prowler-cloud/prowler/tree/master/skills) directory of the Prowler OSS repository. Each skill is a folder containing a `SKILL.md` file with its patterns and metadata.
## Installation
To enable skills for the supported AI coding assistants, run the setup script from the repository root:
```bash
./skills/setup.sh
```
The script creates symlinks so each tool finds the skills in its expected location:
| Tool | Created by setup |
|------|------------------|
| Claude Code | `.claude/skills/` symlink and `CLAUDE.md` |
| Gemini CLI | `.gemini/skills/` symlink and `GEMINI.md` |
| Codex (OpenAI) | `.codex/skills/` symlink (uses `AGENTS.md` natively) |
| GitHub Copilot | `.github/copilot-instructions.md` symlink to `AGENTS.md` |
After running the setup, restart the AI coding assistant to load the skills.
## Using Skills
AI agents discover skills automatically and load them when a request matches a skill trigger. To load a skill manually during a session, point the agent to the skill's `SKILL.md` file:
```text
Read skills/{skill-name}/SKILL.md
```
For the full list of available skills, their triggers, and the Auto-invoke mappings, see the [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) and [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) in the repository.
## Available Skills
| Type | Skills |
|------|--------|
| **Generic** | typescript, react-19, nextjs-16, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5, vitest, tdd |
| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci, prowler-attack-paths-query |
| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
| **Meta** | skill-creator, skill-sync |
<Note>
This table is a snapshot. The repository is the source of truth: see [`skills/README.md`](https://github.com/prowler-cloud/prowler/blob/master/skills/README.md) for the current, complete list.
</Note>
## Skill Structure
Each skill follows the [Agent Skills spec](https://agentskills.io):
```text
skills/{skill-name}/
├── SKILL.md # Patterns, rules, decision trees
├── assets/ # Code templates, schemas
└── references/ # Links to local docs (single source of truth)
```
## Key Design Decisions
1. **Self-contained skills** - Critical patterns inline for fast loading
2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
3. **Single source of truth** - Skills reference docs, no duplication
4. **On-demand loading** - AI loads only what's needed for the task
## Creating New Skills
Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See [`AGENTS.md`](https://github.com/prowler-cloud/prowler/blob/master/AGENTS.md) for the full list of available skills and their triggers.
## How Skills Work
The diagrams below explain the internals of the skill system. They are useful for understanding the design, but are not required to install or use skills.
### Architecture Overview
```mermaid
graph LR
@@ -28,7 +98,7 @@ graph LR
style F fill:#1a4d2e,stroke:#66bb6a,color:#fff
```
## How It Works
### Request Lifecycle
```mermaid
sequenceDiagram
@@ -68,7 +138,7 @@ sequenceDiagram
A->>U: Creates check with correct patterns
```
## Before vs After
### With and Without Skills
```mermaid
graph TD
@@ -96,7 +166,7 @@ graph TD
style AFTER fill:#1a4d1a,stroke:#66bb6a,color:#fff
```
## Complete Architecture
### Full Component Map
```mermaid
flowchart TB
@@ -110,7 +180,7 @@ flowchart TB
subgraph GENERIC["Generic Skills"]
G1["typescript"]
G2["react-19"]
G3["nextjs-15"]
G3["nextjs-16"]
G4["tailwind-4"]
G5["pytest"]
G6["playwright"]
@@ -186,34 +256,3 @@ flowchart TB
style STRUCTURE fill:#5c3d1a,stroke:#ffb74d,color:#fff
style DOCS fill:#1a3d4d,stroke:#4dd0e1,color:#fff
```
## Skills Included
| Type | Skills |
|------|--------|
| **Generic** | typescript, react-19, nextjs-15, tailwind-4, pytest, playwright, django-drf, zod-4, zustand-5, ai-sdk-5 |
| **Prowler** | prowler, prowler-sdk-check, prowler-api, prowler-ui, prowler-mcp, prowler-provider, prowler-compliance, prowler-compliance-review, prowler-docs, prowler-pr, prowler-ci |
| **Testing** | prowler-test-sdk, prowler-test-api, prowler-test-ui |
| **Meta** | skill-creator, skill-sync |
## Skill Structure
Each skill follows the [Agent Skills spec](https://agentskills.io):
```
skills/{skill-name}/
├── SKILL.md # Patterns, rules, decision trees
├── assets/ # Code templates, schemas
└── references/ # Links to local docs (single source of truth)
```
## Key Design Decisions
1. **Self-contained skills** - Critical patterns inline for fast loading
2. **Local doc references** - No web URLs, points to `docs/developer-guide/*.mdx`
3. **Single source of truth** - Skills reference docs, no duplication
4. **On-demand loading** - AI loads only what's needed for the task
## Creating New Skills
Use the `skill-creator` meta-skill to create new skills that follow the Agent Skills spec. See `AGENTS.md` for the full list of available skills and their triggers.
@@ -35,14 +35,28 @@ The bundled checks require the following read-only scopes:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
- `okta.authenticators.read`
- `okta.networkZones.read`
- `okta.apiTokens.read`
- `okta.roles.read`
- `okta.groups.read`
- `okta.logStreams.read`
- `okta.idps.read`
Additional scopes will be needed as more services and checks are added. These are the current ones needed:
| Scope | Used by |
|---|---|
| `okta.policies.read` | Sign-on, password, and authentication policies |
| `okta.policies.read` | Sign-on, password, authentication, and `USER_LIFECYCLE` (Workflow > Automations) policies |
| `okta.brands.read` | Sign-in page customizations (DOD Notice and Consent Banner check) |
| `okta.apps.read` | First-party app settings (Okta Admin Console session), integrated app inventory, and the Authentication Policies bound to Okta applications |
| `okta.authenticators.read` | Okta authenticator configuration, including Okta Verify and Smart Card IdP |
| `okta.networkZones.read` | Network Zone inventory, anonymized-proxy blocklist checks, and API token Network Zone validation |
| `okta.apiTokens.read` | API token metadata and token network conditions |
| `okta.roles.read` | Admin role assignments for API token owners (both direct and group-inherited) |
| `okta.groups.read` | Group memberships of API token owners, used to resolve admin roles inherited via group assignment (e.g. Super Admin granted through the default admin group) |
| `okta.logStreams.read` | Log Stream configuration (`/api/v1/logStreams`) |
| `okta.idps.read` | Identity Providers, including Smart Card (X509) IdPs (`/api/v1/idps`) |
### Required Admin Role
@@ -68,7 +82,9 @@ Okta filters the first-party apps (`saasure`, `okta_enduser`) out of `/api/v1/ap
A fifth check — `application_admin_console_session_idle_timeout_15min` (STIG V-273187) — also requires Super Administrator: it calls `GET /api/v1/first-party-app-settings/admin-console`, which returns `403 E0000006` for every role below Super Administrator.
When the service app runs with Read-Only Administrator, the five checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
`user_inactivity_automation_35d_enabled` (STIG V-273188) reads `USER_LIFECYCLE` policies (`list_policies(type='USER_LIFECYCLE')`) using the `okta.policies.read` scope. The Read-Only Administrator role is enough to list them; no Super Administrator requirement.
When the service app runs with Read-Only Administrator, the checks listed in this section return **MANUAL** instead of PASS/FAIL — the rest of the scan keeps running.
<Note>
Read-Only Administrator stays the recommended default for the least-privilege framing that aligns with DISA STIG. Assign Super Administrator on a separate run when full coverage of the first-party app checks is needed.
@@ -122,7 +138,7 @@ Okta displays the private key **only once**. If you close the modal without copy
### 5. Grant the required OAuth scopes
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, and `okta.apps.read`.
On the app, open the **Okta API Scopes** tab and click **Grant** on every scope Prowler needs. The bundled checks require `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`.
![Okta — grant OAuth scopes](/user-guide/providers/okta/images/grant-permissions.png)
@@ -158,8 +174,8 @@ export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# or
export OKTA_PRIVATE_KEY="$(cat /secure/path/to/prowler-okta.pem)"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
uv run python prowler-cli.py okta
```
@@ -200,7 +216,7 @@ Prowler validates credentials at startup by listing one sign-on policy. This err
Raised when the credential probe succeeds at the OAuth layer but the request is rejected because the service app lacks the required scope or admin role:
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, or `okta.apps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`invalid_scope`** — one of the requested scopes (`okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read`) is not granted on the service app. Grant the missing scope from **Okta API Scopes**.
- **`Forbidden` / `not authorized`** — no admin role is assigned to the service app. Assign **Read-Only Administrator** (or **Super Administrator** for the first-party application checks) from **Admin roles**.
### Application-service checks return MANUAL on first-party apps
@@ -12,7 +12,7 @@ Set up authentication for Okta with the [Okta Authentication](/user-guide/provid
- An Okta organization. The UI examples below use **Identity Engine** terminology such as **Global Session Policy**; Classic Engine exposes the equivalent sign-on policy concepts under older names.
- A **Super Administrator** account on that organization for the one-time service-app setup.
- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, and `okta.apps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers every `signon` check and runs the per-app network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- An **API Services** app integration in the Okta Admin Console with the `okta.policies.read`, `okta.brands.read`, `okta.apps.read`, `okta.authenticators.read`, `okta.networkZones.read`, `okta.apiTokens.read`, `okta.roles.read`, `okta.groups.read`, `okta.logStreams.read`, and `okta.idps.read` scopes granted and an admin role assigned. **Read-Only Administrator** covers the Sign-On, Network, API Token, User, System Log, and Identity Provider checks, and runs the per-app application network-zone check against the apps the service app can see (under Read-Only Administrator that is typically only the service app's own row — the rest of the org's app inventory stays invisible). **Super Administrator** is required additionally to evaluate the five first-party application checks (Okta Admin Console / Okta Dashboard idle timeout, MFA, phishing-resistant authentication) and to widen the application network-zone check to the full app inventory — see [Okta Authentication](/user-guide/providers/okta/authentication#required-admin-role) for the full breakdown.
- Python 3.10+ and Prowler 5.27.0 or later installed locally.
<CardGroup cols={2}>
@@ -85,8 +85,8 @@ Follow the [Okta Authentication](/user-guide/providers/okta/authentication) guid
export OKTA_ORG_DOMAIN="acme.okta.com"
export OKTA_CLIENT_ID="0oa1234567890abcdef"
export OKTA_PRIVATE_KEY_FILE="/secure/path/to/prowler-okta.pem"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read"
# Optional — defaults to "okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read"
```
The private key file may contain either a PEM-encoded RSA key or a JWK JSON document.
@@ -143,10 +143,16 @@ prowler okta --config-file /path/to/config.yaml
Prowler for Okta includes security checks across the following services:
| Service | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
| Service | Description |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Sign-On** | Global session policy controls (idle timeout, lifetime, rule priority and ordering) |
| **Application** | Okta Admin Console sign-on settings plus Authentication Policy controls for Okta applications (session idle, MFA, phishing resistance, network zones) |
| **Authenticator** | Password Policy controls plus Okta Verify FIPS and Smart Card IdP authenticator status |
| **Network** | Network Zone blocklists for anonymized proxy sources |
| **API Token** | API token owner-role validation and Network Zone restrictions |
| **User** | User lifecycle automations (inactivity-based deprovisioning) |
| **System Log** | Log Stream configuration that off-loads audit records to a central SIEM |
| **Identity Provider** | Identity Providers, including Smart Card (X509) IdP status and certificate-chain visibility |
## Troubleshooting
@@ -158,22 +164,29 @@ This is stricter than simply finding the same timeout value somewhere else in th
### Default Scopes
Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On and Application services:
Prowler requests a fixed set of OAuth scopes on every token exchange. The defaults cover every bundled check across the Sign-On, Application, Authenticator, Network, API Token, User, System Log, and Identity Provider services:
- `okta.policies.read`
- `okta.brands.read`
- `okta.apps.read`
- `okta.authenticators.read`
- `okta.networkZones.read`
- `okta.apiTokens.read`
- `okta.roles.read`
- `okta.groups.read`
- `okta.logStreams.read`
- `okta.idps.read`
The service app must have these scopes granted in the **Okta API Scopes** tab. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
The service app must have these scopes granted in the **Okta API Scopes** tab. `okta.groups.read` is required so the API token Super Admin check can resolve admin roles inherited via group membership; without it the check falls back to direct-only role assignments and emits a best-effort caveat. When the granted set is narrower than the requested set, the token request fails with an `invalid_scope` error and the scan stops at provider initialization.
When additional checks are enabled — or when running against a service app that exposes a different scope set — override the default with `OKTA_SCOPES` (comma-separated string for the env var) or `--okta-scopes` (space-separated list for the CLI):
```bash
# Environment variable — comma-separated
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.users.read"
export OKTA_SCOPES="okta.policies.read,okta.brands.read,okta.apps.read,okta.authenticators.read,okta.networkZones.read,okta.apiTokens.read,okta.roles.read,okta.groups.read,okta.logStreams.read,okta.idps.read,okta.users.read"
# CLI flag — space-separated
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.users.read
prowler okta --okta-scopes okta.policies.read okta.brands.read okta.apps.read okta.authenticators.read okta.networkZones.read okta.apiTokens.read okta.roles.read okta.groups.read okta.logStreams.read okta.idps.read okta.users.read
```
For the full catalog of OAuth scopes exposed by the Okta Management API, refer to the [Okta OAuth 2.0 scopes documentation](https://developer.okta.com/docs/api/oauth2/).
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
1. Navigate to **Users** from the side menu.
2. Click the delete button of your current user.
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
+3 -2
View File
@@ -12,8 +12,9 @@ reason = """
CVE-2025-45768 is disputed by the pyjwt maintainers. The advisory describes
weak encryption, but the underlying issue is that callers may pick a short
HMAC secret key-length enforcement is the application's responsibility, not
a defect in the library. We are on pyjwt 2.12.1 (latest at pin time) and
enforce key strength in our own auth code, so this advisory does not apply.
a defect in the library. We are on pyjwt 2.13.0 (which now also emits an
InsecureKeyLengthWarning for short HMAC secrets) and enforce key strength in
our own auth code, so this advisory does not apply.
Re-evaluate when a non-disputed advisory or upstream fix lands.
"""
+35
View File
@@ -6,8 +6,43 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- DISA Okta IDaaS STIG V1R2 compliance framework for the Okta provider, with a dedicated CSV output formatter and terminal summary table [(#11428)](https://github.com/prowler-cloud/prowler/pull/11428)
- `sagemaker_models_monitor_enabled` check for AWS provider, verifying that each SageMaker monitoring schedule is in the `Scheduled` state so data and model drift is actively detected [(#11278)](https://github.com/prowler-cloud/prowler/pull/11278)
- DORA (Digital Operational Resilience Act, Regulation (EU) 2022/2554) universal compliance framework with AWS provider coverage across the five DORA pillars [(#11131)](https://github.com/prowler-cloud/prowler/pull/11131)
- Okta authenticator and password policy checks for STIG-aligned hardening requirements [(#11465)](https://github.com/prowler-cloud/prowler/pull/11465)
- Okta network zone check to detect whether anonymized proxy traffic is blocked [(#11463)](https://github.com/prowler-cloud/prowler/pull/11463)
- Okta API token checks for super admin ownership and network zone restrictions [(#11464)](https://github.com/prowler-cloud/prowler/pull/11464)
- Support for external/custom providers, checks, and compliance frameworks without modifying core code [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
- External multi-provider compliance frameworks can be registered via the `prowler.compliance.universal` entry point group [(#11490)](https://github.com/prowler-cloud/prowler/pull/11490)
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
- `kms_key_rotation_max_90_days` check for GCP provider, verifying KMS customer-managed keys are rotated every 90 days or less in line with the CIS Benchmark [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
- `exchange_mailbox_primary_smtp_uses_custom_domain` check for M365 provider [(#11215)](https://github.com/prowler-cloud/prowler/pull/11215)
- `bedrock_agent_role_least_privilege` check for AWS provider, flagging Bedrock Agent execution roles with full-access managed policies, broad `Resource:*` inline statements, or missing permissions boundaries [(#11335)](https://github.com/prowler-cloud/prowler/pull/11335)
### 🐞 Fixed
- `load_and_validate_config_file` now unwraps namespaced config for every built-in and external provider, and no longer leaks the full file as the provider's config when the file is namespaced [(#10700)](https://github.com/prowler-cloud/prowler/pull/10700)
- `entra_users_mfa_capable` no longer flags pre-provisioned users with future `employeeHireDate`; future-hire date comparisons now tolerate naive datetimes [(#11511)](https://github.com/prowler-cloud/prowler/pull/11511)
- M365 Admin Center group enumeration now follows Microsoft Graph pagination so group-scoped checks include groups beyond the first page [(#11510)](https://github.com/prowler-cloud/prowler/pull/11510)
- GCP `kms_key_rotation_enabled` check now only verifies that automatic key rotation is enabled (any interval) instead of enforcing a 90-day period, resolving the mismatch between the check and its documentation; the CIS, Prowler ThreatScore, and CCC requirements that mandate a 90-day maximum were remapped to the new `kms_key_rotation_max_90_days` check [(#11516)](https://github.com/prowler-cloud/prowler/pull/11516)
---
## [5.29.3] (Prowler v5.29.3)
### 🐞 Fixed
- GCP `logging_sink_created` now recognizes organization-level aggregated sinks with `includeChildren=True`, avoiding false failures for covered projects [(#11355)](https://github.com/prowler-cloud/prowler/pull/11355)
- GCP `logging_log_metric_filter_and_alert_*` checks now recognize organization-level aggregated sinks with `includeChildren=True`, no longer false-failing projects covered by a central bucket-scoped metric + alert [(#11488)](https://github.com/prowler-cloud/prowler/pull/11488)
- Jira integration no longer fails with `400 INVALID_INPUT` when a finding has empty fields [(#11474)](https://github.com/prowler-cloud/prowler/pull/11474)
- GCP `iam_service_account_unused` now passes disabled service accounts instead of failing them, since a disabled account cannot authenticate or be used [(#11467)](https://github.com/prowler-cloud/prowler/pull/11467)
### 🔐 Security
- `dulwich` from 0.23.0 to 1.2.5 and `pyjwt` from 2.12.1 to 2.13.0, patching `GHSA-897w-fcg9-f6xj` (arbitrary file write) and `PYSEC-2026-179` (HMAC/JWK key confusion) flagged by osv-scanner [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
---
+93 -14
View File
@@ -10,7 +10,6 @@ from colorama import Fore, Style
from colorama import init as colorama_init
from prowler.config.config import (
EXTERNAL_TOOL_PROVIDERS,
cloud_api_base_url,
csv_file_suffix,
get_available_compliance_frameworks,
@@ -20,7 +19,7 @@ from prowler.config.config import (
orange_color,
sarif_file_suffix,
)
from prowler.lib.banner import print_banner
from prowler.lib.banner import print_banner, print_prowler_cloud_banner
from prowler.lib.check.check import (
exclude_checks_to_run,
exclude_services_to_run,
@@ -103,6 +102,9 @@ from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_azure import (
AzureMitreAttack,
)
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack_gcp import GCPMitreAttack
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig_okta import (
OktaIDaaSSTIG,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore_alibaba import (
ProwlerThreatScoreAlibaba,
)
@@ -200,14 +202,15 @@ def prowler():
if not args.no_banner:
legend = args.verbose or getattr(args, "fixer", None)
print_banner(legend)
print_banner(legend, provider)
# We treat the compliance framework as another output format
if compliance_framework:
args.output_formats.extend(compliance_framework)
# If no input compliance framework, set all, unless a specific service or check is input
# Skip for IAC and LLM providers that don't use compliance frameworks
elif default_execution and provider not in ["iac", "llm"]:
# If no input compliance framework, set all, unless a specific service or check is input.
# Skip for tool-wrapper providers (iac, llm, image, and any external plug-in
# declaring `is_external_tool_provider = True`) — they don't use compliance frameworks.
elif default_execution and not Provider.is_tool_wrapper_provider(provider):
args.output_formats.extend(get_available_compliance_frameworks(provider))
# Set Logger configuration
@@ -245,7 +248,7 @@ def prowler():
universal_frameworks = {}
# Skip compliance frameworks for external-tool providers
if provider not in EXTERNAL_TOOL_PROVIDERS:
if not Provider.is_tool_wrapper_provider(provider):
bulk_compliance_frameworks = Compliance.get_bulk(provider)
# Complete checks metadata with the compliance framework specification
bulk_checks_metadata = update_checks_metadata_with_compliance(
@@ -313,7 +316,7 @@ def prowler():
sys.exit()
# Skip service and check loading for external-tool providers
if provider not in EXTERNAL_TOOL_PROVIDERS:
if not Provider.is_tool_wrapper_provider(provider):
# Import custom checks from folder
if checks_folder:
custom_checks = parse_checks_from_folder(global_provider, checks_folder)
@@ -436,6 +439,20 @@ def prowler():
output_options = ScalewayOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
else:
# Dynamic fallback: any external/custom provider
try:
output_options = global_provider.get_output_options(
args, bulk_checks_metadata
)
except NotImplementedError:
# No provider-specific OutputOptions: use the generic default so the
# run still produces output instead of aborting.
from prowler.providers.common.models import default_output_options
output_options = default_output_options(
global_provider, args, bulk_checks_metadata
)
# Run the quick inventory for the provider if available
if hasattr(args, "quick_inventory") and args.quick_inventory:
@@ -445,7 +462,7 @@ def prowler():
# Execute checks
findings = []
if provider in EXTERNAL_TOOL_PROVIDERS:
if Provider.is_tool_wrapper_provider(provider):
# For external-tool providers, run the scan directly
if provider == "llm":
@@ -455,12 +472,19 @@ def prowler():
findings = global_provider.run_scan(streaming_callback=streaming_callback)
else:
# Original behavior for IAC and Image
try:
if provider == "image":
try:
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
else:
# IAC and external tool-wrapper providers registered via entry
# points. Unexpected failures propagate to the outer except
# Exception backstop further down in this file — keeping the
# branch free of an Image-specific catch that would otherwise
# mislead plug-in authors reading this code.
findings = global_provider.run()
except ImageBaseException as error:
logger.critical(f"{error}")
sys.exit(1)
# Note: External tool providers don't support granular progress tracking since
# they run external tools as a black box and return all findings at once.
# Progress tracking would just be 0% → 100%.
@@ -1293,6 +1317,57 @@ def prowler():
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
elif provider == "okta":
for compliance_name in input_compliance_frameworks:
if compliance_name.startswith("okta_idaas_stig"):
# Generate Okta IDaaS STIG Finding Object
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
okta_idaas_stig = OktaIDaaSSTIG(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(okta_idaas_stig)
okta_idaas_stig.batch_write_data_to_file()
else:
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
generic_compliance = GenericCompliance(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
else:
# Dynamic fallback: any external/custom provider
try:
global_provider.generate_compliance_output(
finding_outputs,
bulk_compliance_frameworks,
input_compliance_frameworks,
output_options,
generated_outputs,
)
except NotImplementedError:
# Last resort: generic compliance
for compliance_name in input_compliance_frameworks:
filename = (
f"{output_options.output_directory}/compliance/"
f"{output_options.output_filename}_{compliance_name}.csv"
)
generic_compliance = GenericCompliance(
findings=finding_outputs,
compliance=bulk_compliance_frameworks[compliance_name],
file_path=filename,
)
generated_outputs["compliance"].append(generic_compliance)
generic_compliance.batch_write_data_to_file()
# AWS Security Hub Integration
if provider == "aws":
@@ -1401,6 +1476,10 @@ def prowler():
f"\nDetailed compliance results are in {Fore.YELLOW}{output_options.output_directory}/compliance/{Style.RESET_ALL}\n"
)
# Promote Prowler Cloud as the last thing the user sees after the results
if not args.no_banner and not args.only_logs:
print_prowler_cloud_banner(provider)
# If custom checks were passed, remove the modules
if checks_folder:
remove_custom_checks_module(checks_folder, provider)
@@ -1863,7 +1863,9 @@
"Id": "ELB.4",
"Name": "Application load balancers should be configured to drop HTTP headers",
"Description": "This control evaluates AWS Application Load Balancers (ALB) to ensure they are configured to drop invalid HTTP headers. The control fails if the value of routing.http.drop_invalid_header_fields.enabled is set to false. By default, ALBs are not configured to drop invalid HTTP header values. Removing these header values prevents HTTP desync attacks.",
"Checks": [],
"Checks": [
"elbv2_alb_drop_invalid_header_fields_enabled"
],
"Attributes": [
{
"ItemId": "ELB.4",
+1 -1
View File
@@ -889,7 +889,7 @@
}
],
"Checks": [
"kms_key_rotation_enabled"
"kms_key_rotation_max_90_days"
]
},
{
+1 -1
View File
@@ -150,7 +150,7 @@
"Id": "1.10",
"Description": "Google Cloud Key Management Service stores cryptographic keys in a hierarchical structure designed for useful and elegant access control management. The format for the rotation schedule depends on the client library that is used. For the gcloud command-line tool, the next rotation time must be in `ISO` or `RFC3339` format, and the rotation period must be in the form `INTEGERUNIT`, where units can be one of seconds (s), minutes (m), hours (h) or days (d).",
"Checks": [
"kms_key_rotation_enabled"
"kms_key_rotation_max_90_days"
],
"Attributes": [
{
+1 -1
View File
@@ -201,7 +201,7 @@
"Id": "1.10",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
"kms_key_rotation_enabled"
"kms_key_rotation_max_90_days"
],
"Attributes": [
{
+1 -1
View File
@@ -201,7 +201,7 @@
"Id": "1.10",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
"kms_key_rotation_enabled"
"kms_key_rotation_max_90_days"
],
"Attributes": [
{
@@ -117,7 +117,7 @@
"Id": "1.2.4",
"Description": "Ensure KMS Encryption Keys Are Rotated Within a Period of 90 Days",
"Checks": [
"kms_key_rotation_enabled"
"kms_key_rotation_max_90_days"
],
"Attributes": [
{
View File
@@ -0,0 +1,638 @@
{
"Framework": "Okta-IDaaS-STIG",
"Name": "DISA Okta Identity as a Service (IDaaS) STIG V1R2",
"Version": "1R2",
"Provider": "Okta",
"Description": "Defense Information Systems Agency (DISA) Security Technical Implementation Guide (STIG) for Okta Identity as a Service (IDaaS), Version 1 Release 2 (Benchmark Date: 05 Jan 2026).",
"Requirements": [
{
"Id": "OKTA-APP-000020",
"Name": "Okta must log out a session after a 15-minute period of inactivity.",
"Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead. Satisfies: SRG-APP-000003, SRG-APP-000190",
"Checks": [
"signon_global_session_idle_timeout_15min"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273186r1098825_rule",
"StigID": "OKTA-APP-000020",
"CCI": [
"CCI-000057",
"CCI-001133"
],
"CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the edit icon next to the Priority 1 rule. 4. Verify the \"Maximum Okta global session idle time\" is set to 15 minutes. If \"Maximum Okta global session idle time\" is not set to 15 minutes, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session idle time\" to 15 minutes."
}
]
},
{
"Id": "OKTA-APP-000025",
"Name": "The Okta Admin Console must log out a session after a 15-minute period of inactivity.",
"Description": "A session timeout lock is a temporary action taken when a user stops work and moves away from the immediate physical vicinity of the information system but does not log out because of the temporary nature of the absence. Rather than relying on the user to manually lock their application session prior to vacating the vicinity, applications must be able to identify when a user's application session has idled and take action to initiate the session lock. The session lock is implemented at the point where session activity can be determined and/or controlled. This is typically at the operating system level and results in a system lock. However, it may be at the application level where the application interface window is secured instead.",
"Checks": [
"application_admin_console_session_idle_timeout_15min"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273187r1098828_rule",
"StigID": "OKTA-APP-000025",
"CCI": [
"CCI-000057"
],
"CheckText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", verify the \"Maximum app session idle time\" is set to 15 minutes. If the \"Maximum app session idle time\" is not set to 15 minutes, this is a finding.",
"FixText": "From the Admin Console: 1. Select Applications >> Applications >> Okta Admin Console. 2. In the Sign On tab, under \"Okta Admin Console session\", set the \"Maximum app session idle time\" to 15 minutes."
}
]
},
{
"Id": "OKTA-APP-000090",
"Name": "Okta must automatically disable accounts after a 35-day period of account inactivity.",
"Description": "Attackers that are able to exploit an inactive account can potentially obtain and maintain undetected access to an application. Owners of inactive accounts will not notice if unauthorized access to their user account has been obtained. Applications must track periods of user inactivity and disable accounts after 35 days of inactivity. Such a process greatly reduces the risk that accounts will be hijacked, leading to a data compromise. To address access requirements, many application developers choose to integrate their applications with enterprise-level authentication/access mechanisms that meet or exceed access control policy requirements. Such integration allows the application developer to off-load those access control functions and focus on core application features and functionality. This policy does not apply to emergency accounts or infrequently used accounts. Infrequently used accounts are local login administrator accounts used by system administrators when network or normal login/access is not available. Emergency accounts are administrator accounts created in response to crisis situations. Satisfies: SRG-APP-000025, SRG-APP-000163, SRG-APP-000700",
"Checks": [
"user_inactivity_automation_35d_enabled"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273188r1098831_rule",
"StigID": "OKTA-APP-000090",
"CCI": [
"CCI-000017",
"CCI-000795",
"CCI-003627"
],
"CheckText": "If Okta Services rely on external directory services for user sourcing, this is not applicable, and the connected directory services must perform this function. Go to Workflows >> Automations and verify that an Automation has been created to disable accounts after 35 days of inactivity. If the Okta configuration does not automatically disable accounts after a 35-day period of account inactivity, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Workflow >> Automations and select \"Add Automation\". 2. Create a name for the Automation (e.g., \"User Inactivity\"). 3. Click \"Add Condition\" and select \"User Inactivity in Okta\". 4. In the duration field, enter 35 days and click \"Save\". 5 Click the edit button next to \"Select Schedule\". 6. Configure the \"Schedule\" field for \"Run Daily\" and set the \"Time\" field to an organizationally defined time to run this automation. Click \"Save\". 7. Click the edit button next to \"Select group membership\". 8. In the \"Applies to\" field, select the group \"Everyone\" by typing it into the field. Click \"Save\". 9. Click \"Add Action\" and select \"Change User lifecycle state in Okta\". 10. In the \"Change user state to\" field, select \"Suspended\" and click \"Save\". 11. Click the \"Inactive\" button near the top of the section screen and select \"Activate\"."
}
]
},
{
"Id": "OKTA-APP-000170",
"Name": "Okta must enforce the limit of three consecutive invalid login attempts by a user during a 15-minute time period.",
"Description": "By limiting the number of failed login attempts, the risk of unauthorized system access via user password guessing, otherwise known as brute forcing, is reduced. Limits are imposed by locking the account. Satisfies: SRG-APP-000065, SRG-APP-000345",
"Checks": [
"authenticator_password_lockout_threshold_3"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273189r1098834_rule",
"StigID": "OKTA-APP-000170",
"CCI": [
"CCI-000044",
"CCI-002238"
],
"CheckText": "If Okta Services rely on external directory services for user sourcing, this check is not applicable, and the connected directory services must perform this function. From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, verify the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\". If Okta Services are not configured to automatically lock user accounts after three consecutive invalid login attempts, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Click the \"Actions\" button next to \"Password\" and select \"Edit\". 3. For each Password Policy, ensure the \"Lock Out\" section has the following values: - \"Lock out after 3 unsuccessful attempts\" is checked. - The value is set to \"3\"."
}
]
},
{
"Id": "OKTA-APP-000180",
"Name": "The Okta Dashboard application must be configured to allow authentication only via non-phishable authenticators.",
"Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.",
"Checks": [
"application_dashboard_phishing_resistant_authentication"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273190r1099763_rule",
"StigID": "OKTA-APP-000180",
"CCI": [
"CCI-000044"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked."
}
]
},
{
"Id": "OKTA-APP-000190",
"Name": "The Okta Admin Console application must be configured to allow authentication only via non-phishable authenticators.",
"Description": "Requiring the use of non-phishable authenticators protects against brute force/password dictionary attacks. This provides a better level of security while removing the need to lock out accounts after three attempts in 15 minutes.",
"Checks": [
"application_admin_console_phishing_resistant_authentication"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273191r1099764_rule",
"StigID": "OKTA-APP-000190",
"CCI": [
"CCI-000044"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, verify the \"Phishing resistant\" box is checked. This will ensure that only phishing-resistant factors are used to access the Okta Dashboard. If in the \"Possession factor constraints are\" section the \"Phishing resistant\" box is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"Possession factor constraints are\" section, ensure the \"Phishing resistant\" box is checked."
}
]
},
{
"Id": "OKTA-APP-000200",
"Name": "Okta must display the Standard Mandatory DOD Notice and Consent Banner before granting access to the application.",
"Description": "Display of the DOD-approved use notification before granting access to the application ensures that privacy and security notification verbiage used is consistent with applicable federal laws, Executive Orders, directives, policies, regulations, standards, and guidance. System use notifications are required only for access via login interfaces with human users and are not required when such human interfaces do not exist. The banner must be formatted in accordance with DTM-08-060. Use the following verbiage for applications that can accommodate banners of 1300 characters: \"You are accessing a U.S. Government (USG) Information System (IS) that is provided for USG-authorized use only. By using this IS (which includes any device attached to this IS), you consent to the following conditions: -The USG routinely intercepts and monitors communications on this IS for purposes including, but not limited to, penetration testing, COMSEC monitoring, network operations and defense, personnel misconduct (PM), law enforcement (LE), and counterintelligence (CI) investigations. -At any time, the USG may inspect and seize data stored on this IS. -Communications using, or data stored on, this IS are not private, are subject to routine monitoring, interception, and search, and may be disclosed or used for any USG-authorized purpose. -This IS includes security measures (e.g., authentication and access controls) to protect USG interests--not for your personal benefit or privacy. -Notwithstanding the above, using this IS does not constitute consent to PM, LE or CI investigative searching or monitoring of the content of privileged communications, or work product, related to personal representation or services by attorneys, psychotherapists, or clergy, and their assistants. Such communications and work product are private and confidential. See User Agreement for details.\" Use the following verbiage for operating systems that have severe limitations on the number of characters that can be displayed in the banner: \"I've read & consent to terms in IS user agreem't.\" Satisfies: SRG-APP-000068, SRG-APP-000069, SRG-APP-000070",
"Checks": [
"signon_dod_warning_banner_configured"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273192r1098843_rule",
"StigID": "OKTA-APP-000200",
"CCI": [
"CCI-000048",
"CCI-000050",
"CCI-001384",
"CCI-001385",
"CCI-001386",
"CCI-001387",
"CCI-001388"
],
"CheckText": "Attempt to log in to the Okta tenant and verify the DOD-approved warning banner is in place. If the required warning banner is not present and complete, this is a finding.",
"FixText": "Follow the supplemental instructions in the \"Okta DOD Warning Banner Configuration Guide\" provided with this STIG package."
}
]
},
{
"Id": "OKTA-APP-000560",
"Name": "The Okta Admin Console application must be configured to use multifactor authentication.",
"Description": "Without the use of multifactor authentication, the ease of access to privileged functions is greatly increased. Multifactor authentication requires using two or more factors to achieve authentication. Factors include: (i) something a user knows (e.g., password/PIN); (ii) something a user has (e.g., cryptographic identification device, token); or (iii) something a user is (e.g., biometric). A privileged account is defined as an information system account with authorizations of a privileged user. Network access is defined as access to an information system by a user (or a process acting on behalf of a user) communicating through a network (e.g., local area network, wide area network, or the internet). Satisfies: SRG-APP-000149, SRG-APP-000154",
"Checks": [
"application_admin_console_mfa_required"
],
"Attributes": [
{
"Section": "CAT I (High)",
"Severity": "high",
"RuleID": "SV-273193r1098846_rule",
"StigID": "OKTA-APP-000560",
"CCI": [
"CCI-000765",
"CCI-004046"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Admin Console\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"."
}
]
},
{
"Id": "OKTA-APP-000570",
"Name": "The Okta Dashboard application must be configured to use multifactor authentication.",
"Description": "To ensure accountability and prevent unauthenticated access, nonprivileged users must use multifactor authentication to prevent potential misuse and compromise of the system. Multifactor authentication uses two or more factors to achieve authentication. Factors include: (i) Something you know (e.g., password/PIN); (ii) Something you have (e.g., cryptographic identification device, token); or (iii) Something you are (e.g., biometric). A nonprivileged account is any information system account with authorizations of a nonprivileged user. Network access is any access to an application by a user (or process acting on behalf of a user) where the access is obtained through a network connection. Applications integrating with the DOD Active Directory and using the DOD CAC are examples of compliant multifactor authentication solutions. Satisfies: SRG-APP-000150, SRG-APP-000155",
"Checks": [
"application_dashboard_mfa_required"
],
"Attributes": [
{
"Section": "CAT I (High)",
"Severity": "high",
"RuleID": "SV-273194r1098849_rule",
"StigID": "OKTA-APP-000570",
"CCI": [
"CCI-000766",
"CCI-004046"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, verify that either \"Password/IdP + Another factor\" or \"Any 2 factor types\" is selected. If either of these settings is incorrect, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authentication Policies. 2. Click the \"Okta Dashboard\" policy. 3. Click the \"Actions\" button next to the top rule and select \"Edit\". 4. In the \"User must authenticate with\" field, select either \"Password/IdP + Another factor\" or \"Any 2 factor types\"."
}
]
},
{
"Id": "OKTA-APP-000650",
"Name": "Okta must enforce a minimum 15-character password length.",
"Description": "Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password length is one factor of several that helps to determine strength and how long it takes to crack a password. The shorter the password, the lower the number of possible combinations that need to be tested before the password is compromised. Use of more characters in a password helps to exponentially increase the time and/or resources required to compromise the password.",
"Checks": [
"authenticator_password_minimum_length_15"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273195r1098852_rule",
"StigID": "OKTA-APP-000650",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify the \"Minimum Length\" field is set to at least \"15\" characters. If any policy is not set to at least \"15\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set the \"Minimum Length\" field to at least \"15\" characters."
}
]
},
{
"Id": "OKTA-APP-000670",
"Name": "Okta must enforce password complexity by requiring that at least one uppercase character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password is, the greater the number of possible combinations that need to be tested before the password is compromised.",
"Checks": [
"authenticator_password_complexity_uppercase"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273196r1098855_rule",
"StigID": "OKTA-APP-000670",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Upper case letter\" is checked. For each policy, if \"Upper case letter\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Upper case letter\" to checked."
}
]
},
{
"Id": "OKTA-APP-000680",
"Name": "Okta must enforce password complexity by requiring that at least one lowercase character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.",
"Checks": [
"authenticator_password_complexity_lowercase"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273197r1098858_rule",
"StigID": "OKTA-APP-000680",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Lower case letter\" is checked. For each policy, if \"Lower case letter\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Lower case letter\" to checked."
}
]
},
{
"Id": "OKTA-APP-000690",
"Name": "Okta must enforce password complexity by requiring that at least one numeric character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor of several that determine how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised.",
"Checks": [
"authenticator_password_complexity_number"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273198r1098861_rule",
"StigID": "OKTA-APP-000690",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Number (0-9)\" is checked. For each policy, if \"Number (0-9)\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Number (0-9)\" to checked."
}
]
},
{
"Id": "OKTA-APP-000700",
"Name": "Okta must enforce password complexity by requiring that at least one special character be used.",
"Description": "Use of a complex password helps to increase the time and resources required to compromise the password. Password complexity, or strength, is a measure of the effectiveness of a password in resisting attempts at guessing and brute-force attacks. Password complexity is one factor in determining how long it takes to crack a password. The more complex the password, the greater the number of possible combinations that need to be tested before the password is compromised. Special characters are not alphanumeric. Examples include: ~ ! @ # $ % ^ *.",
"Checks": [
"authenticator_password_complexity_symbol"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273199r1098864_rule",
"StigID": "OKTA-APP-000700",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Symbol (e.g., !@#$%^&*)\" is checked. For each policy, if \"Symbol (e.g., !@#$%^&*)\" is not checked, this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Symbol (e.g., !@#$%^&*)\" to checked."
}
]
},
{
"Id": "OKTA-APP-000740",
"Name": "Okta must enforce 24 hours/one day as the minimum password lifetime.",
"Description": "Enforcing a minimum password lifetime helps prevent repeated password changes to defeat the password reuse or history enforcement requirement. Restricting this setting limits the user's ability to change their password. Passwords must be changed at specific policy-based intervals; however, if the application allows the user to immediately and continually change their password, it could be changed repeatedly in a short period of time to defeat the organization's policy regarding password reuse. Satisfies: SRG-APP-000173, SRG-APP-000870",
"Checks": [
"authenticator_password_minimum_age_24h"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273200r1098867_rule",
"StigID": "OKTA-APP-000740",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Minimum password age is XX hours\" is set to at least \"24\". For each policy, if \"Minimum password age is XX hours\" is not set to at least \"24\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Minimum password age is XX hours\" to at least \"24\"."
}
]
},
{
"Id": "OKTA-APP-000745",
"Name": "Okta must enforce a 60-day maximum password lifetime restriction.",
"Description": "Any password, no matter how complex, can eventually be cracked. Therefore, passwords must be changed at specific intervals. One method of minimizing this risk is to use complex passwords and periodically change them. If the application does not limit the lifetime of passwords and force users to change their passwords, there is the risk that the system and/or application passwords could be compromised. This requirement does not include emergency administration accounts, which are meant for access to the application in case of failure. These accounts are not required to have maximum password lifetime restrictions.",
"Checks": [
"authenticator_password_maximum_age_60d"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273201r1098870_rule",
"StigID": "OKTA-APP-000745",
"CCI": [
"CCI-004066"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy, verify \"Password expires after XX days\" is set to \"60\". For each policy, if \"Password expires after XX days\" is not set to \"60\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Password expires after XX days\" to \"60\"."
}
]
},
{
"Id": "OKTA-APP-001430",
"Name": "Okta must off-load audit records onto a central log server.",
"Description": "Information stored in one location is vulnerable to accidental or incidental deletion or alteration. Off-loading is a common process in information systems with limited audit storage capacity. Satisfies: SRG-APP-000358, SRG-APP-000080, SRG-APP-000125",
"Checks": [
"systemlog_streaming_enabled"
],
"Attributes": [
{
"Section": "CAT I (High)",
"Severity": "high",
"RuleID": "SV-273202r1099766_rule",
"StigID": "OKTA-APP-001430",
"CCI": [
"CCI-001851",
"CCI-000166",
"CCI-001348"
],
"CheckText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Verify that a Log Stream connection is configured and active. Alternately, interview the information system security manager (ISSM) and verify that an external Security Information and Event Management (SIEM) system is pulling Okta logs via an Application Programming Interface (API). If either of these is not configured, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Reports >> Log Streaming. 2. Select either \"AWS EventBridge\" or \"Splunk Cloud\" and click \"Next\". 3. Complete the necessary fields and click \"Save\". If Log Streaming is not an option because the SIEM required is not an option, customers can use the Okta Log API to export system logs in real time."
}
]
},
{
"Id": "OKTA-APP-001665",
"Name": "Okta must be configured to limit the global session lifetime to 18 hours.",
"Description": "Without reauthentication, users may access resources or perform tasks for which they do not have authorization. When applications provide the capability to change security roles or escalate the functional capability of the application, it is critical the user reauthenticate. In addition to the reauthentication requirements associated with session locks, organizations may require reauthentication of individuals and/or devices in other situations, including (but not limited to) the following circumstances. (i) When authenticators change; (ii) When roles change; (iii) When security categories of information systems change; (iv) When the execution of privileged functions occurs; (v) After a fixed period of time; or (vi) Periodically. Within the DOD, the minimum circumstances requiring reauthentication are privilege escalation and role changes.",
"Checks": [
"signon_global_session_lifetime_18h"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273203r1099958_rule",
"StigID": "OKTA-APP-001665",
"CCI": [
"CCI-002038"
],
"CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Maximum Okta global session lifetime\" is set to 18 hours. If the above is not set, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the Rules table, make these updates: - Click \"Add rule\". - Set \"Maximum Okta global session lifetime\" to 18 hours."
}
]
},
{
"Id": "OKTA-APP-001670",
"Name": "Okta must be configured to accept Personal Identity Verification (PIV) credentials.",
"Description": "The use of PIV credentials facilitates standardization and reduces the risk of unauthorized access. DOD has mandated the use of the common access card (CAC) to support identity management and personal authentication for systems covered under HSPD 12, as well as a primary component of layered protection for national security systems. Satisfies: SRG-APP-000391, SRG-APP-000402, SRG-APP-000403",
"Checks": [
"authenticator_smart_card_active"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273204r1098879_rule",
"StigID": "OKTA-APP-001670",
"CCI": [
"CCI-001953",
"CCI-002009",
"CCI-002010"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. Verify that \"Smart Card Authenticator\" is listed and has \"Status\" listed as \"Active\". If \"Smart Card Authenticator\" is not listed or is not listed as \"Active\", this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. In the \"Setup\" tab, click \"Add authenticator\". 3. Select the configured Smart Card Identity Provider and finish configuration."
}
]
},
{
"Id": "OKTA-APP-001700",
"Name": "The Okta Verify application must be configured to connect only to FIPS-compliant devices.",
"Description": "Without device-to-device authentication, communications with malicious devices may be established. Bidirectional authentication provides stronger safeguards to validate the identity of other devices for connections that are of greater risk. Currently, DOD requires the use of AES for bidirectional authentication because it is the only FIPS-validated AES cipher block algorithm. For distributed architectures (e.g., service-oriented architectures), the decisions regarding the validation of authentication claims may be made by services separate from the services acting on those decisions. In such situations, it is necessary to provide authentication decisions (as opposed to the actual authenticators) to the services that need to act on those decisions. A local connection is any connection with a device communicating without the use of a network. A network connection is any connection with a device that communicates through a network (e.g., local area or wide area network; the internet). A remote connection is any connection with a device communicating through an external network (e.g., the internet). Because of the challenges of applying this requirement on a large scale, organizations are encouraged to apply the requirement only to those limited number (and type) of devices that truly need to support this capability.",
"Checks": [
"authenticator_okta_verify_fips_compliant"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273205r1098882_rule",
"StigID": "OKTA-APP-001700",
"CCI": [
"CCI-001967"
],
"CheckText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. Review the \"FIPS Compliance\" field. If FIPS-compliant authentication is not enabled, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Authenticators. 2. From the \"Setup\" tab, select \"Edit Okta Verify\". 3. In the \"FIPS Compliance\" field, choose whether users enrolling in Okta Verify can use FIPS-compliant devices only or any device. 4. Click \"Save\" after making any changes."
}
]
},
{
"Id": "OKTA-APP-001710",
"Name": "Okta must be configured to disable persistent global session cookies.",
"Description": "If cached authentication information is out of date, the validity of the authentication information may be questionable. Satisfies: SRG-APP-000400, SRG-APP-000157",
"Checks": [
"signon_global_session_cookies_not_persistent"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273206r1098885_rule",
"StigID": "OKTA-APP-001710",
"CCI": [
"CCI-002007",
"CCI-001942"
],
"CheckText": "From the Admin Console: 1. Select Security >> Global Session Policy. 2. In the Default Policy, verify a rule is configured at Priority 1 that is not named \"Default Rule\". 3. Click the \"Edit\" icon next to the Priority 1 rule. 4. Verify \"Okta global session cookies persist across browser sessions\" is set to \"Disabled\". If the above it not set, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Global Session Policy. 2. Select the Default Policy. 3. In the \"Rules\" table, make these updates: - Click \"Add rule\". - Set \"Okta global session cookies persist across browser sessions\" to Disable."
}
]
},
{
"Id": "OKTA-APP-001920",
"Name": "Okta must be configured to use only DOD-approved certificate authorities.",
"Description": "Untrusted Certificate Authorities (CA) can issue certificates, but they may be issued by organizations or individuals that seek to compromise DOD systems or by organizations with insufficient security controls. If the CA used for verifying the certificate is not DOD approved, trust of this CA has not been established. The DOD will accept only PKI certificates obtained from a DOD-approved internal or external CA. Reliance on CAs for the establishment of secure sessions includes, for example, the use of Transport Layer Security (TLS) certificates. This requirement focuses on communications protection for the application session rather than for the network packet. This requirement applies to applications that use communications sessions. This includes, but is not limited to, web-based applications and Service-Oriented Architectures (SOA). Satisfies: SRG-APP-000427, SRG-APP-000910",
"Checks": [
"idp_smart_card_dod_approved_ca"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273207r1098888_rule",
"StigID": "OKTA-APP-001920",
"CCI": [
"CCI-002470",
"CCI-004909"
],
"CheckText": "From the Admin Console: 1. Select Security >> Identity Providers (IdPs). 2. Review the list of IdPs with \"Type\" as \"Smart Card\". If the IdP is not listed as \"Active\", this is a finding. 3. Select Actions >> Configure. 4. Under \"Certificate chain\", verify the certificate is from a DOD-approved CA. If the certificate is not from a DOD-approved CA, this is a finding.",
"FixText": "From the Admin Console: 1. Go to Security >> Identity Providers. 2. Click \"Add identity provider.\" 3. Click \"Smart Card IdP\". Click \"Next\". 4. Enter the name of the identity provider. 5. Build a certificate chain: - Click \"Browse\" to open a file explorer. Select the certificate file to add and click \"Open\". - To add another certificate, click \"Add Another\" and repeat step 1. - Click \"Build certificate chain\". On success, the chain and its certificates are shown. If the build failed, correct any issues and try again. - Click \"Reset certificate chain\" if replacing the current chain with a new one. 6. In \"IdP username\", select the \"idpuser.subjectAltNameUpn\" attribute. This is the attribute that stores the Electronic Data Interchange Personnel Identifier (EDIPI) on the CAC. 7. In the \"Match Against\" field, select the Okta Profile Attribute in which the EDIPI is to be stored."
}
]
},
{
"Id": "OKTA-APP-002980",
"Name": "Okta must validate passwords against a list of commonly used, expected, or compromised passwords.",
"Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.",
"Checks": [
"authenticator_password_common_password_check"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273208r1099769_rule",
"StigID": "OKTA-APP-002980",
"CCI": [
"CCI-004058"
],
"CheckText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, verify the \"Common Password Check\" box is checked. If \"Common Password Check\" is not selected, this is a finding.",
"FixText": "From the Admin Console: 1. Navigate to Security >> Authenticators. 2. Click the \"Actions\" button next to the Password authenticator and select \"Edit\". 3. Under the \"Password Settings\" section, check the \"Common Password Check\" box."
}
]
},
{
"Id": "OKTA-APP-003010",
"Name": "Okta must prohibit password reuse for a minimum of five generations.",
"Description": "Password-based authentication applies to passwords regardless of whether they are used in single-factor or multifactor authentication. Long passwords or passphrases are preferable over shorter passwords. Enforced composition rules provide marginal security benefits while decreasing usability. However, organizations may choose to establish certain rules for password generation (e.g., minimum character length for long passwords) under certain circumstances and can enforce this requirement in IA-5(1)(h). Account recovery can occur, for example, in situations when a password is forgotten. Cryptographically protected passwords include salted one-way cryptographic hashes of passwords. The list of commonly used, compromised, or expected passwords includes passwords obtained from previous breach corpuses, dictionary words, and repetitive or sequential characters. The list includes context-specific words, such as the name of the service, username, and derivatives thereof.",
"Checks": [
"authenticator_password_history_5"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-273209r1098894_rule",
"StigID": "OKTA-APP-003010",
"CCI": [
"CCI-004061"
],
"CheckText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password row\" and select \"Edit\". 3. For each listed policy, verify \"Enforce password history for last XX passwords\" is set to \"5\". If any policy is not set to at least \"5\", this is a finding.",
"FixText": "From the Admin Console: 1. Select Security >> Authenticators. 2. Click the \"Actions\" button next to the \"Password\" row and select \"Edit\". 3. For each listed policy: - Click \"Edit\". - Set \"Enforce password history for last XX passwords\" to \"5\"."
}
]
},
{
"Id": "OKTA-APP-003240",
"Name": "Okta API tokens must be configured with Network Zones to restrict authorization from known networks.",
"Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. API tokens have the potential to be replicated or stolen (just like a password). Because of this, it is important to only allow API tokens to authenticate from known IP ranges as this limits an adversary's ability to use a token to gain access.",
"Checks": [
"apitoken_restricted_to_network_zone"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279689r1155066_rule",
"StigID": "OKTA-APP-003240",
"CCI": [
"CCI-005165",
"CCI-000366"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, verify the \"Token can be used from\" setting is mapped to a known network zone for the application calling the API. If a network zone for each API access token is not defined, this is a finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, click the token name link. 4. In the \"Security\" section, click \"Edit\". 5. Set the \"Token can be used from\" setting to the known network zone for the application calling the API. 6. Click \"Save\"."
}
]
},
{
"Id": "OKTA-APP-003241",
"Name": "Okta API tokens must be created under new dedicated user accounts.",
"Description": "An access token is a piece of data that represents the authorization granted to a user or NPE to access specific systems or information resources. Access tokens enable controlled access to services and resources. Properly managing the lifecycle of access tokens, including their issuance, validation, and revocation, is crucial to maintaining confidentiality of data and systems. Restricting token validity to a specific audience, e.g., an application or security domain, and restricting token validity lifetimes are important practices. Access tokens are revoked or invalidated if they are compromised, lost, or are no longer needed to mitigate the risks associated with stolen or misused tokens. When API tokens are created, they inherit the permissions of the user that created them. Therefore, API tokens should only be created from dedicated accounts and permissions must be constrained to least privilege for that dedicated user account and token. No API tokens should be created using a Super Admin account.",
"Checks": [
"apitoken_not_super_admin"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279690r1155069_rule",
"StigID": "OKTA-APP-003241",
"CCI": [
"CCI-005165",
"CCI-000366"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed, verify that the Role listed is not \"Super Admin\", and that the account has been specifically created for that token. 4. Click the account name to be token to the user profile for that user. 5. Verify the user only has an administrator role (standard or customer) applied that is correctly scoped as required and documented in the Okta Access Control policy. If the token is using a Super Administrator account, or one that is not properly scoped per the Access Control policy, this is a finding. Note: If a Super Admin token is required for system operation, then this permanent finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"API\" item. 2. Click the \"Tokens\" tab. 3. For each token listed that has \"Super Admin\" or an improperly scoped Admin account, delete the token and create a new one with the appropriately scoped permissions. 4. Verify the application performing the API calls with the new token has been updated."
}
]
},
{
"Id": "OKTA-APP-003242",
"Name": "The Okta Global Session policy must be configured to allow or deny IP based access in accordance with the Access Control policy for Okta.",
"Description": "To mitigate the risk of unauthorized access to sensitive information by entities that have been issued certificates by DOD-approved PKIs, all DOD systems (e.g., networks, web servers, and web portals) must be properly configured to incorporate access control methods that do not rely solely on the possession of a certificate for access. Successful authentication must not automatically give an entity access to an asset or security boundary. Authorization procedures and controls must be implemented to ensure each authenticated entity also has a validated and current authorization. Authorization is the process of determining whether an entity, once authenticated, is permitted to access a specific asset. Information systems use access control policies and enforcement mechanisms to implement this requirement. Access Control policies include identity-based policies, role-based policies, and attribute-based policies. Access enforcement mechanisms include access control lists, access control matrices, and cryptography. These policies and mechanisms must be employed by the application to control access between users (or processes acting on behalf of users) and objects (e.g., devices, files, records, processes, programs, and domains) in the information system. The Okta Global Session Policy is applied at the organization level and before any application-specific authentication policies are processed. The Okta authorization package should contain an access control policy that defines IP ranges from which to either allow or deny access. This list (either as an explicit allow or explicit deny) can be implemented in the Global Session Policy.",
"Checks": [
"signon_global_session_policy_network_zone_enforced"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279691r1155072_rule",
"StigID": "OKTA-APP-003242",
"CCI": [
"CCI-000213"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the \"Policy Settings\" section, verify the \"IF User's IP is\" setting is correctly set to either allow or deny based on the organization defined policy. If the Okta Global Session Policy is not configured to restrict access to specific IP ranges, this is a finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Global Session Policy\" item. 2. In the Policy Settings section, configure the \"IF User's IP is\" setting to correctly set the appropriate network to either allow or deny based on the Access Control Policy."
}
]
},
{
"Id": "OKTA-APP-003243",
"Name": "Okta must be configured with Network Zones defined to block anonymized proxies according to organizationally defined policy.",
"Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Working with the organizational CSSP, the ISSM should obtain a list of known anonymizer proxies that exist on the commercial internet. If this is not available from the CSSP, then the Okta-provided \"Enhanced dynamic zone blocklist\" should be activated.",
"Checks": [
"network_zone_block_anonymized_proxies"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279692r1155075_rule",
"StigID": "OKTA-APP-003243",
"CCI": [
"CCI-001414"
],
"CheckText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks' item. 2. If the CSSP has provided a list of anonymizers to block, verify the \"IP Block list\" is configured with them. a. Click the pencil icon next to IP Block list. b. Verify the \"Gateway IPs\" section contains all of the IP ranges in the provided list. 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Verify the \"Enhanced dynamic zone blocklist\" is set to \"Active\". If Network Zones are not configured to block anonymous proxies, this is a finding.",
"FixText": "From the Admin Console: 1. Select the \"Security\" menu, and then click the \"Networks\" item. 2. If the CSSP has provided a list of anonymizers to block, add the IP ranges to the \"IP Block list\". a. Click the pencil icon next to IP Block list. b. Add the IP ranges to the \"Gateway IPs\" section and click \"Save\". 3. If the CSSP is not able to provide a list, then implement the Okta managed list. a. Set the \"Enhanced dynamic zone blocklist\" to \"Active\"."
}
]
},
{
"Id": "OKTA-APP-003244",
"Name": "For each application integrated with Okta, network zones must be defined in its authentication policy.",
"Description": "A mechanism to detect and prevent unauthorized communication flow must be configured or provided as part of the system design. If information flow is not enforced based on approved authorizations, the system may become compromised. Information flow control regulates where information is allowed to travel within a system and between interconnected systems. The flow of all application information must be monitored and controlled so it does not introduce any unacceptable risk to the systems or data. Application-specific examples of enforcement occurs in systems that employ rule sets or establish configuration settings that restrict information system services, or provide a message filtering capability based on message content (e.g., implementing key word searches or using document characteristics). Applications providing information flow control must be able to enforce approved authorizations for controlling the flow of information between interconnected systems in accordance with applicable policy. Each application in Okta should have a well defined access control policy that takes into account the end user network. This should be documented in the Access Control policy for each application. As an example, access to an application may be restricted to a specific location by policy. In this case, a network defining that specific location should be created.",
"Checks": [
"application_authentication_policy_network_zone_enforced"
],
"Attributes": [
{
"Section": "CAT II (Medium)",
"Severity": "medium",
"RuleID": "SV-279693r1155078_rule",
"StigID": "OKTA-APP-003244",
"CCI": [
"CCI-001414"
],
"CheckText": "For each application integrated into Okta: 1. From the Admin console, open the \"Security\" menu, and then select \"Networks\". 2. Verify the list of networks includes all necessary allow or block lists. If any application is not configured with network zones, this is a finding.",
"FixText": "For each application, starting at the admin console: 1. Open the \"Applications\" group from the Menu, and then click the \"Applications\" menu item. 2. Click the application name. 3. Click the \"Sign On\" tab. 4. Scroll to the \"User Authentication\" section, and then click \"Edit\". 5. Select the appropriate Authentication policy from the pull down, and then click \"Save\". 6. Click \"View Policy Details\". 7. For each nondefault rule: a. Select \"Edit\" from the Actions menu. b. In the \"IF\" section, verify the \"User is\" setting has the appropriate allow or deny range has been selected based on the Access Control policy for the application. c. Scroll down to the bottom and click \"Save\". 8. For the Catch-All rule: a. Select \"Edit\" from the Actions menu. b. Scroll down to the \"Then\" section. c. For the \"Access is\" setting, select \"Denied\", and then click \"Save\"."
}
]
}
]
}
+86 -14
View File
@@ -1,3 +1,4 @@
import importlib.metadata
import os
import pathlib
from datetime import datetime, timezone
@@ -85,13 +86,38 @@ class Provider(str, Enum):
actual_directory = pathlib.Path(os.path.dirname(os.path.realpath(__file__)))
def _get_ep_compliance_dirs() -> dict:
"""Discover compliance directories from entry points. Returns {provider: path}."""
dirs = {}
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
try:
module = ep.load()
if hasattr(module, "__path__"):
dirs[ep.name] = module.__path__[0]
elif hasattr(module, "__file__"):
dirs[ep.name] = os.path.dirname(module.__file__)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return dirs
def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks = []
providers = [p.value for p in Provider]
# Built-in compliance
compliance_base = f"{actual_directory}/../compliance"
if provider:
providers = [provider]
for current_provider in providers:
compliance_dir = f"{actual_directory}/../compliance/{current_provider}"
else:
# Scan compliance directory for all provider subdirectories
providers = []
if os.path.isdir(compliance_base):
for entry in os.scandir(compliance_base):
if entry.is_dir():
providers.append(entry.name)
for prov in providers:
compliance_dir = f"{compliance_base}/{prov}"
if not os.path.isdir(compliance_dir):
continue
with os.scandir(compliance_dir) as files:
@@ -100,7 +126,8 @@ def get_available_compliance_frameworks(provider=None):
available_compliance_frameworks.append(
file.name.removesuffix(".json")
)
# Also scan top-level compliance/ for multi-provider (universal) JSONs.
# Built-in multi-provider frameworks at top-level compliance/ directory.
# Placed before external entry points so built-ins win on name collisions.
# When a specific provider was requested, only include the framework if it
# declares support for that provider; otherwise include all universal frameworks.
compliance_root = f"{actual_directory}/../compliance"
@@ -117,6 +144,43 @@ def get_available_compliance_frameworks(provider=None):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
# External per-provider compliance via entry points.
ep_dirs = _get_ep_compliance_dirs()
for prov, path in ep_dirs.items():
if provider and prov != provider:
continue
if os.path.isdir(path):
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
name = file.name.removesuffix(".json")
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
# External multi-provider frameworks via the dedicated universal group;
# filtered by supports_provider when a provider is given.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
path = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
continue
if not os.path.isdir(path):
continue
for file in os.scandir(path):
if file.is_file() and file.name.endswith(".json"):
name = file.name.removesuffix(".json")
if provider:
framework = load_compliance_framework_universal(file.path)
if framework is None or not framework.supports_provider(provider):
continue
if name not in available_compliance_frameworks:
available_compliance_frameworks.append(name)
return available_compliance_frameworks
@@ -228,18 +292,26 @@ def load_and_validate_config_file(provider: str, config_file_path: str) -> dict:
with open(config_file_path, "r", encoding=encoding_format_utf_8) as f:
config_file = yaml.safe_load(f)
# Not to introduce a breaking change, allow the old format config file without any provider keys
# and a new format with a key for each provider to include their configuration values within.
if any(
key in config_file
for key in ["aws", "gcp", "azure", "kubernetes", "m365"]
# Namespaced format: each provider has its own top-level key.
# Works for every built-in and every external plugin without a hardcoded list.
# Flat legacy format is AWS-only (historical, pre-multicloud). We identify it
# by the absence of nested-dict top-level values (namespaced files always
# have dict values; the legacy AWS format only has primitives/lists).
if (
isinstance(config_file, dict)
and provider in config_file
and isinstance(config_file[provider], dict)
):
config = config_file.get(provider, {})
config = config_file.get(provider, {}) or {}
elif (
isinstance(config_file, dict)
and config_file
and provider == "aws"
and not any(isinstance(v, dict) for v in config_file.values())
):
config = config_file
else:
config = config_file if config_file else {}
# Not to break Azure, K8s and GCP does not support or use the old config format
if provider in ["azure", "gcp", "kubernetes", "m365"]:
config = {}
config = {}
return config
+38 -4
View File
@@ -3,12 +3,13 @@ from colorama import Fore, Style
from prowler.config.config import banner_color, orange_color, prowler_version, timestamp
def print_banner(legend: bool = False):
def print_banner(legend: bool = False, provider: str = None):
"""
Prints the banner with optional legend for color codes.
Parameters:
- legend (bool): Flag to indicate whether to print the color legend or not. Default is False.
- provider (str): The provider being scanned, used to tailor the Prowler Cloud banner.
Returns:
- None
@@ -20,13 +21,12 @@ def print_banner(legend: bool = False):
| .__/|_| \___/ \_/\_/ |_|\___|_|v{prowler_version}
|_|{Fore.BLUE} Get the most at https://cloud.prowler.com {Style.RESET_ALL}
{Fore.GREEN}New! Send findings from Prowler CLI to Prowler Cloud{Style.RESET_ALL}
{Fore.GREEN}More details here: goto.prowler.com/import-findings{Style.RESET_ALL}
{Fore.YELLOW}Date: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}{Style.RESET_ALL}
"""
print(banner)
print_prowler_cloud_banner(provider)
if legend:
print(
f"""
@@ -37,3 +37,37 @@ def print_banner(legend: bool = False):
- {Fore.RED}FAIL (Fix required){Style.RESET_ALL}
"""
)
def print_prowler_cloud_banner(provider: str = None):
"""
Prints a promotional banner highlighting what Prowler Cloud adds on top of
the open-source CLI.
Shown at the start and end of a scan to let users know about the managed
platform capabilities they are missing (attack paths, AI, organizations,
continuous scanning, integrations and live compliance dashboards).
Parameters:
- provider (str): The provider that was scanned, used to tailor the message.
Returns:
- None
"""
check = f"{Fore.GREEN}{Style.RESET_ALL}"
bar = f"{banner_color}{Style.RESET_ALL}"
print(
f"""
{bar} {Style.BRIGHT}You're getting a snapshot. Prowler Cloud gives you the full picture.{Style.RESET_ALL}
{bar}
{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels
{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, prioritization and remediation
{bar} {check} {Style.BRIGHT}Organizations{Style.RESET_ALL} - all your AWS accounts under one organization
{bar} {check} {Style.BRIGHT}Continuous scanning{Style.RESET_ALL} - scheduled scans with history, trends and alerts
{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC
{bar} {check} {Style.BRIGHT}Reports{Style.RESET_ALL} - download ready-to-share PDF reports
{bar} {check} {Style.BRIGHT}Live compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date
{bar}
{bar} {Fore.BLUE}Start free at cloud.prowler.com{Style.RESET_ALL}
"""
)
+54 -6
View File
@@ -1,4 +1,6 @@
import importlib
import importlib.metadata
import importlib.util
import json
import os
import re
@@ -19,6 +21,7 @@ from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.lib.outputs.outputs import report
from prowler.lib.utils.utils import open_file, parse_json_file, print_boxes
from prowler.providers.common.builtin import is_builtin_provider
from prowler.providers.common.models import Audit_Metadata
@@ -385,6 +388,45 @@ def import_check(check_path: str) -> ModuleType:
return lib
def _resolve_check_module(
provider_type: str, service: str, check_name: str
) -> ModuleType:
"""Resolve and import a check module.
Built-in wins on CheckID collision. Plug-ins are first-class extenders
(they can add new checks under new CheckIDs) but cannot override
existing built-ins a security tool prefers fail-loud predictability
over silent overrides. CheckMetadata.get_bulk() applies the same
precedence on the metadata side (first-write-wins) and emits a warning
when a plug-in tries to override, so the user knows their plug-in
duplicate is being ignored and can rename it.
Gates the built-in branch on `is_builtin_provider(provider_type)`
calling `find_spec` on `prowler.providers.{provider_type}.services...`
directly would propagate `ModuleNotFoundError` for external providers
(their parent package `prowler.providers.{provider_type}` does not
exist) instead of returning None. The leaf helper encapsulates the
safe lookup, so external providers go straight to entry points. For
built-ins we still use `find_spec` to distinguish "check doesn't
exist" from "check exists but failed to import" (broken transitive
dep, etc.).
"""
# Built-in first — built-in wins on CheckID collision
if is_builtin_provider(provider_type):
builtin_path = f"prowler.providers.{provider_type}.services.{service}.{check_name}.{check_name}"
if importlib.util.find_spec(builtin_path) is not None:
return import_check(builtin_path)
# Entry point lookup — only consulted when the built-in truly doesn't exist
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider_type}"):
if ep.name == check_name:
return importlib.import_module(ep.value)
raise ModuleNotFoundError(
f"Check '{check_name}' not found for provider '{provider_type}'"
)
def run_fixer(check_findings: list) -> int:
"""
Run the fixer for the check if it exists and there are any FAIL findings
@@ -525,9 +567,10 @@ def execute_checks(
service = check_name.split("_")[0]
try:
try:
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -605,9 +648,10 @@ def execute_checks(
)
try:
try:
# Import check module
check_module_path = f"prowler.providers.{global_provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point)
lib = _resolve_check_module(
global_provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -753,6 +797,10 @@ def execute(
is_finding_muted_args["org_domain"] = (
global_provider.identity.org_domain
)
elif not is_builtin_provider(global_provider.type):
# External/custom provider — delegate identity args
is_finding_muted_args = global_provider.get_mutelist_finding_args()
for finding in check_findings:
if global_provider.type == "cloudflare":
is_finding_muted_args["account_id"] = finding.account_id
+8 -3
View File
@@ -2,10 +2,10 @@ import sys
from colorama import Fore, Style
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.check import parse_checks_from_file
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.models import CheckMetadata, Severity
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger
@@ -26,8 +26,13 @@ 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 providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — they delegate
# scanning to an external tool and have no checks to recover.
# Single source of truth across __main__, the CheckMetadata validators,
# check discovery and this loader, covering both built-in tool wrappers
# (iac/llm/image) and external plug-ins that declare
# `is_external_tool_provider = True` via the contract.
if is_tool_wrapper_provider(provider):
return set()
# Local subsets
+101 -15
View File
@@ -1,3 +1,4 @@
import importlib.metadata
import json
import os
import sys
@@ -282,6 +283,26 @@ class CSA_CCM_Requirement_Attribute(BaseModel):
ScopeApplicability: list[dict]
class STIG_Requirement_Attribute_Severity(str, Enum):
"""DISA STIG Requirement Attribute Severity (maps to CAT I/II/III)"""
high = "high"
medium = "medium"
low = "low"
class STIG_Requirement_Attribute(BaseModel):
"""DISA STIG Requirement Attribute"""
Section: str
Severity: STIG_Requirement_Attribute_Severity
RuleID: str
StigID: str
CCI: Optional[list[str]] = None
CheckText: Optional[str] = None
FixText: Optional[str] = None
# Base Compliance Model
# TODO: move this to compliance folder
class Compliance_Requirement(BaseModel):
@@ -302,6 +323,7 @@ class Compliance_Requirement(BaseModel):
CCC_Requirement_Attribute,
C5Germany_Requirement_Attribute,
CSA_CCM_Requirement_Attribute,
STIG_Requirement_Attribute,
# Generic_Compliance_Requirement_Attribute must be the last one since it is the fallback for generic compliance framework
Generic_Compliance_Requirement_Attribute,
]
@@ -434,26 +456,63 @@ class Compliance(BaseModel):
"""Bulk load all compliance frameworks specification into a dict"""
try:
bulk_compliance_frameworks = {}
# Built-in compliance from prowler/compliance/{provider}/
available_compliance_framework_modules = list_compliance_modules()
for compliance_framework in available_compliance_framework_modules:
if provider in compliance_framework.name:
# Match the provider segment exactly, not as a substring, so
# e.g. `cloud` does not capture `cloudflare`.
if compliance_framework.name.split(".")[-1] == provider:
compliance_specification_dir_path = (
f"{compliance_framework.module_finder.path}/{provider}"
)
# for compliance_framework in available_compliance_framework_modules:
for filename in os.listdir(compliance_specification_dir_path):
file_path = os.path.join(
compliance_specification_dir_path, filename
)
# Check if it is a file and ti size is greater than 0
if os.path.isfile(file_path) and os.stat(file_path).st_size > 0:
# Open Compliance file in JSON
# cis_v1.4_aws.json --> cis_v1.4_aws
compliance_framework_name = filename.split(".json")[0]
# Store the compliance info
bulk_compliance_frameworks[compliance_framework_name] = (
load_compliance_framework(file_path)
)
# External compliance via entry points
for ep in importlib.metadata.entry_points(group="prowler.compliance"):
if ep.name == provider:
try:
module = ep.load()
compliance_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
for filename in os.listdir(compliance_dir):
if filename.endswith(".json"):
file_path = os.path.join(compliance_dir, filename)
if (
os.path.isfile(file_path)
and os.stat(file_path).st_size > 0
):
compliance_framework_name = filename.split(".json")[
0
]
if (
compliance_framework_name
not in bulk_compliance_frameworks
):
# External JSON: tolerate non-legacy
# schemas (skip + warn) instead of aborting.
framework = load_compliance_framework(
file_path, fatal=False
)
if framework is not None:
bulk_compliance_frameworks[
compliance_framework_name
] = framework
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
@@ -462,18 +521,26 @@ class Compliance(BaseModel):
# Testing Pending
def load_compliance_framework(
compliance_specification_file: str,
) -> Compliance:
"""load_compliance_framework loads and parse a Compliance Framework Specification"""
compliance_specification_file: str, fatal: bool = True
) -> Optional[Compliance]:
"""load_compliance_framework loads and parse a Compliance Framework Specification.
With ``fatal=True`` (built-in JSONs) an invalid file aborts the run; with
``fatal=False`` (external JSONs) it is skipped with a warning and ``None``
is returned.
"""
try:
compliance_framework = Compliance.parse_file(compliance_specification_file)
return Compliance.parse_file(compliance_specification_file)
except ValidationError as error:
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
if fatal:
logger.critical(
f"Compliance Framework Specification from {compliance_specification_file} is not valid: {error}"
)
sys.exit(1)
logger.warning(
f"Skipping invalid compliance framework {compliance_specification_file}: {error}"
)
sys.exit(1)
else:
return compliance_framework
return None
# ─── Universal Compliance Schema Models (Phase 1-3) ─────────────────────────
@@ -950,6 +1017,25 @@ def get_bulk_compliance_frameworks_universal(provider: str) -> dict:
if compliance_root and os.path.isdir(compliance_root):
_load_jsons_from_dir(compliance_root, provider, bulk)
# External multi-provider frameworks via the dedicated universal entry
# point group, kept separate from the per-provider `prowler.compliance`
# group so the legacy loader never parses a universal JSON. Built-ins
# (already in bulk) win on a name collision.
for ep in importlib.metadata.entry_points(group="prowler.compliance.universal"):
try:
module = ep.load()
ep_dir = (
module.__path__[0]
if hasattr(module, "__path__")
else os.path.dirname(module.__file__)
)
if os.path.isdir(ep_dir):
_load_jsons_from_dir(ep_dir, provider, bulk)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as e:
logger.error(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}] -- {e}")
return bulk
+37 -12
View File
@@ -11,10 +11,10 @@ from typing import Any, Dict, Optional, Set
from pydantic.v1 import BaseModel, Field, ValidationError, validator
from pydantic.v1.error_wrappers import ErrorWrapper
from prowler.config.config import EXTERNAL_TOOL_PROVIDERS, Provider
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.check.utils import recover_checks_from_provider
from prowler.lib.logger import logger
from prowler.providers.common.provider import Provider as ProviderABC
# Valid ResourceGroup values as defined in the RFC
VALID_RESOURCE_GROUPS = frozenset(
@@ -259,7 +259,7 @@ class CheckMetadata(BaseModel):
)
if (
value_lower not in VALID_CATEGORIES
and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS
and not ProviderABC.is_tool_wrapper_provider(values.get("Provider"))
):
raise ValueError(
f"Invalid category: '{value_lower}'. Must be one of: {', '.join(sorted(VALID_CATEGORIES))}."
@@ -288,7 +288,9 @@ class CheckMetadata(BaseModel):
raise ValueError("ServiceName must be a non-empty string")
check_id = values.get("CheckID")
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if check_id and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
service_from_check_id = check_id.split("_")[0]
if service_name != service_from_check_id:
raise ValueError(
@@ -304,7 +306,9 @@ class CheckMetadata(BaseModel):
if not check_id:
raise ValueError("CheckID must be a non-empty string")
if check_id and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if check_id and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
if "-" in check_id:
raise ValueError(
f"CheckID {check_id} contains a hyphen, which is not allowed"
@@ -313,8 +317,9 @@ class CheckMetadata(BaseModel):
return check_id
@validator("CheckTitle", pre=True, always=True)
@classmethod
def validate_check_title(cls, check_title, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(check_title) > 150:
raise ValueError(
f"CheckTitle must not exceed 150 characters, got {len(check_title)} characters"
@@ -326,14 +331,18 @@ class CheckMetadata(BaseModel):
return check_title
@validator("RelatedUrl", pre=True, always=True)
@classmethod
def validate_related_url(cls, related_url, values): # noqa: F841
if related_url and values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if related_url and not ProviderABC.is_tool_wrapper_provider(
values.get("Provider")
):
raise ValueError("RelatedUrl must be empty. This field is deprecated.")
return related_url
@validator("Remediation")
@classmethod
def validate_recommendation_url(cls, remediation, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
url = remediation.Recommendation.Url
if url and not url.startswith("https://hub.prowler.com/"):
raise ValueError(
@@ -346,7 +355,7 @@ class CheckMetadata(BaseModel):
provider = values.get("Provider", "").lower()
# Non-AWS providers must have an empty CheckType list
if provider != "aws" and provider not in EXTERNAL_TOOL_PROVIDERS:
if provider != "aws" and not ProviderABC.is_tool_wrapper_provider(provider):
if check_type:
raise ValueError(
f"CheckType must be empty for non-AWS providers. Got {check_type} for provider '{provider}'."
@@ -371,8 +380,9 @@ class CheckMetadata(BaseModel):
return check_type
@validator("Description", pre=True, always=True)
@classmethod
def validate_description(cls, description, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(description) > 400:
raise ValueError(
f"Description must not exceed 400 characters, got {len(description)} characters"
@@ -380,8 +390,9 @@ class CheckMetadata(BaseModel):
return description
@validator("Risk", pre=True, always=True)
@classmethod
def validate_risk(cls, risk, values): # noqa: F841
if values.get("Provider") not in EXTERNAL_TOOL_PROVIDERS:
if not ProviderABC.is_tool_wrapper_provider(values.get("Provider")):
if len(risk) > 400:
raise ValueError(
f"Risk must not exceed 400 characters, got {len(risk)} characters"
@@ -433,6 +444,20 @@ class CheckMetadata(BaseModel):
metadata_file = f"{check_path}/{check_name}.metadata.json"
# Load metadata
check_metadata = load_check_metadata(metadata_file)
# Built-in wins on CheckID collision. Plug-in entry points are
# appended after built-ins by `recover_checks_from_provider`, so
# a duplicate CheckID here means an entry-point check is trying
# to override a built-in. Ignore the override (the built-in
# metadata stays) and surface it via a warning — matching the
# precedence enforced by `_resolve_check_module`.
if check_metadata.CheckID in bulk_check_metadata:
logger.warning(
f"Plug-in check metadata '{check_metadata.CheckID}' "
f"(loaded from '{metadata_file}') is being IGNORED — "
f"a built-in with the same CheckID exists. To use your "
f"plug-in, register it under a different CheckID."
)
continue
bulk_check_metadata[check_metadata.CheckID] = check_metadata
return bulk_check_metadata
@@ -470,7 +495,7 @@ class CheckMetadata(BaseModel):
# If the bulk checks metadata is not provided, get it
if not bulk_checks_metadata:
bulk_checks_metadata = {}
available_providers = [p.value for p in Provider]
available_providers = ProviderABC.get_available_providers()
for provider_name in available_providers:
bulk_checks_metadata.update(CheckMetadata.get_bulk(provider_name))
if provider:
@@ -495,7 +520,7 @@ class CheckMetadata(BaseModel):
# Loaded here, as it is not always needed
if not bulk_compliance_frameworks:
bulk_compliance_frameworks = {}
available_providers = [p.value for p in Provider]
available_providers = ProviderABC.get_available_providers()
for provider in available_providers:
bulk_compliance_frameworks = Compliance.get_bulk(provider=provider)
checks_from_compliance_framework = (
+62
View File
@@ -0,0 +1,62 @@
"""Standalone helper for tool-wrapper provider detection.
A provider is a "tool wrapper" if it delegates scanning to an external tool
(Trivy, promptfoo, etc.) instead of running checks/services through the
standard Prowler engine. This module is the single source of truth for that
classification across the codebase.
Kept as a leaf module with no Prowler imports beyond the leaf
`external_tool_providers` so it can be referenced from `prowler.lib.check.*`
and `prowler.providers.common.provider` without forming an import cycle.
"""
import importlib.metadata
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.providers.common.builtin import is_builtin_provider
# Module-level cache for entry-point classes consulted by this helper.
# Independent of `Provider._ep_providers` to keep this module leaf — the cost
# of a duplicate cache entry is negligible (one class object per external
# provider, loaded lazily on first lookup).
_ep_class_cache: dict = {}
def _load_ep_class(provider: str):
"""Return the entry-point provider class for `provider`, or None.
Caches the result in `_ep_class_cache`. Errors during entry-point loading
are swallowed (returning None) so a broken plug-in never crashes the
is-tool-wrapper check; it just falls through to "not a tool wrapper".
"""
if provider in _ep_class_cache:
return _ep_class_cache[provider]
for ep in importlib.metadata.entry_points(group="prowler.providers"):
if ep.name == provider:
try:
cls = ep.load()
except Exception:
cls = None
_ep_class_cache[provider] = cls
return cls
_ep_class_cache[provider] = None
return None
def is_tool_wrapper_provider(provider: str) -> bool:
"""Return True if the provider delegates scanning to an external tool.
Combines the built-in `EXTERNAL_TOOL_PROVIDERS` frozenset (fast path for
iac/llm/image) with the `is_external_tool_provider` class attribute of
external plug-ins registered via entry points. This is the single source
of truth consulted by `__main__`, the `CheckMetadata` validators, the
check-loading utilities, and the checks loader.
"""
if provider in EXTERNAL_TOOL_PROVIDERS:
return True
# Built-in wins: short-circuit before ep.load() so a same-name plug-in
# cannot flip a built-in onto the tool-wrapper path or run its code.
if is_builtin_provider(provider):
return False
cls = _load_ep_class(provider)
return bool(cls and getattr(cls, "is_external_tool_provider", False))
+84 -23
View File
@@ -1,9 +1,43 @@
import importlib
import importlib.metadata
import importlib.util
import os
import sys
from pkgutil import walk_packages
from prowler.lib.check.external_tool_providers import EXTERNAL_TOOL_PROVIDERS
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider
from prowler.lib.logger import logger
from prowler.providers.common.builtin import is_builtin_provider
def _recover_ep_checks(provider: str, service: str = None) -> list[tuple]:
"""Discover external checks registered via entry points for a provider.
External plugins follow the same layout as built-ins:
`{plugin_root}.services.{service}.{check}.{check}`
When `service` is provided, only entry points whose dotted path contains
`.services.{service}.` are included mirroring how built-in discovery
filters by the `prowler.providers.{provider}.services.{service}` package.
Uses find_spec to locate the check module without importing it,
avoiding service client initialization at discovery time.
"""
checks = []
for ep in importlib.metadata.entry_points(group=f"prowler.checks.{provider}"):
try:
if service and f".services.{service}." not in ep.value:
continue
spec = importlib.util.find_spec(ep.value)
if spec and spec.origin:
check_path = os.path.dirname(spec.origin)
checks.append((ep.name, check_path))
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return checks
def recover_checks_from_provider(
@@ -15,29 +49,55 @@ def recover_checks_from_provider(
Returns a list of tuples with the following format (check_name, check_path)
"""
try:
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — they delegate
# scanning to an external tool and have no checks to recover.
# Single source of truth: combines the EXTERNAL_TOOL_PROVIDERS
# frozenset (built-ins) with the per-provider `is_external_tool_provider`
# class attribute (so external plug-ins opt in via the contract).
if is_tool_wrapper_provider(provider):
return []
checks = []
modules = list_modules(provider, service)
for module_name in modules:
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
check_module_name = module_name.name
# We need to exclude common shared libraries in services
if (
check_module_name.count(".") == 6
and ".lib." not in check_module_name
and (not check_module_name.endswith("_fixer") or include_fixers)
):
check_path = module_name.module_finder.path
# Check name is the last part of the check_module_name
check_name = check_module_name.split(".")[-1]
check_info = (check_name, check_path)
checks.append(check_info)
except ModuleNotFoundError:
logger.critical(f"Service {service} was not found for the {provider} provider.")
sys.exit(1)
# Built-in checks from prowler.providers.{provider}.services. Gate
# the built-in branch on `is_builtin_provider(provider)` — calling
# `find_spec` directly on `prowler.providers.{provider}.services`
# would propagate `ModuleNotFoundError` when the parent package
# `prowler.providers.{provider}` does not exist (i.e. the provider
# is external), instead of returning None. The leaf helper
# encapsulates the safe lookup, so we only run the built-in
# discovery when the provider actually ships with the SDK; for
# external providers we go straight to entry points.
if is_builtin_provider(provider):
modules = list_modules(provider, service)
for module_name in modules:
# Format: "prowler.providers.{provider}.services.{service}.{check_name}.{check_name}"
check_module_name = module_name.name
# We need to exclude common shared libraries in services
if (
check_module_name.count(".") == 6
and ".lib." not in check_module_name
and (not check_module_name.endswith("_fixer") or include_fixers)
):
check_path = module_name.module_finder.path
check_name = check_module_name.split(".")[-1]
check_info = (check_name, check_path)
checks.append(check_info)
# External checks registered via entry points — always consulted, with
# optional service filter. Previously gated by `if not service:`, which
# prevented external providers from being usable with --service.
checks.extend(_recover_ep_checks(provider, service))
# A service was requested but nothing matched in either built-ins or
# entry points — surface this as a clear error instead of silently
# returning an empty list.
if service and not checks:
logger.critical(
f"Service '{service}' was not found for the '{provider}' provider "
f"(neither as a built-in nor via external entry points)."
)
sys.exit(1)
except Exception as e:
logger.critical(f"{e.__class__.__name__}[{e.__traceback__.tb_lineno}]: {e}")
sys.exit(1)
@@ -64,8 +124,9 @@ def recover_checks_from_service(service_list: list, provider: str) -> set:
Returns a set of checks from the given services
"""
try:
# Bypass check loading for providers that use external tools directly
if provider in EXTERNAL_TOOL_PROVIDERS:
# Bypass check loading for tool-wrapper providers — symmetric with
# `recover_checks_from_provider` above, using the same source of truth.
if is_tool_wrapper_provider(provider):
return set()
checks = set()
+52 -7
View File
@@ -20,19 +20,61 @@ from prowler.providers.common.arguments import (
validate_provider_arguments,
validate_sarif_usage,
)
from prowler.providers.common.provider import Provider
class ProwlerArgumentParser:
# Set the default parser
def __init__(self):
# Discover any providers not in the hardcoded list below
# TODO - First step to support current providers and the new external provider implementation
known_providers = {
"aws",
"azure",
"gcp",
"kubernetes",
"m365",
"github",
"googleworkspace",
"cloudflare",
"oraclecloud",
"openstack",
"alibabacloud",
"iac",
"llm",
"image",
"nhn",
"mongodbatlas",
"vercel",
"okta",
"scaleway",
"stackit",
}
all_providers = set(Provider.get_available_providers())
new_providers = sorted(all_providers - known_providers)
# Build extra strings for dynamically discovered providers
extra_providers_csv = ""
extra_providers_text = ""
if new_providers:
providers_help = Provider.get_providers_help_text()
extra_providers_csv = "," + ",".join(new_providers)
extra_lines = []
for name in new_providers:
help_text = providers_help.get(name, "")
if help_text:
extra_lines.append(f" {name:<20}{help_text}")
if extra_lines:
extra_providers_text = "\n" + "\n".join(extra_lines)
# CLI Arguments
self.parser = argparse.ArgumentParser(
prog="prowler",
formatter_class=RawTextHelpFormatter,
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm} ...",
epilog="""
usage=f"prowler [-h] [--version] {{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel,dashboard,iac,image,llm{extra_providers_csv}}} ...",
epilog=f"""
Available Cloud Providers:
{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel}
{{aws,azure,gcp,kubernetes,m365,github,googleworkspace,okta,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,scaleway,stackit,vercel{extra_providers_csv}}}
aws AWS Provider
azure Azure Provider
gcp GCP Provider
@@ -52,13 +94,14 @@ Available Cloud Providers:
nhn NHN Provider (Unofficial)
mongodbatlas MongoDB Atlas Provider
scaleway Scaleway Provider
vercel Vercel Provider
vercel Vercel Provider{extra_providers_text}
Available components:
dashboard Local dashboard
To see the different available options on a specific component, run:
prowler {provider|dashboard} -h|--help
prowler {{provider|dashboard}} -h|--help
Detailed documentation at https://docs.prowler.com
""",
@@ -117,8 +160,10 @@ Detailed documentation at https://docs.prowler.com
and (sys.argv[1] not in ("-v", "--version"))
):
# Since the provider is always the second argument, we are checking if
# a flag, starting by "-", is supplied
if "-" in sys.argv[1]:
# a flag is supplied. Use startswith("-") instead of "in" to avoid
# matching external provider names that contain hyphens
# (e.g. "local-acme-snowflake").
if sys.argv[1].startswith("-"):
sys.argv = self.__set_default_provider__(sys.argv)
# Provider aliases mapping
+32 -2
View File
@@ -18,6 +18,9 @@ from prowler.lib.outputs.compliance.kisa_ismsp.kisa_ismsp import get_kisa_ismsp_
from prowler.lib.outputs.compliance.mitre_attack.mitre_attack import (
get_mitre_attack_table,
)
from prowler.lib.outputs.compliance.okta_idaas_stig.okta_idaas_stig import (
get_okta_idaas_stig_table,
)
from prowler.lib.outputs.compliance.prowler_threatscore.prowler_threatscore import (
get_prowler_threatscore_table,
)
@@ -252,8 +255,8 @@ def display_compliance_table(
output_directory,
compliance_overview,
)
else:
get_generic_compliance_table(
elif compliance_framework.startswith("okta_idaas_stig"):
get_okta_idaas_stig_table(
findings,
bulk_checks_metadata,
compliance_framework,
@@ -261,6 +264,33 @@ def display_compliance_table(
output_directory,
compliance_overview,
)
else:
# Try provider-specific table first, fall back to generic
from prowler.providers.common.provider import Provider
provider = Provider.get_global_provider()
handled = False
if provider is not None:
try:
handled = provider.display_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
except NotImplementedError:
handled = False
if not handled:
get_generic_compliance_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}:{error.__traceback__.tb_lineno} -- {error}"
@@ -34,60 +34,48 @@ class GenericCompliance(ComplianceOutput):
Returns:
- None
"""
def compliance_row(requirement, attribute, finding=None):
# Read attribute fields defensively: GenericCompliance is the
# last-resort renderer for any framework, and provider-specific
# schemas (e.g. CIS, ENS, ISO27001) do not declare the universal
# Section/SubSection/SubGroup/Service/Type/Comment fields.
return GenericComplianceModel(
Provider=(finding.provider if finding else compliance.Provider.lower()),
Description=compliance.Description,
AccountId=finding.account_uid if finding else "",
Region=finding.region if finding else "",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=getattr(attribute, "Section", None),
Requirements_Attributes_SubSection=getattr(
attribute, "SubSection", None
),
Requirements_Attributes_SubGroup=getattr(attribute, "SubGroup", None),
Requirements_Attributes_Service=getattr(attribute, "Service", None),
Requirements_Attributes_Type=getattr(attribute, "Type", None),
Requirements_Attributes_Comment=getattr(attribute, "Comment", None),
Status=finding.status if finding else "MANUAL",
StatusExtended=(finding.status_extended if finding else "Manual check"),
ResourceId=finding.resource_uid if finding else "manual_check",
ResourceName=finding.resource_name if finding else "Manual check",
CheckId=finding.check_id if finding else "manual",
Muted=finding.muted if finding else False,
Framework=compliance.Framework,
Name=compliance.Name,
)
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GenericComplianceModel(
Provider=finding.provider,
Description=compliance.Description,
AccountId=finding.account_uid,
Region=finding.region,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_SubGroup=attribute.SubGroup,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Requirements_Attributes_Comment=attribute.Comment,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
self._data.append(
compliance_row(requirement, attribute, finding)
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = GenericComplianceModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
AccountId="",
Region="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_SubSection=attribute.SubSection,
Requirements_Attributes_SubGroup=attribute.SubGroup,
Requirements_Attributes_Service=attribute.Service,
Requirements_Attributes_Type=attribute.Type,
Requirements_Attributes_Comment=attribute.Comment,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
self._data.append(compliance_row(requirement, attribute))
@@ -0,0 +1,32 @@
from typing import Optional
from pydantic.v1 import BaseModel
class OktaIDaaSSTIGModel(BaseModel):
"""
OktaIDaaSSTIGModel generates a finding's output in DISA Okta IDaaS STIG Compliance format.
"""
Provider: str
Description: str
OrganizationDomain: str
AssessmentDate: str
Requirements_Id: str
Requirements_Name: str
Requirements_Description: str
Requirements_Attributes_Section: str
Requirements_Attributes_Severity: str
Requirements_Attributes_RuleID: str
Requirements_Attributes_StigID: str
Requirements_Attributes_CCI: Optional[list[str]] = None
Requirements_Attributes_CheckText: Optional[str] = None
Requirements_Attributes_FixText: Optional[str] = None
Status: str
StatusExtended: str
ResourceId: str
ResourceName: str
CheckId: str
Muted: bool
Framework: str
Name: str
@@ -0,0 +1,98 @@
from colorama import Fore, Style
from tabulate import tabulate
from prowler.config.config import orange_color
def get_okta_idaas_stig_table(
findings: list,
bulk_checks_metadata: dict,
compliance_framework: str,
output_filename: str,
output_directory: str,
compliance_overview: bool,
):
section_table = {
"Provider": [],
"Section": [],
"Status": [],
"Muted": [],
}
pass_count = []
fail_count = []
muted_count = []
sections = {}
for index, finding in enumerate(findings):
check = bulk_checks_metadata[finding.check_metadata.CheckID]
check_compliances = check.Compliance
for compliance in check_compliances:
if compliance.Framework == "Okta-IDaaS-STIG":
for requirement in compliance.Requirements:
for attribute in requirement.Attributes:
section = attribute.Section
if section not in sections:
sections[section] = {"FAIL": 0, "PASS": 0, "Muted": 0}
if finding.muted:
if index not in muted_count:
muted_count.append(index)
sections[section]["Muted"] += 1
else:
if finding.status == "FAIL" and index not in fail_count:
fail_count.append(index)
sections[section]["FAIL"] += 1
elif finding.status == "PASS" and index not in pass_count:
pass_count.append(index)
sections[section]["PASS"] += 1
sections = dict(sorted(sections.items()))
for section in sections:
section_table["Provider"].append(compliance.Provider)
section_table["Section"].append(section)
if sections[section]["FAIL"] > 0:
section_table["Status"].append(
f"{Fore.RED}FAIL({sections[section]['FAIL']}){Style.RESET_ALL}"
)
else:
if sections[section]["PASS"] > 0:
section_table["Status"].append(
f"{Fore.GREEN}PASS({sections[section]['PASS']}){Style.RESET_ALL}"
)
else:
section_table["Status"].append(f"{Fore.GREEN}PASS{Style.RESET_ALL}")
section_table["Muted"].append(
f"{orange_color}{sections[section]['Muted']}{Style.RESET_ALL}"
)
if (
len(fail_count) + len(pass_count) + len(muted_count) > 1
): # If there are no resources, don't print the compliance table
print(
f"\nCompliance Status of {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Framework:"
)
total_findings_count = len(fail_count) + len(pass_count) + len(muted_count)
overview_table = [
[
f"{Fore.RED}{round(len(fail_count) / total_findings_count * 100, 2)}% ({len(fail_count)}) FAIL{Style.RESET_ALL}",
f"{Fore.GREEN}{round(len(pass_count) / total_findings_count * 100, 2)}% ({len(pass_count)}) PASS{Style.RESET_ALL}",
f"{orange_color}{round(len(muted_count) / total_findings_count * 100, 2)}% ({len(muted_count)}) MUTED{Style.RESET_ALL}",
]
]
print(tabulate(overview_table, tablefmt="rounded_grid"))
if not compliance_overview:
if len(fail_count) > 0 and len(section_table["Section"]) > 0:
print(
f"\nFramework {Fore.YELLOW}{compliance_framework.upper()}{Style.RESET_ALL} Results:"
)
print(
tabulate(
section_table,
tablefmt="rounded_grid",
headers="keys",
)
)
print(f"\nDetailed results of {compliance_framework.upper()} are in:")
print(
f" - CSV: {output_directory}/compliance/{output_filename}_{compliance_framework}.csv\n"
)
@@ -0,0 +1,95 @@
from prowler.config.config import timestamp
from prowler.lib.check.compliance_models import Compliance
from prowler.lib.outputs.compliance.compliance_output import ComplianceOutput
from prowler.lib.outputs.compliance.okta_idaas_stig.models import OktaIDaaSSTIGModel
from prowler.lib.outputs.finding import Finding
class OktaIDaaSSTIG(ComplianceOutput):
"""
This class represents the Okta IDaaS STIG compliance output.
Attributes:
- _data (list): A list to store transformed data from findings.
- _file_descriptor (TextIOWrapper): A file descriptor to write data to a file.
Methods:
- transform: Transforms findings into Okta IDaaS STIG compliance format.
"""
def transform(
self,
findings: list[Finding],
compliance: Compliance,
_compliance_name: str,
) -> None:
"""
Transforms a list of findings into Okta IDaaS STIG compliance format.
Parameters:
- findings (list): A list of findings.
- compliance (Compliance): A compliance model.
- _compliance_name (str): The name of the compliance model (unused).
Returns:
- None
"""
for finding in findings:
for requirement in compliance.Requirements:
# Source of truth: framework JSON, not finding.compliance snapshot (avoids CSV/UI count drift).
if finding.check_id in requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = OktaIDaaSSTIGModel(
Provider=finding.provider,
Description=compliance.Description,
OrganizationDomain=finding.account_name,
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Name=requirement.Name,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_Severity=attribute.Severity.value,
Requirements_Attributes_RuleID=attribute.RuleID,
Requirements_Attributes_StigID=attribute.StigID,
Requirements_Attributes_CCI=attribute.CCI,
Requirements_Attributes_CheckText=attribute.CheckText,
Requirements_Attributes_FixText=attribute.FixText,
Status=finding.status,
StatusExtended=finding.status_extended,
ResourceId=finding.resource_uid,
ResourceName=finding.resource_name,
CheckId=finding.check_id,
Muted=finding.muted,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
# Add manual requirements to the compliance output
for requirement in compliance.Requirements:
if not requirement.Checks:
for attribute in requirement.Attributes:
compliance_row = OktaIDaaSSTIGModel(
Provider=compliance.Provider.lower(),
Description=compliance.Description,
OrganizationDomain="",
AssessmentDate=str(timestamp),
Requirements_Id=requirement.Id,
Requirements_Name=requirement.Name,
Requirements_Description=requirement.Description,
Requirements_Attributes_Section=attribute.Section,
Requirements_Attributes_Severity=attribute.Severity.value,
Requirements_Attributes_RuleID=attribute.RuleID,
Requirements_Attributes_StigID=attribute.StigID,
Requirements_Attributes_CCI=attribute.CCI,
Requirements_Attributes_CheckText=attribute.CheckText,
Requirements_Attributes_FixText=attribute.FixText,
Status="MANUAL",
StatusExtended="Manual check",
ResourceId="manual_check",
ResourceName="Manual check",
CheckId="manual",
Muted=False,
Framework=compliance.Framework,
Name=compliance.Name,
)
self._data.append(compliance_row)
+5
View File
@@ -517,6 +517,11 @@ class Finding(BaseModel):
check_output, "fixed_version", ""
)
else:
# Dynamic fallback: any external/custom provider
provider_data = provider.get_finding_output_data(check_output)
output_data.update(provider_data)
# check_output Unique ID
# TODO: move this to a function
# TODO: in Azure, GCP and K8s there are findings without resource_name
+7 -5
View File
@@ -1608,11 +1608,13 @@ class HTML(Output):
# Azure_provider --> azure
# Kubernetes_provider --> kubernetes
# Dynamically get the Provider quick inventory handler
provider_html_assessment_summary_function = (
f"get_{provider.type}_assessment_summary"
)
return getattr(HTML, provider_html_assessment_summary_function)(provider)
# Try static method first, fall back to provider method
method_name = f"get_{provider.type}_assessment_summary"
if hasattr(HTML, method_name):
return getattr(HTML, method_name)(provider)
else:
# Dynamic fallback: any external/custom provider
return provider.get_html_assessment_summary()
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
+15 -1
View File
@@ -229,7 +229,9 @@ class MarkdownToADFConverter:
return node
def _paragraph_with_text(self, text: str) -> Dict:
return {"type": "paragraph", "content": [self._create_text_node(text, None)]}
# ADF forbids empty text nodes; emit an empty paragraph instead.
content = [self._create_text_node(text, None)] if text else []
return {"type": "paragraph", "content": content}
@staticmethod
def _pop_mark(marks_stack: List[Dict], mark_type: str) -> None:
@@ -1118,6 +1120,18 @@ class Jira:
tenant_info: str = "",
) -> dict:
# ADF forbids empty text nodes, so Jira rejects them with 400 INVALID_INPUT.
def _safe(value: str) -> str:
return value if (value and value.strip()) else "-"
check_id = _safe(check_id)
check_title = _safe(check_title)
status_extended = _safe(status_extended)
provider = _safe(provider)
region = _safe(region)
resource_uid = _safe(resource_uid)
resource_name = _safe(resource_name)
table_rows = [
{
"type": "tableRow",
+36 -22
View File
@@ -7,45 +7,52 @@ from prowler.lib.outputs.common import Status
from prowler.lib.outputs.finding import Finding
def stdout_report(finding, color, verbose, status, fix):
def stdout_report(finding, color, verbose, status, fix, provider=None):
if finding.check_metadata.Provider == "aws":
details = finding.region
if finding.check_metadata.Provider == "azure":
elif finding.check_metadata.Provider == "azure":
details = finding.location
if finding.check_metadata.Provider == "gcp":
elif finding.check_metadata.Provider == "gcp":
details = finding.location.lower()
if finding.check_metadata.Provider == "kubernetes":
elif finding.check_metadata.Provider == "kubernetes":
details = finding.namespace.lower()
if finding.check_metadata.Provider == "github":
elif finding.check_metadata.Provider == "github":
details = finding.owner
if finding.check_metadata.Provider == "m365":
elif finding.check_metadata.Provider == "m365":
details = finding.location
if finding.check_metadata.Provider == "mongodbatlas":
elif finding.check_metadata.Provider == "mongodbatlas":
details = finding.location
if finding.check_metadata.Provider == "nhn":
elif finding.check_metadata.Provider == "nhn":
details = finding.location
if finding.check_metadata.Provider == "stackit":
elif finding.check_metadata.Provider == "stackit":
details = finding.location
if finding.check_metadata.Provider == "llm":
elif finding.check_metadata.Provider == "llm":
details = finding.check_metadata.CheckID
if finding.check_metadata.Provider == "iac":
elif finding.check_metadata.Provider == "iac":
details = finding.check_metadata.CheckID
if finding.check_metadata.Provider == "oraclecloud":
elif finding.check_metadata.Provider == "oraclecloud":
details = finding.region
if finding.check_metadata.Provider == "alibabacloud":
elif finding.check_metadata.Provider == "alibabacloud":
details = finding.region
if finding.check_metadata.Provider == "openstack":
elif finding.check_metadata.Provider == "openstack":
details = finding.region
if finding.check_metadata.Provider == "cloudflare":
elif finding.check_metadata.Provider == "cloudflare":
details = finding.zone_name
if finding.check_metadata.Provider == "googleworkspace":
elif finding.check_metadata.Provider == "googleworkspace":
details = finding.location
if finding.check_metadata.Provider == "vercel":
elif finding.check_metadata.Provider == "vercel":
details = finding.region
if finding.check_metadata.Provider == "okta":
elif finding.check_metadata.Provider == "okta":
details = finding.region
if finding.check_metadata.Provider == "scaleway":
elif finding.check_metadata.Provider == "scaleway":
details = finding.region
else:
# Dynamic fallback: any external/custom provider
if provider is None:
from prowler.providers.common.provider import Provider
provider = Provider.get_global_provider()
details = provider.get_stdout_detail(finding)
if (verbose or fix) and (not status or finding.status in status):
if finding.muted:
@@ -65,12 +72,15 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "verbose"):
verbose = output_options.verbose
if check_findings:
# TO-DO Generic Function
if provider.type == "aws":
check_findings.sort(key=lambda x: x.region)
if provider.type == "azure":
elif provider.type == "azure":
check_findings.sort(key=lambda x: x.subscription)
else:
# Dynamic fallback: any external/custom provider
sort_key = provider.get_finding_sort_key()
if sort_key and isinstance(sort_key, str):
check_findings.sort(key=lambda x: getattr(x, sort_key, ""))
for finding in check_findings:
# Print findings by stdout
@@ -81,12 +91,16 @@ def report(check_findings, provider, output_options):
if hasattr(output_options, "fixer"):
fixer = output_options.fixer
color = set_report_color(finding.status, finding.muted)
# Pass the local `provider` through so the dynamic else inside
# `stdout_report` does not have to consult the global singleton
# — defeating the whole purpose of the new parameter.
stdout_report(
finding,
color,
verbose,
status,
fixer,
provider=provider,
)
else: # No service resources in the whole account
+3
View File
@@ -121,6 +121,9 @@ def display_summary_table(
elif provider.type == "scaleway":
entity_type = "Organization"
audited_entities = provider.identity.organization_id
else:
# Dynamic fallback: any external/custom provider
entity_type, audited_entities = provider.get_summary_entity()
# Check if there are findings and that they are not all MANUAL
if findings and not all(finding.status == "MANUAL" for finding in findings):
+9 -4
View File
@@ -4,8 +4,8 @@ from types import SimpleNamespace
from typing import Generator
from prowler.lib.check.check import (
_resolve_check_module,
execute,
import_check,
list_services,
update_audit_metadata,
)
@@ -426,9 +426,14 @@ class Scan:
# Recover service from check name
service = get_service_name_from_check_name(check_name)
try:
# Import check module
check_module_path = f"prowler.providers.{self._provider.type}.services.{service}.{check_name}.{check_name}"
lib = import_check(check_module_path)
# Import check module (built-in or entry point) —
# delegates to `_resolve_check_module` so external
# providers registered via entry points are resolved
# correctly (their checks do not live under
# `prowler.providers.{type}.services...`).
lib = _resolve_check_module(
self._provider.type, service, check_name
)
# Recover functions from check
check_to_execute = getattr(lib, check_name)
check = check_to_execute()
@@ -0,0 +1,41 @@
{
"Provider": "aws",
"CheckID": "bedrock_agent_role_least_privilege",
"CheckTitle": "Amazon Bedrock agent execution role follows least privilege",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
"TTPs/Privilege Escalation"
],
"ServiceName": "bedrock",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "Other",
"ResourceGroup": "ai_ml",
"Description": "**Bedrock Agent** execution roles (`agentResourceRoleArn`) should grant only the minimum permissions the agent needs. The evaluation FAILs when the role has an AWS-managed `*FullAccess` policy attached, has an inline statement allowing broad actions on `Resource: \"*\"`, or has no permissions boundary configured.",
"Risk": "An overly permissive **Bedrock Agent** execution role turns a successful **prompt injection** into AWS privilege escalation. A model coerced into calling tools can invoke any API the role allows — reading secrets, modifying IAM, exfiltrating data from S3, or pivoting laterally. **Least privilege** plus a **permissions boundary** keeps the blast radius bounded even when guardrails fail.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/bedrock/latest/userguide/agents-permissions.html",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege"
],
"Remediation": {
"Code": {
"CLI": "aws iam put-role-permissions-boundary --role-name <execution_role_name> --permissions-boundary <boundary_policy_arn>",
"NativeIaC": "",
"Other": "1. Identify the Bedrock Agent's execution role (agentResourceRoleArn) in the IAM console\n2. Detach any AWS-managed *FullAccess policies (e.g. AmazonBedrockFullAccess, AdministratorAccess)\n3. Replace inline policies that use Resource: \"*\" with statements scoped to specific resource ARNs and minimal action sets\n4. Attach a permissions boundary that caps what the role can ever do, even if a future policy is added\n5. Re-run Prowler to confirm the check passes",
"Terraform": "```hcl\nresource \"aws_iam_role\" \"bedrock_agent\" {\n name = \"<execution_role_name>\"\n assume_role_policy = data.aws_iam_policy_document.trust.json\n permissions_boundary = aws_iam_policy.bedrock_agent_boundary.arn # CRITICAL: caps maximum privileges\n}\n\nresource \"aws_iam_role_policy\" \"bedrock_agent_inline\" {\n role = aws_iam_role.bedrock_agent.name\n policy = jsonencode({\n Version = \"2012-10-17\",\n Statement = [{\n Effect = \"Allow\",\n Action = [\"s3:GetObject\"], # CRITICAL: narrow action\n Resource = [\"arn:aws:s3:::my-rag-bucket/*\"] # CRITICAL: narrow resource\n }]\n })\n}\n```"
},
"Recommendation": {
"Text": "Apply **least privilege** to every Bedrock Agent execution role: scope `Action` and `Resource` to exactly what the agent needs, avoid AWS-managed `*FullAccess` policies, and always attach a **permissions boundary** so that future policy edits cannot exceed an approved ceiling. Treat agent roles as high-risk because prompt injection can weaponize any granted permission.",
"Url": "https://hub.prowler.com/check/bedrock_agent_role_least_privilege"
}
},
"Categories": [
"gen-ai"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,101 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.bedrock.bedrock_agent_client import (
bedrock_agent_client,
)
from prowler.providers.aws.services.iam.iam_client import iam_client
from prowler.providers.aws.services.iam.lib.policy import check_admin_access
from prowler.providers.aws.services.iam.lib.privilege_escalation import (
check_privilege_escalation,
)
class bedrock_agent_role_least_privilege(Check):
"""Ensure Bedrock Agent execution roles follow least privilege.
A Bedrock Agent's execution role is evaluated against three criteria:
- No AWS-managed ``*FullAccess`` policy attached.
- No attached or inline policy granting administrative access or known
privilege escalation combinations.
- A permissions boundary is configured on the role.
"""
def execute(self) -> list[Check_Report_AWS]:
"""Run the least-privilege evaluation across all Bedrock Agents.
Returns:
A list of ``Check_Report_AWS`` with one entry per agent. The
status is ``FAIL`` when any of the criteria above is violated,
or when the execution role cannot be resolved in IAM.
"""
findings = []
roles_by_arn = {role.arn: role for role in (iam_client.roles or [])}
for agent in bedrock_agent_client.agents.values():
report = Check_Report_AWS(metadata=self.metadata(), resource=agent)
report.status = "PASS"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role follows least privilege."
)
role = roles_by_arn.get(agent.role_arn) if agent.role_arn else None
if role is None:
report.status = "FAIL"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role could not be "
f"resolved in IAM and cannot be evaluated for least privilege."
)
findings.append(report)
continue
violations = []
for policy in role.attached_policies:
policy_arn = policy.get("PolicyArn", "")
policy_name = policy.get("PolicyName") or policy_arn
if policy_arn.startswith(
"arn:aws:iam::aws:policy/"
) and policy_arn.endswith("FullAccess"):
violations.append(
f"managed policy {policy_name} grants full access"
)
continue
policy_obj = iam_client.policies.get(policy_arn)
if policy_obj is None or not policy_obj.document:
continue
document = policy_obj.document
if check_admin_access(document):
violations.append(
f"managed policy {policy_name} grants administrative access"
)
elif check_privilege_escalation(document):
violations.append(
f"managed policy {policy_name} allows privilege escalation"
)
for inline_name in role.inline_policies:
policy_obj = iam_client.policies.get(f"{role.arn}:policy/{inline_name}")
if policy_obj is None or not policy_obj.document:
continue
document = policy_obj.document
if check_admin_access(document):
violations.append(
f"inline policy {inline_name} grants administrative access"
)
elif check_privilege_escalation(document):
violations.append(
f"inline policy {inline_name} allows privilege escalation"
)
if not role.permissions_boundary:
violations.append("no permissions boundary configured")
if violations:
report.status = "FAIL"
report.status_extended = (
f"Bedrock Agent {agent.name} execution role violates least "
f"privilege: {'; '.join(violations)}."
)
findings.append(report)
return findings
@@ -146,6 +146,7 @@ class BedrockAgent(AWSService):
self.prompts = {}
self.prompt_scanned_regions: set = set()
self.__threading_call__(self._list_agents)
self.__threading_call__(self._get_agent, self.agents.values())
self.__threading_call__(self._list_prompts)
self.__threading_call__(self._get_prompt, self.prompts.values())
self.__threading_call__(self._list_tags_for_resource, self.agents.values())
@@ -174,6 +175,22 @@ class BedrockAgent(AWSService):
f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_agent(self, agent):
"""Fetch full agent details to capture the execution role ARN.
list_agents only returns summaries (no agentResourceRoleArn), so we
need a per-agent GetAgent call. Stored on the Agent model for use by
checks like bedrock_agent_role_least_privilege.
"""
logger.info("Bedrock Agent - Getting Agent...")
try:
agent_info = self.regional_clients[agent.region].get_agent(agentId=agent.id)
agent.role_arn = agent_info.get("agent", {}).get("agentResourceRoleArn")
except Exception as error:
logger.error(
f"{agent.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_prompts(self, regional_client):
"""List all prompts in a region."""
logger.info("Bedrock Agent - Listing Prompts...")
@@ -236,6 +253,7 @@ class Agent(BaseModel):
name: str
arn: str
guardrail_id: Optional[str] = None
role_arn: Optional[str] = None
region: str
tags: Optional[list] = []
@@ -0,0 +1,40 @@
{
"Provider": "aws",
"CheckID": "elbv2_alb_drop_invalid_header_fields_enabled",
"CheckTitle": "Application Load Balancer should be configured to drop invalid HTTP header fields",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices/Network Reachability",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
"TTPs/Initial Access",
"Effects/Data Exposure"
],
"ServiceName": "elbv2",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "AwsElbv2LoadBalancer",
"ResourceGroup": "network",
"Description": "Ensure that Application Load Balancers (ALB) are configured to drop invalid HTTP header fields. The check fails when `routing.http.drop_invalid_header_fields.enabled` is not set to `true`. By default, ALBs do not remove HTTP headers that do not conform to RFC 7230.",
"Risk": "Forwarding non-RFC-compliant HTTP headers to backend targets enables HTTP desync (request smuggling):\n- **Confidentiality**: session/token theft, data exfiltration\n- **Integrity**: cache poisoning, request routing bypass, unauthorized actions\n- **Availability**: backend exhaustion.\nDropping invalid header fields removes a primary smuggling vector.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/elasticloadbalancing/latest/application/application-load-balancers.html#drop-invalid-header-fields",
"https://docs.aws.amazon.com/securityhub/latest/userguide/elb-controls.html#elb-4"
],
"Remediation": {
"Code": {
"CLI": "aws elbv2 modify-load-balancer-attributes --load-balancer-arn <ALB_ARN> --attributes Key=routing.http.drop_invalid_header_fields.enabled,Value=true",
"NativeIaC": "```yaml\n# CloudFormation: enable drop invalid header fields on an ALB\nResources:\n <example_resource_name>:\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n Properties:\n Type: application\n Subnets:\n - <example_subnet_id1>\n - <example_subnet_id2>\n LoadBalancerAttributes:\n - Key: routing.http.drop_invalid_header_fields.enabled # Critical: drop non-RFC-compliant headers\n Value: true\n```",
"Other": "1. Open the Amazon EC2 console and choose Load Balancers.\n2. Select the Application Load Balancer.\n3. On the Attributes tab, choose Edit.\n4. Set 'Drop invalid header fields' to Enabled.\n5. Save changes.",
"Terraform": "```hcl\n# Terraform: enable drop invalid header fields on an ALB\nresource \"aws_lb\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n load_balancer_type = \"application\"\n subnets = [\"<example_subnet_id1>\", \"<example_subnet_id2>\"]\n drop_invalid_header_fields = true # Critical: drop non-RFC-compliant headers\n}\n```"
},
"Recommendation": {
"Text": "Enable 'drop invalid header fields' on Application Load Balancers so non-RFC-compliant HTTP headers are removed before requests reach backend targets, reducing exposure to HTTP desync and request smuggling. Apply defense in depth and validate requests at the application layer as well.",
"Url": "https://hub.prowler.com/check/elbv2_alb_drop_invalid_header_fields_enabled"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,27 @@
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.elbv2.elbv2_client import elbv2_client
class elbv2_alb_drop_invalid_header_fields_enabled(Check):
def execute(self):
findings = []
for lb in elbv2_client.loadbalancersv2.values():
if lb.type == "application":
report = Check_Report_AWS(
metadata=self.metadata(),
resource=lb,
)
report.status = "PASS"
report.status_extended = (
f"ELBv2 ALB {lb.name} is configured to drop invalid "
"header fields."
)
if lb.drop_invalid_header_fields != "true":
report.status = "FAIL"
report.status_extended = (
f"ELBv2 ALB {lb.name} is not configured to drop "
"invalid header fields."
)
findings.append(report)
return findings
@@ -103,6 +103,9 @@ class IAM(AWSService):
self._get_user_temporary_credentials_usage()
self.organization_features = []
self._list_organizations_features()
# ListRoles does not echo PermissionsBoundary; backfill via GetRole.
if self.roles:
self.__threading_call__(self._get_role_permissions_boundary, self.roles)
# List missing tags
self.__threading_call__(self._list_tags, self.users)
self.__threading_call__(self._list_tags, self.roles)
@@ -133,6 +136,7 @@ class IAM(AWSService):
arn=role["Arn"],
assume_role_policy=role["AssumeRolePolicyDocument"],
is_service_role=is_service_role(role),
permissions_boundary=role.get("PermissionsBoundary"),
)
)
except ClientError as error:
@@ -460,6 +464,34 @@ class IAM(AWSService):
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_role_permissions_boundary(self, role):
"""Backfill ``role.permissions_boundary`` via ``GetRole``.
``ListRoles`` does not return ``PermissionsBoundary`` in practice, so
the value is fetched per role and stored on the ``Role`` model.
Args:
role: The ``Role`` instance to enrich.
"""
try:
response = self.client.get_role(RoleName=role.name)
role.permissions_boundary = response.get("Role", {}).get(
"PermissionsBoundary"
)
except ClientError as error:
if error.response["Error"]["Code"] == "NoSuchEntity":
logger.warning(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
else:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{self.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _list_attached_role_policies(self):
logger.info("IAM - List Attached Role Policies...")
try:
@@ -1139,6 +1171,7 @@ class Role(BaseModel):
is_service_role: bool
attached_policies: list[dict] = []
inline_policies: list[str] = []
permissions_boundary: Optional[dict] = None
tags: Optional[list]
+35 -12
View File
@@ -16,18 +16,41 @@ def init_providers_parser(self):
# We need to call the arguments parser for each provider
providers = Provider.get_available_providers()
for provider in providers:
try:
getattr(
import_module(
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
),
init_provider_arguments_function,
)(self)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
# Discriminate built-in vs external upfront via find_spec, so an
# ImportError from a transitive dependency missing inside a built-in
# arguments module surfaces clearly instead of being silently
# re-routed to the entry-point path (which only has external providers).
if Provider.is_builtin(provider):
try:
getattr(
import_module(
f"{providers_path}.{provider}.{provider_arguments_lib_path}"
),
init_provider_arguments_function,
)(self)
except ImportError as e:
logger.critical(
f"Failed to load arguments for built-in provider '{provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
sys.exit(1)
else:
# External provider — init_parser classmethod via entry point
cls = Provider._load_ep_provider(provider)
if cls and hasattr(cls, "init_parser"):
try:
cls.init_parser(self)
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def validate_provider_arguments(arguments: Namespace) -> tuple[bool, str]:
+29
View File
@@ -0,0 +1,29 @@
"""Leaf helper for built-in provider detection.
Lives in its own module with no imports back into `prowler.lib.check` so
that callers in `prowler.lib.check.*` can ask "is this provider built-in?"
without creating an import cycle through `prowler.providers.common.provider`
(which transitively imports `prowler.config.config` and from there
`prowler.lib.check.compliance_models` / `prowler.lib.check.external_tool_providers`).
Same rationale as `prowler.lib.check.tool_wrapper`: extracting the predicate
to a leaf module is the canonical way to break the cycle in this codebase.
"""
import importlib.util
def is_builtin_provider(provider: str) -> bool:
"""Return True if the provider's own package ships with the SDK.
Wraps `importlib.util.find_spec` in `try/except (ImportError, ValueError)`
because `find_spec` propagates `ModuleNotFoundError` when a parent package
in the dotted path does not exist (instead of returning `None`). The
try/except is what makes the call safe for external providers, whose
package does not live under `prowler.providers.{provider}`.
"""
try:
spec = importlib.util.find_spec(f"prowler.providers.{provider}")
return spec is not None
except (ImportError, ValueError):
return False
+13
View File
@@ -4,6 +4,7 @@ from os.path import isdir
from pydantic.v1 import BaseModel
from prowler.config.config import output_file_timestamp
from prowler.providers.common.provider import Provider
@@ -69,3 +70,15 @@ class Connection:
is_connected: bool = False
error: Exception = None
def default_output_options(provider, arguments, bulk_checks_metadata):
"""Generic OutputOptions fallback for external providers that do not
implement get_output_options, so the run still produces output instead of
aborting. Honors arguments.output_filename and otherwise derives a name
from the provider type."""
output_options = ProviderOutputOptions(arguments, bulk_checks_metadata)
output_options.output_filename = getattr(arguments, "output_filename", None) or (
f"prowler-output-{provider.type}-{output_file_timestamp}"
)
return output_options
+284 -31
View File
@@ -1,4 +1,6 @@
import importlib
import importlib.metadata
import importlib.util
import os
import pkgutil
import sys
@@ -136,6 +138,106 @@ class Provider(ABC):
"""
return set()
# --- Dynamic provider contract methods (not @abstractmethod for incremental migration) ---
_cli_help_text: str = ""
@classmethod
def from_cli_args(cls, arguments: Namespace, fixer_config: dict) -> "Provider":
"""Instantiate the provider from CLI arguments and return the instance.
The caller wires the returned instance into the global provider slot
via Provider.set_global_provider(). Implementations that already call
set_global_provider(self) from __init__ are also supported the call
site tolerates a None return in that case.
"""
raise NotImplementedError(f"{cls.__name__} has not implemented from_cli_args()")
def get_output_options(self, arguments, _bulk_checks_metadata):
"""Create the provider-specific OutputOptions."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_output_options()"
)
def get_stdout_detail(self, _finding) -> str:
"""Return the detail string for stdout reporting (region, location, etc.)."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_stdout_detail()"
)
def get_finding_sort_key(self) -> Optional[str]:
"""Return the attribute name to sort findings by, or None for no sorting."""
return None
def get_summary_entity(self) -> tuple:
"""Return (entity_type, audited_entities) for the summary table."""
return (self.type, getattr(self.identity, "account_id", ""))
def get_finding_output_data(self, _check_output) -> dict:
"""Return provider-specific fields for Finding.generate_output()."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_finding_output_data()"
)
def get_html_assessment_summary(self) -> str:
"""Return the HTML assessment summary card for this provider."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_html_assessment_summary()"
)
def generate_compliance_output(
self,
_findings,
_bulk_compliance_frameworks,
_input_compliance_frameworks,
_output_options,
_generated_outputs,
) -> None:
"""Generate compliance CSV output for this provider's frameworks."""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented generate_compliance_output()"
)
def get_mutelist_finding_args(self) -> dict:
"""Return extra kwargs for mutelist.is_finding_muted() besides 'finding'.
External providers must return a dict with the identity key their
Mutelist subclass expects, e.g. ``{"account_id": self.identity.account_id}``.
The ``finding`` kwarg is added automatically by the caller.
"""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented get_mutelist_finding_args()"
)
def display_compliance_table(
self,
_findings: list,
_bulk_checks_metadata: dict,
_compliance_framework: str,
_output_filename: str,
_output_directory: str,
_compliance_overview: bool,
) -> bool:
"""Render a custom compliance table in the terminal.
External providers can override this to display a detailed
compliance table (e.g., per-section breakdown). Return True
if the table was rendered, False to fall back to the generic table.
"""
raise NotImplementedError(
f"{self.__class__.__name__} has not implemented display_compliance_table()"
)
# Class-level flag: True for providers that delegate scanning to an external
# tool (e.g. Trivy, promptfoo) and bypass standard check/service loading and
# metadata validation. Subclasses override as `is_external_tool_provider = True`.
# Kept as a class attribute (not a property) so it can be read from the class
# without instantiation — the metadata validators in lib.check.models need to
# decide whether to relax validation before any provider instance exists.
is_external_tool_provider: bool = False
# --- End dynamic provider contract methods ---
@staticmethod
def get_excluded_regions_from_env() -> set:
"""Parse the PROWLER_AWS_DISALLOWED_REGIONS environment variable.
@@ -159,20 +261,74 @@ class Provider(ABC):
@staticmethod
def init_global_provider(arguments: Namespace) -> None:
try:
provider_class_path = (
f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
)
provider_class_name = f"{arguments.provider.capitalize()}Provider"
provider_class = getattr(
import_module(provider_class_path), provider_class_name
# Discriminate built-in vs external upfront via find_spec, so an
# ImportError from a transitive dependency missing inside a
# built-in's own import chain surfaces clearly instead of being
# silently re-routed to the entry-point path.
provider_class = None
if Provider.is_builtin(arguments.provider):
# Built-in wins on provider-name collision. Plug-ins are
# first-class extenders (they can register new provider
# names) but cannot override existing built-ins — a security
# tool prefers fail-loud predictability over silent
# overrides. Surface the override so the user knows their
# plug-in is being ignored and can rename it.
# Match by name only — never ep.load() a shadowing plug-in.
if any(
ep.name == arguments.provider
for ep in importlib.metadata.entry_points(group="prowler.providers")
):
logger.warning(
f"Plug-in provider '{arguments.provider}' registered "
f"via entry points is being IGNORED — a built-in with "
f"the same name exists. To use your plug-in, register "
f"it under a different name."
)
provider_class_path = f"{providers_path}.{arguments.provider}.{arguments.provider}_provider"
provider_class_name = f"{arguments.provider.capitalize()}Provider"
try:
provider_class = getattr(
import_module(provider_class_path), provider_class_name
)
except ImportError as e:
logger.critical(
f"Failed to load built-in provider '{arguments.provider}'. "
f"Missing dependency: {e}. "
f"Ensure all required dependencies are installed."
)
logger.debug("Full traceback:", exc_info=True)
sys.exit(1)
except AttributeError:
# Module exists but doesn't define the expected class —
# treat as external and try entry points.
provider_class = Provider._load_ep_provider(arguments.provider)
else:
provider_class = Provider._load_ep_provider(arguments.provider)
if provider_class is None:
raise ImportError(
f"Provider '{arguments.provider}' not found as built-in or entry point"
)
# Kept for downstream forks that may extend the dispatch below
# with their own custom built-in branches and reference this name.
# The upstream chain dispatches by `arguments.provider` directly.
provider_class_name = (
f"{arguments.provider.capitalize()}Provider" # noqa: F841
)
fixer_config = load_and_validate_config_file(
arguments.provider, arguments.fixer_config
)
# Dispatch by exact provider name (equality, not substring) so
# external plug-ins whose names contain a built-in substring
# (e.g. `awsx`, `azure_gov`, `iac_v2`) cannot be silently routed
# to the wrong built-in branch. Anything that doesn't match a
# built-in falls through to the dynamic else and uses the
# contract's `from_cli_args`.
if not isinstance(Provider._global, provider_class):
if "aws" in provider_class_name.lower():
if arguments.provider == "aws":
excluded_regions = (
set(arguments.excluded_region)
if getattr(arguments, "excluded_region", None)
@@ -196,7 +352,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "azure" in provider_class_name.lower():
elif arguments.provider == "azure":
provider_class(
az_cli_auth=arguments.az_cli_auth,
sp_env_auth=arguments.sp_env_auth,
@@ -209,7 +365,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "gcp" in provider_class_name.lower():
elif arguments.provider == "gcp":
provider_class(
retries_max_attempts=arguments.gcp_retries_max_attempts,
organization_id=arguments.organization_id,
@@ -223,7 +379,7 @@ class Provider(ABC):
fixer_config=fixer_config,
skip_api_check=arguments.skip_api_check,
)
elif "kubernetes" in provider_class_name.lower():
elif arguments.provider == "kubernetes":
provider_class(
kubeconfig_file=arguments.kubeconfig_file,
context=arguments.context,
@@ -233,7 +389,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "m365" in provider_class_name.lower():
elif arguments.provider == "m365":
provider_class(
region=arguments.region,
config_path=arguments.config_file,
@@ -247,7 +403,7 @@ class Provider(ABC):
init_modules=arguments.init_modules,
fixer_config=fixer_config,
)
elif "nhn" in provider_class_name.lower():
elif arguments.provider == "nhn":
provider_class(
username=arguments.nhn_username,
password=arguments.nhn_password,
@@ -256,7 +412,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "stackit" in provider_class_name.lower():
elif arguments.provider == "stackit":
provider_class(
project_id=arguments.stackit_project_id,
service_account_key_path=getattr(
@@ -275,7 +431,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "github" in provider_class_name.lower():
elif arguments.provider == "github":
orgs = []
repos = []
@@ -307,13 +463,13 @@ class Provider(ABC):
exclude_workflows=getattr(arguments, "exclude_workflows", []),
fixer_config=fixer_config,
)
elif "googleworkspace" in provider_class_name.lower():
elif arguments.provider == "googleworkspace":
provider_class(
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "cloudflare" in provider_class_name.lower():
elif arguments.provider == "cloudflare":
provider_class(
filter_zones=arguments.region,
filter_accounts=arguments.account_id,
@@ -321,7 +477,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "iac" in provider_class_name.lower():
elif arguments.provider == "iac":
provider_class(
scan_path=arguments.scan_path,
scan_repository_url=arguments.scan_repository_url,
@@ -334,13 +490,13 @@ class Provider(ABC):
oauth_app_token=arguments.oauth_app_token,
provider_uid=arguments.provider_uid,
)
elif "llm" in provider_class_name.lower():
elif arguments.provider == "llm":
provider_class(
max_concurrency=arguments.max_concurrency,
config_path=arguments.config_file,
fixer_config=fixer_config,
)
elif "image" in provider_class_name.lower():
elif arguments.provider == "image":
provider_class(
images=arguments.images,
image_list_file=arguments.image_list_file,
@@ -358,7 +514,7 @@ class Provider(ABC):
registry_insecure=arguments.registry_insecure,
registry_list_images=arguments.registry_list_images,
)
elif "mongodbatlas" in provider_class_name.lower():
elif arguments.provider == "mongodbatlas":
provider_class(
atlas_public_key=arguments.atlas_public_key,
atlas_private_key=arguments.atlas_private_key,
@@ -367,7 +523,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "oraclecloud" in provider_class_name.lower():
elif arguments.provider == "oraclecloud":
provider_class(
oci_config_file=arguments.oci_config_file,
profile=arguments.profile,
@@ -378,7 +534,7 @@ class Provider(ABC):
fixer_config=fixer_config,
use_instance_principal=arguments.use_instance_principal,
)
elif "openstack" in provider_class_name.lower():
elif arguments.provider == "openstack":
provider_class(
clouds_yaml_file=getattr(arguments, "clouds_yaml_file", None),
clouds_yaml_content=getattr(
@@ -403,7 +559,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "alibabacloud" in provider_class_name.lower():
elif arguments.provider == "alibabacloud":
provider_class(
role_arn=arguments.role_arn,
role_session_name=arguments.role_session_name,
@@ -415,14 +571,14 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "vercel" in provider_class_name.lower():
elif arguments.provider == "vercel":
provider_class(
projects=getattr(arguments, "project", None),
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "okta" in provider_class_name.lower():
elif arguments.provider == "okta":
provider_class(
okta_org_domain=getattr(arguments, "okta_org_domain", ""),
okta_client_id=getattr(arguments, "okta_client_id", ""),
@@ -435,7 +591,7 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "scaleway" in provider_class_name.lower():
elif arguments.provider == "scaleway":
# Credentials are read from the SCW_ACCESS_KEY /
# SCW_SECRET_KEY env vars by the provider itself; there
# are no credential CLI flags to avoid leaking secrets.
@@ -447,6 +603,18 @@ class Provider(ABC):
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
else:
# Dynamic fallback: any external/custom provider.
# Honor the from_cli_args type hint (-> Provider): if the
# implementation returns an instance, wire it as the global
# provider here. Implementations that call
# set_global_provider(self) from __init__ return None and
# remain supported (the condition below is a no-op for them).
provider_instance = provider_class.from_cli_args(
arguments, fixer_config
)
if provider_instance is not None:
Provider.set_global_provider(provider_instance)
except TypeError as error:
logger.critical(
@@ -459,17 +627,102 @@ class Provider(ABC):
)
sys.exit(1)
# Cache for entry-point provider classes {name: class}
_ep_providers: dict = {}
@staticmethod
def get_available_providers() -> list[str]:
"""get_available_providers returns a list of the available providers"""
providers = []
# Dynamically import the package based on its string path
providers = set()
# Built-in providers from local package
prowler_providers = importlib.import_module(providers_path)
# Iterate over all modules found in the prowler_providers package
for _, provider, ispkg in pkgutil.iter_modules(prowler_providers.__path__):
if provider != "common" and ispkg:
providers.append(provider)
return providers
providers.add(provider)
# External providers registered via entry points
for ep in importlib.metadata.entry_points(group="prowler.providers"):
providers.add(ep.name)
return sorted(providers)
@staticmethod
def is_tool_wrapper_provider(provider: str) -> bool:
"""Return True if the provider delegates scanning to an external tool.
Delegates to `prowler.lib.check.tool_wrapper.is_tool_wrapper_provider`,
the leaf module that holds the actual logic. Kept on `Provider` as a
convenience entry point for callers that already import `Provider`.
"""
from prowler.lib.check.tool_wrapper import is_tool_wrapper_provider as _impl
return _impl(provider)
@staticmethod
def is_builtin(provider: str) -> bool:
"""Return True if the provider's own package is importable as a built-in.
Delegates to `prowler.providers.common.builtin.is_builtin_provider`,
the leaf module that holds the actual check. Kept on `Provider` as a
convenience entry point for callers that already import `Provider`.
Call sites in `prowler.lib.check.*` should import from the leaf
directly to avoid the import cycle through this module.
"""
from prowler.providers.common.builtin import is_builtin_provider as _impl
return _impl(provider)
@staticmethod
def _load_ep_provider(name: str):
"""Load an external provider class from entry points, with cache.
Caches both hits and misses so repeated lookups for unknown names do
not re-iterate entry_points(). Symmetric with
tool_wrapper._ep_class_cache.
"""
if name in Provider._ep_providers:
return Provider._ep_providers[name]
for ep in importlib.metadata.entry_points(group="prowler.providers"):
if ep.name == name:
try:
cls = ep.load()
Provider._ep_providers[name] = cls
return cls
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
Provider._ep_providers[name] = None
return None
@staticmethod
def get_providers_help_text() -> dict:
"""Returns a dict of {provider_name: cli_help_text} for all available providers."""
help_text = {}
for name in Provider.get_available_providers():
try:
# Try built-in first
module_path = f"{providers_path}.{name}.{name}_provider"
module = import_module(module_path)
cls = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, Provider)
and attr is not Provider
):
cls = attr
break
help_text[name] = getattr(cls, "_cli_help_text", "") if cls else ""
except ImportError:
# External provider — load via entry point
cls = Provider._load_ep_provider(name)
help_text[name] = getattr(cls, "_cli_help_text", "") if cls else ""
except Exception as error:
logger.warning(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
help_text[name] = ""
return help_text
@staticmethod
def update_provider_config(audit_config: dict, variable: str, value: str):
@@ -37,6 +37,7 @@ class IAM(GCPService):
display_name=account.get("displayName", ""),
project_id=project_id,
uniqueId=account.get("uniqueId", ""),
disabled=account.get("disabled", False),
)
)
@@ -102,6 +103,7 @@ class ServiceAccount(BaseModel):
keys: list[Key] = []
project_id: str
uniqueId: str
disabled: bool = False
class AccessApproval(GCPService):
@@ -19,7 +19,12 @@ class iam_service_account_unused(Check):
resource_id=account.email,
location=iam_client.region,
)
if account.uniqueId in sa_ids_used:
if account.disabled:
report.status = "PASS"
report.status_extended = (
f"Service Account {account.email} is disabled and cannot be used."
)
elif account.uniqueId in sa_ids_used:
report.status = "PASS"
report.status_extended = f"Service Account {account.email} was used over the last {max_unused_days} days."
else:
@@ -1,14 +1,14 @@
{
"Provider": "gcp",
"CheckID": "kms_key_rotation_enabled",
"CheckTitle": "KMS key is rotated at least annually",
"CheckTitle": "KMS key has automatic rotation enabled",
"CheckType": [],
"ServiceName": "kms",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "low",
"ResourceType": "cloudkms.googleapis.com/CryptoKey",
"Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled or a rotation interval `365` days.\n\nThe evaluation reviews each key's rotation settings to confirm periodic creation of new key versions.",
"Description": "Google Cloud KMS customer-managed keys have **automatic rotation** enabled, regardless of the rotation interval.\n\nThe evaluation reviews each key's rotation settings to confirm that a rotation period is configured so new key versions are created periodically.",
"Risk": "Without timely rotation, a stolen key can decrypt an expanding volume of data, eroding **confidentiality**. Prolonged key lifetimes widen windows for misuse, impact **integrity** of protected workloads, and make emergency rollover harder, risking **availability** disruptions.",
"RelatedUrl": "",
"AdditionalURLs": [
@@ -17,13 +17,13 @@
],
"Remediation": {
"Code": {
"CLI": "gcloud kms keys update <KEY_NAME> --keyring=<KEY_RING> --location=<LOCATION> --rotation-period=365d --next-rotation-time=<RFC3339_TIME>",
"CLI": "gcloud kms keys update <KEY_NAME> --keyring=<KEY_RING> --location=<LOCATION> --rotation-period=<PERIOD> --next-rotation-time=<RFC3339_TIME>",
"NativeIaC": "",
"Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set Rotation period to 365 days or less\n5. Set Next rotation date/time\n6. Click Save",
"Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n key_ring = \"<example_resource_id>\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"31536000s\" # Critical: sets automatic rotation to 365 days (<= 365 ensures PASS)\n}\n```"
"Other": "1. In Google Cloud Console, go to Security > Key Management > Key rings\n2. Open the key ring and select the key\n3. Click Edit rotation schedule (or Set rotation schedule)\n4. Set a Rotation period\n5. Set Next rotation date/time\n6. Click Save",
"Terraform": "```hcl\nresource \"google_kms_crypto_key\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n key_ring = \"<example_resource_id>\"\n purpose = \"ENCRYPT_DECRYPT\"\n\n rotation_period = \"7776000s\" # Critical: enables automatic rotation (any period ensures PASS)\n}\n```"
},
"Recommendation": {
"Text": "Enable **auto-rotation** for customer-managed keys with an interval `365` days.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
"Text": "Enable **auto-rotation** for customer-managed keys by configuring a rotation period.\n\nAdopt a **key lifecycle** policy: enforce **least privilege** on key usage, apply **separation of duties** between key admins and users, monitor key access, and rehearse emergency rotation to minimize blast radius.",
"Url": "https://hub.prowler.com/check/kms_key_rotation_enabled"
}
},
@@ -31,6 +31,8 @@
"encryption"
],
"DependsOn": [],
"RelatedTo": [],
"RelatedTo": [
"kms_key_rotation_max_90_days"
],
"Notes": ""
}
@@ -1,5 +1,3 @@
import datetime
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.kms.kms_client import kms_client
@@ -9,36 +7,16 @@ class kms_key_rotation_enabled(Check):
findings = []
for key in kms_client.crypto_keys:
report = Check_Report_GCP(metadata=self.metadata(), resource=key)
now = datetime.datetime.now()
condition_next_rotation_time = False
if key.next_rotation_time:
try:
next_rotation_time = datetime.datetime.strptime(
key.next_rotation_time, "%Y-%m-%dT%H:%M:%S.%fZ"
)
except ValueError:
next_rotation_time = datetime.datetime.strptime(
key.next_rotation_time, "%Y-%m-%dT%H:%M:%SZ"
)
condition_next_rotation_time = (
abs((next_rotation_time - now).days) <= 90
)
condition_rotation_period = False
if key.rotation_period:
condition_rotation_period = (
int(key.rotation_period[:-1]) // (24 * 3600) <= 90
)
if condition_rotation_period and condition_next_rotation_time:
report.status = "PASS"
report.status_extended = f"Key {key.name} is rotated every 90 days or less and the next rotation time is in less than 90 days."
report.status_extended = (
f"Key {key.name} has automatic rotation enabled."
)
else:
report.status = "FAIL"
if condition_rotation_period:
report.status_extended = f"Key {key.name} is rotated every 90 days or less but the next rotation time is in more than 90 days."
elif condition_next_rotation_time:
report.status_extended = f"Key {key.name} is not rotated every 90 days or less but the next rotation time is in less than 90 days."
else:
report.status_extended = f"Key {key.name} is not rotated every 90 days or less and the next rotation time is in more than 90 days."
report.status_extended = (
f"Key {key.name} does not have automatic rotation enabled."
)
findings.append(report)
return findings

Some files were not shown because too many files have changed in this diff Show More