Compare commits

..

35 Commits

Author SHA1 Message Date
Adrián Jesús Peña Rodríguez 45cef5e67b fix(ci): harden changelog compilation
- Validate changelog compile target branches

- Use an explicit master checkout for forward sync

- Cover changelog attribution edge cases
2026-06-12 15:00:10 +02:00
Adrián Jesús Peña Rodríguez 281d35dce8 chore: merge master into changelog fragments 2026-06-12 14:39:24 +02:00
Prowler Bot dc3433aaf0 feat(aws): Update regions for AWS services (#11570)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-12 14:15:02 +02:00
Pedro Martín 25fc285966 chore(banner): update info (#11568) 2026-06-12 13:45:34 +02:00
s1ns3nz0 9022a3a138 feat(azure): add cosmosdb_account_automatic_failover_enabled check (#11031)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-12 13:18:08 +02:00
Josema Camacho ca443b8ff1 chore: prepare API and UI changelogs for 5.30.1 release (#11562) 2026-06-12 12:07:31 +02:00
s1ns3nz0 79e066d3f5 feat(gcp): add cloudsql_instance_high_availability_enabled check (#11024)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
Co-authored-by: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com>
2026-06-12 11:51:13 +02:00
Hugo Pereira Brito 56831a7392 feat(oci): add storage admin delete exclusion check (#11523) 2026-06-12 11:10:46 +02:00
Adrián Jesús Peña Rodríguez 08df99c1d4 docs: update changelog guidance to the fragment workflow 2026-06-12 10:55:19 +02:00
Adrián Jesús Peña Rodríguez 8cd1cc0471 chore(ci): ignore changelog fragments in CI change detection 2026-06-12 10:52:58 +02:00
Adrián Jesús Peña Rodríguez c6e7500753 feat: migrate unreleased changelog entries to fragments 2026-06-12 10:52:17 +02:00
Adrián Jesús Peña Rodríguez 8e8770edcb feat(ci): require fragment files in the PR changelog gate 2026-06-12 10:52:14 +02:00
Adrián Jesús Peña Rodríguez c94a999ef4 feat(ci): add compile-changelogs workflow with PR attribution 2026-06-12 10:52:11 +02:00
Adrián Jesús Peña Rodríguez 55269e81be feat: add changelog fragments tooling 2026-06-12 10:51:38 +02:00
Alejandro Bailo 2e82f1564f fix(ui): show threat map data for okta and google workspace accounts (#11542) 2026-06-12 10:07:56 +02:00
Josema Camacho a394c0fdf6 fix(api): drop_subgraph deletes relationships then nodes to cut Neo4j memory (#11557) 2026-06-11 18:32:35 +02:00
Pedro Martín 20eca78767 fix(compliance): resolve provider from scan in attributes endp (#11546) 2026-06-11 18:00:36 +02:00
Oleksandr_Sanin bba594a1db feat(aws/sagemaker): add sagemaker_clarify_exists check (#11211)
Signed-off-by: Oleksandr Sanin <alexaaander.sanin@gmail.com>
Signed-off-by: Oleksandr Yizchak Sanin <alexaaander.sanin@gmail.com>
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-06-11 17:40:41 +02:00
Hugo Pereira Brito 65f00a197b fix(api): normalize OCI scan region credentials (#11558) 2026-06-11 17:32:28 +02:00
Zeus Almightee ce27053c2d feat(aws): add securityhub + config org-wide delegated admin checks (#11259)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-06-11 16:53:28 +02:00
Pedro Martín 610febb5d5 fix(api): bump prowler SDK lock to v5.30.0 for okta_idaas_stig (#11553) 2026-06-11 15:53:44 +02:00
Prowler Bot c4378d5992 chore(release): Bump versions to v5.31.0 (#11548)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-06-11 15:28:25 +02:00
Hugo Pereira Brito f1d741214a fix(ui): adapt risk pipeline sankey layout (#11527) 2026-06-11 09:44:17 +02:00
Pepe Fagoaga 285974b7d4 chore(changelog): v5.30.0 (#11540)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Daniel Barranquero <74871504+danibarranqueroo@users.noreply.github.com>
2026-06-11 09:08:25 +02:00
Daniel Barranquero 989c3b174e fix(bedrock): per-finding severity for long-term API key check (#11526) 2026-06-11 08:31:08 +02:00
Pedro Martín 75f95559d6 fix(api): warm compliance caches when starting the worker (#11530) 2026-06-10 19:04:40 +02:00
sahil-sols e085e14247 fix(aws): order-independent CloudWatch metric filter pattern checks (#11345)
Co-authored-by: Sahil Pugalia <sahil-sols@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
2026-06-10 18:49:06 +02:00
Johannes Engler 368d3a2661 feat(stackit): add objectstorage checks (#11397)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-06-10 18:43:24 +02: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
235 changed files with 12061 additions and 1231 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.30.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.31.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+5 -5
View File
@@ -18,8 +18,8 @@ Please add a detailed description of how to review this PR.
<summary><b>Community Checklist</b></summary>
- [ ] This feature/issue is listed in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com
- [ ] Is it assigned to me, if not, request it via the issue/feature in [here](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](goto.prowler.com/slack)
- [ ] This feature/issue is listed in the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or roadmap.prowler.com
- [ ] Is it assigned to me, if not, request it via the [open issues](https://github.com/prowler-cloud/prowler/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) or [Prowler Community Slack](goto.prowler.com/slack)
</details>
@@ -28,7 +28,7 @@ Please add a detailed description of how to review this PR.
- [ ] Review if code is being documented following this specification https://github.com/google/styleguide/blob/gh-pages/pyguide.md#38-comments-and-docstrings
- [ ] Review if backport is needed.
- [ ] Review if is needed to change the [Readme.md](https://github.com/prowler-cloud/prowler/blob/master/README.md)
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/prowler/CHANGELOG.md), if applicable.
- [ ] Ensure a changelog fragment is added under [prowler/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/prowler/changelog.d), if applicable.
#### SDK/CLI
- Are there new checks included in this PR? Yes / No
@@ -40,7 +40,7 @@ Please add a detailed description of how to review this PR.
- [ ] Screenshots/Video of the functionality flow (if applicable) - Mobile (X < 640px)
- [ ] Screenshots/Video of the functionality flow (if applicable) - Table (640px > X < 1024px)
- [ ] Screenshots/Video of the functionality flow (if applicable) - Desktop (X > 1024px)
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/ui/CHANGELOG.md), if applicable.
- [ ] Ensure a changelog fragment is added under [ui/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/ui/changelog.d), if applicable.
#### API
- [ ] All issue/task requirements work as expected on the API
@@ -50,7 +50,7 @@ Please add a detailed description of how to review this PR.
- [ ] Any other relevant evidence of the implementation (if applicable)
- [ ] Verify if API specs need to be regenerated.
- [ ] Check if version updates are required (e.g., specs, uv, etc.).
- [ ] Ensure new entries are added to [CHANGELOG.md](https://github.com/prowler-cloud/prowler/blob/master/api/CHANGELOG.md), if applicable.
- [ ] Ensure a changelog fragment is added under [api/changelog.d/](https://github.com/prowler-cloud/prowler/tree/master/api/changelog.d), if applicable.
### License
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Rename changelog fragments to their PR number before running towncrier.
For every <slug>.<type>.md in <component_dir>/changelog.d/, find the commit that
added it, resolve its PR via the GitHub API (falling back to the squash-commit
subject), and `git mv` it to <PR>.<type>.md so towncrier renders the PR link.
Unresolvable fragments become +<slug>.<type>.md orphans (rendered without link).
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
FRAGMENT_RE = re.compile(
r"^(?P<slug>[A-Za-z0-9][A-Za-z0-9._-]*?)"
r"\.(?P<type>added|changed|deprecated|removed|fixed|security)"
r"(?:\.(?P<counter>[0-9]+))?\.md$"
)
SUBJECT_PR_RE = re.compile(r" \(#([0-9]+)\)$")
IGNORED_FILES = {".gitkeep", "README.md"}
API_TIMEOUT_SECONDS = 10
def git(*args: str) -> str:
result = subprocess.run(["git", *args], check=True, capture_output=True, text=True)
return result.stdout.strip()
def find_adding_commit(path: str) -> str | None:
"""Find the commit that added a file, following renames.
Falls back to a plain (no --follow) lookup: rename detection can lose the
add event for degenerate content (e.g. files identical to many others).
"""
sha = git("log", "--follow", "--diff-filter=A", "--format=%H", "-1", "--", path)
if not sha:
sha = git("log", "--diff-filter=A", "--format=%H", "-1", "--", path)
return sha or None
def pr_from_api(repo: str, sha: str) -> int | None:
"""Resolve the PR associated with a commit via the GitHub API.
Returns None on any network/API failure so the caller can fall back to
parsing the squash-commit subject.
"""
url = f"https://api.github.com/repos/{repo}/commits/{sha}/pulls"
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "prowler-changelog-attribution",
}
token = os.environ.get("GITHUB_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(request, timeout=API_TIMEOUT_SECONDS) as response:
pulls = json.load(response)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return None
if isinstance(pulls, list) and pulls:
return pulls[0].get("number")
return None
def pr_from_subject(sha: str) -> int | None:
subject = git("log", "-1", "--format=%s", sha)
match = SUBJECT_PR_RE.search(subject)
return int(match.group(1)) if match else None
def unique_destination(directory: str, base_name: str, fragment_type: str) -> str:
"""Return a non-colliding fragment path, appending a numeric counter if needed."""
candidate = os.path.join(directory, f"{base_name}.{fragment_type}.md")
counter = 0
while os.path.exists(candidate):
counter += 1
candidate = os.path.join(directory, f"{base_name}.{fragment_type}.{counter}.md")
return candidate
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("component_dir", help="Component directory, e.g. prowler")
parser.add_argument("--repo", default="prowler-cloud/prowler")
parser.add_argument(
"--no-api",
action="store_true",
help="Skip the GitHub API and resolve PRs from commit subjects only",
)
args = parser.parse_args()
fragments_dir = os.path.join(args.component_dir, "changelog.d")
if not os.path.isdir(fragments_dir):
print(f"::error::Fragments directory not found: {fragments_dir}")
return 1
malformed = []
for name in sorted(os.listdir(fragments_dir)):
if name in IGNORED_FILES or name.startswith("+"):
continue
match = FRAGMENT_RE.match(name)
if not match:
malformed.append(name)
continue
slug, fragment_type = match.group("slug"), match.group("type")
if slug.isdigit():
continue
path = os.path.join(fragments_dir, name)
sha = find_adding_commit(path)
pr_number = None
if sha:
if not args.no_api:
pr_number = pr_from_api(args.repo, sha)
if pr_number is None:
pr_number = pr_from_subject(sha)
if pr_number is not None:
destination = unique_destination(
fragments_dir, str(pr_number), fragment_type
)
else:
destination = unique_destination(fragments_dir, f"+{slug}", fragment_type)
print(
f"::warning::Could not resolve a PR for {path}; renamed to "
f"{os.path.basename(destination)} (entry will render without a PR link)"
)
git("mv", path, destination)
print(f"{path} -> {destination}")
if malformed:
for name in malformed:
print(
f"::error::Malformed fragment filename in {fragments_dir}: {name} "
"(expected <slug>.<type>.md with type one of added|changed|"
"deprecated|removed|fixed|security)"
)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+13
View File
@@ -0,0 +1,13 @@
{% for section, _ in sections.items() %}
{% for category, val in definitions.items() if category in sections[section] %}
### {{ definitions[category]['name'] }}
{% for text, values in sections[section][category].items() %}
- {{ text }}{% if values %} {{ values|join(', ') }}{% endif %}
{% endfor %}
{% endfor %}
{% endfor %}
---
{{ "\n" }}
+1
View File
@@ -62,6 +62,7 @@ jobs:
api/docs/**
api/README.md
api/CHANGELOG.md
api/changelog.d/**
api/AGENTS.md
- name: Setup Python with uv
@@ -111,6 +111,7 @@ jobs:
api/docs/**
api/README.md
api/CHANGELOG.md
api/changelog.d/**
api/AGENTS.md
- name: Set up Docker Buildx
+1
View File
@@ -84,6 +84,7 @@ jobs:
api/docs/**
api/README.md
api/CHANGELOG.md
api/changelog.d/**
api/AGENTS.md
- name: Setup Python with uv
+1
View File
@@ -111,6 +111,7 @@ jobs:
api/docs/**
api/README.md
api/CHANGELOG.md
api/changelog.d/**
api/AGENTS.md
- name: Setup Python with uv
+356
View File
@@ -0,0 +1,356 @@
name: 'Tools: Compile Changelogs'
run-name: 'Compile changelogs for Prowler ${{ inputs.prowler_version }}'
on:
workflow_dispatch:
inputs:
prowler_version:
description: 'Prowler version being released (e.g., 5.31.0)'
required: true
type: string
target_branch:
description: 'Branch to compile on (master for minor releases, v5.X for patches)'
required: true
type: string
sdk_version:
description: 'SDK version override (empty = auto-derive from prowler/CHANGELOG.md + pending fragment types; "skip" = hold this component back)'
required: false
type: string
api_version:
description: 'API version override (empty = auto-derive; "skip" = hold back)'
required: false
type: string
ui_version:
description: 'UI version override (empty = auto-derive; "skip" = hold back)'
required: false
type: string
mcp_version:
description: 'MCP Server version override (empty = auto-derive; "skip" = hold back)'
required: false
type: string
concurrency:
group: ${{ github.workflow }}-${{ inputs.prowler_version }}
cancel-in-progress: false
env:
PROWLER_VERSION: ${{ inputs.prowler_version }}
TARGET_BRANCH: ${{ inputs.target_branch }}
SDK_VERSION: ${{ inputs.sdk_version }}
API_VERSION: ${{ inputs.api_version }}
UI_VERSION: ${{ inputs.ui_version }}
MCP_VERSION: ${{ inputs.mcp_version }}
permissions: {}
jobs:
compile-changelogs:
if: github.event_name == 'workflow_dispatch' && github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.target_branch }}
fetch-depth: 0 # PR attribution resolves each fragment's adding commit from history
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.12'
- name: Install towncrier
run: pip install --no-cache-dir towncrier==25.8.0
- name: Configure Git
run: |
git config --global user.name 'prowler-bot'
git config --global user.email '179230569+prowler-bot@users.noreply.github.com'
- name: Validate version inputs
run: |
if [[ ! "$PROWLER_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid prowler_version syntax: '$PROWLER_VERSION' (must be N.N.N)"
exit 1
fi
if [ "$TARGET_BRANCH" != "master" ] && [[ ! "$TARGET_BRANCH" =~ ^v[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid target_branch syntax: '$TARGET_BRANCH' (must be 'master' or vN.N, e.g. v5.31)"
exit 1
fi
for pair in "sdk_version:$SDK_VERSION" "api_version:$API_VERSION" "ui_version:$UI_VERSION" "mcp_version:$MCP_VERSION"; do
input_name="${pair%%:*}"
input_value="${pair#*:}"
if [ -n "$input_value" ] && [ "$input_value" != "skip" ] && [[ ! "$input_value" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Invalid $input_name syntax: '$input_value' (must be N.N.N, empty for auto-derivation, or 'skip')"
exit 1
fi
done
- name: Compile changelogs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
component_input() {
case "$1" in
prowler) echo "$SDK_VERSION" ;;
api) echo "$API_VERSION" ;;
ui) echo "$UI_VERSION" ;;
mcp_server) echo "$MCP_VERSION" ;;
esac
}
pending_fragments() {
find "$1/changelog.d" -maxdepth 1 -type f ! -name '.gitkeep' ! -name 'README.md' | sort
}
# The component's last released version is the first stamped heading
# of its CHANGELOG.md, the same source prepare-release.yml greps.
latest_released_version() {
grep -m1 -E '^## \[v?[0-9]+\.[0-9]+\.[0-9]+\]' "$1/CHANGELOG.md" | sed -E 's/^## \[v?([0-9]+\.[0-9]+\.[0-9]+)\].*/\1/'
}
# Resolve every component's effective version before compiling
# anything, so a wrong input cannot leave the tree half-compiled.
# Empty input = auto-derive (latest released version + semver bump
# from the pending fragment types). 'skip' = hold the component back.
errors=0
compiling=""
for component in prowler api ui mcp_server; do
input=$(component_input "$component")
fragments=$(pending_fragments "$component")
if [ "$input" = "skip" ]; then
if [ -n "$fragments" ]; then
echo "::warning::${component}: held back by request; these pending fragments stay for a future release:"
echo "$fragments"
fi
continue
fi
if [ -n "$input" ] && [ -z "$fragments" ]; then
echo "::error::${component}: version input '$input' provided but ${component}/changelog.d/ has no pending fragments (wrong input?)"
errors=1
continue
fi
if [ -z "$fragments" ]; then
continue
fi
if [ -n "$input" ]; then
effective="$input"
mode="explicit"
else
if echo "$fragments" | grep -qE '\.removed(\.[0-9]+)?\.md$'; then
echo "::error::${component}: pending 'removed' fragments imply a major bump (breaking change); provide its version input explicitly"
errors=1
continue
fi
current=$(latest_released_version "$component")
if [ -z "$current" ]; then
echo "::error::${component}: could not read the latest released version from ${component}/CHANGELOG.md; provide its version input explicitly"
errors=1
continue
fi
IFS=. read -r major minor patch <<< "$current"
if echo "$fragments" | grep -qE '\.(added|changed|deprecated)(\.[0-9]+)?\.md$'; then
effective="${major}.$((minor + 1)).0"
else
effective="${major}.${minor}.$((patch + 1))"
fi
mode="auto"
echo "::notice::${component}: version auto-derived ${current} -> ${effective} from the pending fragment types"
fi
# Without the marker the build would insert the new block above the
# file header instead of below it.
if ! grep -q '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md"; then
echo "::error::${component}/CHANGELOG.md is missing the '<!-- changelog: release notes start -->' marker; restore it after the intro line before compiling"
errors=1
continue
fi
# A hand-written UNRELEASED block means someone followed the old
# convention; its entries would be left out of the compiled block
# and out of the release notes extraction.
if grep -q '(Prowler UNRELEASED)' "$component/CHANGELOG.md"; then
echo "::error::${component}/CHANGELOG.md contains a hand-written '(Prowler UNRELEASED)' block; convert its entries to fragments in ${component}/changelog.d/ and delete the block before compiling"
errors=1
continue
fi
echo "${effective} ${mode}" > "${RUNNER_TEMP}/version-${component}.txt"
compiling="${compiling}${component} "
done
if [ "$errors" -ne 0 ]; then
exit 1
fi
if [ -z "$compiling" ]; then
echo "::error::Nothing to compile: no component has pending fragments to release"
exit 1
fi
body_file="${RUNNER_TEMP}/compile-changelogs-pr-body.md"
{
echo "### Description"
echo ""
echo "Compiles the pending changelog fragments into the per-component \`CHANGELOG.md\` files for Prowler v${PROWLER_VERSION}, replacing the manual stamping PR."
echo ""
echo "| Component | Version | Fragments consumed |"
echo "|---|---|---|"
} > "$body_file"
compiled_components=""
for component in prowler api ui mcp_server; do
if [ ! -f "${RUNNER_TEMP}/version-${component}.txt" ]; then
echo "Skipping ${component} (no pending fragments or held back)"
echo "| \`${component}\` | - | 0 |" >> "$body_file"
continue
fi
read -r version mode < "${RUNNER_TEMP}/version-${component}.txt"
version_label="$version"
if [ "$mode" = "auto" ]; then
version_label="${version} (auto)"
fi
count=$(pending_fragments "$component" | wc -l | tr -d ' ')
echo "Compiling ${component} ${version} (${count} fragments, ${mode} version)..."
# Captured before attribution renames them: these original paths are
# what the forward-sync deletes on master (backports copy fragments
# verbatim, so filenames match across branches).
pending_fragments "$component" > "${RUNNER_TEMP}/consumed-${component}.txt"
pre_lines=$(wc -l < "$component/CHANGELOG.md")
# Attribution must run before the build: towncrier renders the
# first dotted segment of each filename as the PR number.
python .github/scripts/changelog_attribution.py "$component"
towncrier build --config "$component/towncrier.toml" --version "$version" --name "Prowler v${PROWLER_VERSION}" --yes
# The build only inserts lines right after the marker, so the new
# stamped block is exactly the added lines following it. Captured
# for the forward-sync to master.
post_lines=$(wc -l < "$component/CHANGELOG.md")
delta=$((post_lines - pre_lines))
marker_line=$(grep -n -m1 '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md" | cut -d: -f1)
sed -n "$((marker_line + 1)),$((marker_line + delta))p" "$component/CHANGELOG.md" > "${RUNNER_TEMP}/block-${component}.md"
compiled_components="${compiled_components}${component} "
echo "| \`${component}\` | ${version_label} | ${count} |" >> "$body_file"
done
echo "COMPILED_COMPONENTS=${compiled_components}" >> "$GITHUB_ENV"
{
echo ""
echo "Review that no pending fragment was dropped (the diff must delete every consumed fragment) and that each new version block is correct, then squash-merge."
echo ""
echo "### License"
echo ""
echo "By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license."
} >> "$body_file"
echo "PR_BODY_FILE=${body_file}" >> "$GITHUB_ENV"
- name: Create compile PR
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
commit-message: 'chore(changelog): v${{ env.PROWLER_VERSION }}'
branch: compile-changelogs-${{ env.PROWLER_VERSION }}
base: ${{ env.TARGET_BRANCH }}
title: 'chore(changelog): v${{ env.PROWLER_VERSION }}'
body-path: ${{ env.PR_BODY_FILE }}
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
labels: |
no-changelog
# Patch compiles (target_branch = v5.X) leave master holding the consumed
# fragments and missing the new version block. This applies the equivalent
# change to master: insert the same stamped blocks under the marker and
# delete the consumed fragments, so the next minor compile cannot
# re-release entries that already shipped in the patch.
- name: Apply forward-sync to master
if: env.TARGET_BRANCH != 'master'
run: |
set -euo pipefail
git checkout -B master origin/master
sync_body="${RUNNER_TEMP}/forward-sync-pr-body.md"
{
echo "### Description"
echo ""
echo "Forward-syncs the v${PROWLER_VERSION} compiled changelogs from \`${TARGET_BRANCH}\` to \`master\`: inserts the same stamped version blocks under the insertion marker and deletes the consumed fragments, so the next minor compile cannot re-release entries that already shipped in this patch. Opened automatically by the same run that opened the compile PR; review and squash-merge after it."
echo ""
echo "| Component | Fragments deleted on master | Skipped (only on ${TARGET_BRANCH}) |"
echo "|---|---|---|"
} > "$sync_body"
for component in $COMPILED_COMPONENTS; do
block_file="${RUNNER_TEMP}/block-${component}.md"
consumed_file="${RUNNER_TEMP}/consumed-${component}.txt"
if ! grep -qm1 '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md"; then
echo "::error::${component}/CHANGELOG.md on master is missing the insertion marker; cannot forward-sync"
exit 1
fi
deleted=0
skipped=0
while IFS= read -r fragment; do
if [ -z "$fragment" ]; then
continue
fi
if [ -f "$fragment" ]; then
git rm -q "$fragment"
deleted=$((deleted + 1))
else
echo "::notice::${fragment} does not exist on master (change landed only on ${TARGET_BRANCH}); skipping its deletion"
skipped=$((skipped + 1))
fi
done < "$consumed_file"
marker_line=$(grep -n -m1 '^<!-- changelog: release notes start -->$' "$component/CHANGELOG.md" | cut -d: -f1)
{
head -n "$marker_line" "$component/CHANGELOG.md"
cat "$block_file"
tail -n +"$((marker_line + 1))" "$component/CHANGELOG.md"
} > "${RUNNER_TEMP}/changelog.tmp"
mv "${RUNNER_TEMP}/changelog.tmp" "$component/CHANGELOG.md"
echo "| \`${component}\` | ${deleted} | ${skipped} |" >> "$sync_body"
done
{
echo ""
echo "### License"
echo ""
echo "By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license."
} >> "$sync_body"
echo "SYNC_BODY_FILE=${sync_body}" >> "$GITHUB_ENV"
- name: Create forward-sync PR
if: env.TARGET_BRANCH != 'master'
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
commit-message: 'chore(changelog): v${{ env.PROWLER_VERSION }} forward-sync to master'
branch: forward-sync-changelogs-${{ env.PROWLER_VERSION }}
base: master
title: 'chore(changelog): v${{ env.PROWLER_VERSION }} forward-sync to master'
body-path: ${{ env.SYNC_BODY_FILE }}
author: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
labels: |
no-changelog
@@ -105,6 +105,7 @@ jobs:
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
mcp_server/changelog.d/**
- name: Set up Docker Buildx
if: steps.check-changes.outputs.any_changed == 'true'
+108 -25
View File
@@ -62,53 +62,140 @@ jobs:
uv.lock
pyproject.toml
- name: Check for folder changes and changelog presence
- name: Check for folder changes and changelog fragment presence
id: check-folders
run: |
missing_changelogs=""
fragment_name_re='^[A-Za-z0-9][A-Za-z0-9._-]*\.(added|changed|deprecated|removed|fixed|security)(\.[0-9]+)?\.md$'
missing_fragments=""
invalid_fragments=""
linked_fragments=""
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
added_or_modified=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" | tr ' ' '\n')
# Returns success if the folder has a valid fragment added/modified, or
# (transition mode, to be removed after the migration grace period) a
# direct CHANGELOG.md edit.
has_changelog_update() {
local folder="$1"
if echo "$added_or_modified" | grep "^${folder}/changelog.d/" | sed "s|^${folder}/changelog.d/||" | grep -qE "$fragment_name_re"; then
return 0
fi
if echo "$all_changed" | grep -q "^${folder}/CHANGELOG.md$"; then
return 0
fi
return 1
}
if [[ "${STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED}" == "true" ]]; then
# Check monitored folders
for folder in $MONITORED_FOLDERS; do
# Get files changed in this folder
changed_in_folder=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep "^${folder}/" || true)
changed_in_folder=$(echo "$all_changed" | grep "^${folder}/" || true)
if [ -n "$changed_in_folder" ]; then
echo "Detected changes in ${folder}/"
# Check if CHANGELOG.md was updated
if ! echo "$changed_in_folder" | grep -q "^${folder}/CHANGELOG.md$"; then
echo "No changelog update found for ${folder}/"
missing_changelogs="${missing_changelogs}- \`${folder}\`"$'\n'
if ! has_changelog_update "$folder"; then
echo "No changelog fragment found for ${folder}/"
missing_fragments="${missing_fragments}- \`${folder}\`"$'\n'
fi
fi
done
# Check root-level dependency files (uv.lock, pyproject.toml)
# These are associated with the prowler folder changelog
root_deps_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep -E "^(uv\.lock|pyproject\.toml)$" || true)
root_deps_changed=$(echo "$all_changed" | grep -E "^(uv\.lock|pyproject\.toml)$" || true)
if [ -n "$root_deps_changed" ]; then
echo "Detected changes in root dependency files: $root_deps_changed"
# Check if prowler/CHANGELOG.md was already updated (might have been caught above)
prowler_changelog_updated=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | grep "^prowler/CHANGELOG.md$" || true)
if [ -z "$prowler_changelog_updated" ]; then
if ! has_changelog_update "prowler"; then
# Only add if prowler wasn't already flagged
if ! echo "$missing_changelogs" | grep -q "prowler"; then
echo "No changelog update found for root dependency changes"
missing_changelogs="${missing_changelogs}- \`prowler\` (root dependency files changed)"$'\n'
if ! echo "$missing_fragments" | grep -q "prowler"; then
echo "No changelog fragment found for root dependency changes"
missing_fragments="${missing_fragments}- \`prowler\` (root dependency files changed)"$'\n'
fi
fi
fi
# Validate the filename of every fragment added by this PR
added_fragments=$(echo "$added" | grep -E '^(api|ui|prowler|mcp_server)/changelog\.d/' || true)
for fragment in $added_fragments; do
name=$(basename "$fragment")
if [ "$name" = ".gitkeep" ] || [ "$name" = "README.md" ]; then
continue
fi
if ! echo "$name" | grep -qE "$fragment_name_re"; then
echo "Invalid fragment filename: $fragment"
invalid_fragments="${invalid_fragments}- \`${fragment}\`"$'\n'
fi
done
# Lint fragment content: the PR link is attached automatically at
# compile time, so a hand-written one would end up duplicated
touched_fragments=$(echo "$added_or_modified" | grep -E '^(api|ui|prowler|mcp_server)/changelog\.d/' || true)
for fragment in $touched_fragments; do
name=$(basename "$fragment")
if [ "$name" = ".gitkeep" ] || [ "$name" = "README.md" ] || [ ! -f "$fragment" ]; then
continue
fi
if grep -qE '\[\(#[0-9]+\)\]' "$fragment"; then
echo "Fragment contains a hand-written PR link: $fragment"
linked_fragments="${linked_fragments}- \`${fragment}\`"$'\n'
fi
done
fi
# Suggest a slug derived from the branch name for the bot comment
suggested_slug=$(echo "$HEAD_REF" | tr '[:upper:]' '[:lower:]' | sed 's|.*/||; s/[^a-z0-9._-]/-/g; s/^[^a-z0-9]*//')
if [ -z "$suggested_slug" ]; then
suggested_slug="my-change"
fi
fragment_help="A changelog fragment is a small Markdown file named \`<slug>.<type>.md\` under \`<component>/changelog.d/\`, where \`<type>\` is one of \`added\`, \`changed\`, \`deprecated\`, \`removed\`, \`fixed\` or \`security\`. Its content is the changelog entry text, without the PR link (added automatically at release time) and without a trailing period. For example:
\`\`\`
echo 'Entry text describing the change' > <component>/changelog.d/${suggested_slug}.fixed.md
\`\`\`
If this PR does not need a changelog entry, add the \`no-changelog\` label instead."
if [ -n "$missing_fragments" ] || [ -n "$invalid_fragments" ] || [ -n "$linked_fragments" ]; then
comment_body=""
if [ -n "$missing_fragments" ]; then
comment_body="⚠️ **Changes detected in the following folders without a changelog fragment:**"$'\n\n'"${missing_fragments}"$'\n'
fi
if [ -n "$invalid_fragments" ]; then
comment_body="${comment_body}⚠️ **Changelog fragment filenames that do not follow the naming convention:**"$'\n\n'"${invalid_fragments}"$'\n'
fi
if [ -n "$linked_fragments" ]; then
comment_body="${comment_body}⚠️ **Changelog fragments containing a hand-written PR link (remove it; the link is attached automatically at release time):**"$'\n\n'"${linked_fragments}"$'\n'
fi
comment_body="${comment_body}${fragment_help}"
else
comment_body="✅ All required changelog fragments are present."
fi
{
echo "missing_changelogs<<EOF"
echo -e "${missing_changelogs}"
echo "missing_fragments<<EOF"
echo -e "${missing_fragments}"
echo "EOF"
echo "invalid_fragments<<EOF"
echo -e "${invalid_fragments}"
echo "EOF"
echo "linked_fragments<<EOF"
echo -e "${linked_fragments}"
echo "EOF"
echo "comment_body<<EOF"
echo "${comment_body}"
echo "EOF"
} >> $GITHUB_OUTPUT
env:
STEPS_CHANGED_FILES_OUTPUTS_ANY_CHANGED: ${{ steps.changed-files.outputs.any_changed }}
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES: ${{ steps.changed-files.outputs.added_files }}
STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES: ${{ steps.changed-files.outputs.modified_files }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
- name: Find existing changelog comment
if: github.event.pull_request.head.repo.full_name == github.repository
@@ -128,14 +215,10 @@ jobs:
edit-mode: replace
body: |
<!-- changelog-check -->
${{ steps.check-folders.outputs.missing_changelogs != '' && format('⚠️ **Changes detected in the following folders without a corresponding update to the `CHANGELOG.md`:**
${{ steps.check-folders.outputs.comment_body }}
{0}
Please add an entry to the corresponding `CHANGELOG.md` file to maintain a clear history of changes.', steps.check-folders.outputs.missing_changelogs) || '✅ All necessary `CHANGELOG.md` files have been updated.' }}
- name: Fail if changelog is missing
if: steps.check-folders.outputs.missing_changelogs != ''
- name: Fail if changelog fragment is missing or invalid
if: steps.check-folders.outputs.missing_fragments != '' || steps.check-folders.outputs.invalid_fragments != '' || steps.check-folders.outputs.linked_fragments != ''
run: |
echo "::error::Missing changelog updates in some folders"
echo "::error::Missing or invalid changelog fragments"
exit 1
+1
View File
@@ -54,6 +54,7 @@ jobs:
files_ignore: |
.github/**
prowler/CHANGELOG.md
prowler/changelog.d/**
docs/**
permissions/**
api/**
+2
View File
@@ -12,6 +12,7 @@ on:
- '.github/workflows/sdk-codeql.yml'
- '.github/codeql/sdk-codeql-config.yml'
- '!prowler/CHANGELOG.md'
- '!prowler/changelog.d/**'
pull_request:
branches:
- 'master'
@@ -23,6 +24,7 @@ on:
- '.github/workflows/sdk-codeql.yml'
- '.github/codeql/sdk-codeql-config.yml'
- '!prowler/CHANGELOG.md'
- '!prowler/changelog.d/**'
schedule:
- cron: '00 12 * * *'
@@ -115,6 +115,7 @@ jobs:
files_ignore: |
.github/**
prowler/CHANGELOG.md
prowler/changelog.d/**
docs/**
permissions/**
api/**
+1
View File
@@ -77,6 +77,7 @@ jobs:
files_ignore: |
.github/**
prowler/CHANGELOG.md
prowler/changelog.d/**
docs/**
permissions/**
api/**
+5 -4
View File
@@ -76,6 +76,7 @@ jobs:
files_ignore: |
.github/**
prowler/CHANGELOG.md
prowler/changelog.d/**
docs/**
permissions/**
api/**
@@ -540,7 +541,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 +589,7 @@ 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'
@@ -608,14 +609,14 @@ jobs:
- 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'
+2
View File
@@ -10,6 +10,7 @@ on:
- '.github/workflows/ui-codeql.yml'
- '.github/codeql/ui-codeql-config.yml'
- '!ui/CHANGELOG.md'
- '!ui/changelog.d/**'
pull_request:
branches:
- 'master'
@@ -19,6 +20,7 @@ on:
- '.github/workflows/ui-codeql.yml'
- '.github/codeql/ui-codeql-config.yml'
- '!ui/CHANGELOG.md'
- '!ui/changelog.d/**'
schedule:
- cron: '00 12 * * *'
@@ -105,6 +105,7 @@ jobs:
files: ui/**
files_ignore: |
ui/CHANGELOG.md
ui/changelog.d/**
ui/README.md
ui/AGENTS.md
+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: |
+1
View File
@@ -60,6 +60,7 @@ jobs:
.github/workflows/ui-tests.yml
files_ignore: |
ui/CHANGELOG.md
ui/changelog.d/**
ui/README.md
ui/AGENTS.md
+1 -1
View File
@@ -122,7 +122,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Vercel | 26 | 6 | 0 | 8 | Official | UI, API, CLI |
| Okta | 1 | 1 | 0 | 1 | Official | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 0 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 4 | 1 | 0 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 0 | 3 | Unofficial | CLI |
| NHN | 6 | 2 | 1 | 0 | Unofficial | CLI |
> [!Note]
+17 -3
View File
@@ -2,26 +2,40 @@
All notable changes to the **Prowler API** are documented in this file.
## [1.31.0] (Prowler UNRELEASED)
<!-- changelog: release notes start -->
## [1.31.1] (Prowler v5.30.1)
### 🐞 Fixed
- `compliance-overviews/attributes` now resolves the provider from the scan, so multi-provider universal frameworks (e.g. CSA CCM) return the check IDs of the scan's provider and Azure/GCP requirement details show their findings instead of appearing empty [(#11546)](https://github.com/prowler-cloud/prowler/pull/11546)
- Attack Paths: `drop_subgraph` now deletes relationships first and then nodes in batches, using less memory on Neo4j when clearing a dense provider graph [(#11557)](https://github.com/prowler-cloud/prowler/pull/11557)
- OCI scans now use API key credentials with the configured region instead of falling back to `/home/prowler/.oci/config` [(#11558)](https://github.com/prowler-cloud/prowler/pull/11558)
---
## [1.31.0] (Prowler v5.30.0)
### 🚀 Added
- 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)
- SAML logins no longer wipe a user's roles when the IdP does not send the `userType` attribute; existing roles are kept, and when `userType` names a role that does not exist it is now created with read-only access (visibility over all providers, no management permissions) instead of no permissions at all [(#11520)](https://github.com/prowler-cloud/prowler/pull/11520)
### 🐞 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)
- Compliance catalog now warms in background during startup. `compliance-overviews/attributes` returns `503` while warming, so the first request after a deploy no longer trips the API timeout [(#11530)](https://github.com/prowler-cloud/prowler/pull/11530)
### 🔐 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)
- `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) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
---
View File
+10
View File
@@ -0,0 +1,10 @@
# Changelog fragments
Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
- Filename: `<slug>.<type>.md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
- `<type>` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
+1 -1
View File
@@ -68,7 +68,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.31.0"
version = "1.32.0"
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
+18 -2
View File
@@ -175,7 +175,8 @@ def drop_subgraph(database: str, provider_id: str) -> int:
"""
Delete all nodes for a provider from the tenant database.
Uses batched deletion to avoid memory issues with large graphs.
Deletes relationships then nodes in batches (not `DETACH DELETE`) so a dense
provider's graph cannot exceed Neo4j's transaction memory limit.
Silently returns 0 if the database doesn't exist.
"""
provider_label = get_provider_label(provider_id)
@@ -183,13 +184,28 @@ def drop_subgraph(database: str, provider_id: str) -> int:
try:
with get_session(database) as session:
# Phase 1: delete relationships incident to provider nodes in batches.
deleted_count = 1
while deleted_count > 0:
result = session.run(
f"""
MATCH (:`{provider_label}`)-[r]-()
WITH DISTINCT r LIMIT $batch_size
DELETE r
RETURN COUNT(r) AS deleted_rels_count
""",
{"batch_size": BATCH_SIZE},
)
deleted_count = result.single().get("deleted_rels_count", 0)
# Phase 2: delete the now relationship-free nodes in batches.
deleted_count = 1
while deleted_count > 0:
result = session.run(
f"""
MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
WITH n LIMIT $batch_size
DETACH DELETE n
DELETE n
RETURN COUNT(n) AS deleted_nodes_count
""",
{"batch_size": BATCH_SIZE},
+63
View File
@@ -1,3 +1,5 @@
import logging
import threading
from collections.abc import Iterable, Mapping
from api.models import Provider
@@ -6,8 +8,19 @@ from prowler.lib.check.compliance_models import (
)
from prowler.lib.check.models import CheckMetadata
logger = logging.getLogger(__name__)
AVAILABLE_COMPLIANCE_FRAMEWORKS = {}
# Per-process readiness flags for the background compliance warm-up.
# `STARTED` is set as soon as warming begins (only happens under Gunicorn via
# the post_fork hook); `WARMED` is set when it finishes. The attributes
# endpoint checks both: it returns 503 only while warming is in progress.
# Under `runserver` warming never runs, so `STARTED` stays clear and the
# endpoint keeps lazy-loading as before.
COMPLIANCE_WARMING_STARTED = threading.Event()
COMPLIANCE_WARMED = threading.Event()
class LazyComplianceTemplate(Mapping):
"""Lazy-load compliance templates per provider on first access."""
@@ -174,6 +187,56 @@ def _ensure_provider_loaded(provider_type: Provider.ProviderChoices) -> None:
PROWLER_CHECKS._cache[provider_type] = checks
def warm_compliance_caches(
provider_types: Iterable[str] | None = None,
) -> list[str]:
"""
Eagerly populate the per-process compliance caches at server startup.
Moves the cold-cache catalog load off the request thread so the first
request does not trip the Gunicorn worker timeout. Reads only on-disk
metadata (no database access). Each provider is warmed in isolation;
failures are logged and fall back to lazy loading.
Args:
provider_types (Iterable[str] | None): Subset to warm. Defaults to all.
Returns:
list[str]: Provider types that could not be warmed.
"""
if provider_types is None:
provider_types = Provider.ProviderChoices.values
provider_types = list(provider_types)
COMPLIANCE_WARMING_STARTED.set()
logger.info("Compliance cache warm-up started for providers: %s", provider_types)
failed = []
for provider_type in provider_types:
try:
get_compliance_frameworks(provider_type)
_ensure_provider_loaded(provider_type)
# Prowler check loading may sys.exit (SystemExit, not Exception).
except (Exception, SystemExit):
logger.warning(
"Failed to warm compliance caches for provider '%s'; "
"loading lazily on first request",
provider_type,
exc_info=True,
)
failed.append(provider_type)
# Mark as warmed even when some providers failed: a failed provider falls
# back to a single-provider lazy load, which stays under the worker timeout.
COMPLIANCE_WARMED.set()
logger.info(
"Compliance cache warm-up finished (providers warmed: %d, failed: %s)",
len(provider_types) - len(failed),
failed,
)
return failed
def load_prowler_checks(
prowler_compliance, provider_types: Iterable[str] | None = None
):
+26
View File
@@ -187,6 +187,32 @@ class UpstreamServiceUnavailableError(APIException):
)
class ComplianceWarmingError(APIException):
"""Compliance catalog is still warming (503 Service Unavailable).
Returned by the compliance attributes endpoint while the per-process
catalog warm-up is in progress, so the request thread never triggers the
slow cold load that would trip the Gunicorn worker timeout.
"""
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
default_detail = (
"Compliance data is still loading. Please try again in a few seconds."
)
default_code = "compliance_warming"
def __init__(self, detail=None):
super().__init__(
detail=[
{
"detail": detail or self.default_detail,
"status": str(self.status_code),
"code": self.default_code,
}
]
)
class UpstreamInternalError(APIException):
"""Unexpected error communicating with provider (500 Internal Server Error).
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.31.0
version: 1.32.0
description: |-
Prowler API specification.
@@ -542,3 +542,84 @@ class TestHasProviderData:
):
with pytest.raises(db_module.GraphDatabaseQueryException):
db_module.has_provider_data("db-tenant-abc", "provider-123")
class TestDropSubgraph:
"""Test drop_subgraph two-phase batched deletion of a provider's graph."""
@staticmethod
def _result(count):
result = MagicMock()
result.single.return_value.get.return_value = count
return result
@staticmethod
def _session_ctx(session):
ctx = MagicMock()
ctx.__enter__.return_value = session
ctx.__exit__.return_value = False
return ctx
def test_deletes_relationships_then_nodes_in_batches(self):
session = MagicMock()
# Phase 1 (relationships): one full batch then empty.
# Phase 2 (nodes): one full batch then empty.
session.run.side_effect = [
self._result(1000),
self._result(0),
self._result(1000),
self._result(0),
]
with patch(
"api.attack_paths.database.get_session",
return_value=self._session_ctx(session),
):
deleted = db_module.drop_subgraph("db-tenant-abc", "provider-123")
# Only phase-2 node counts contribute to the return value.
assert deleted == 1000
assert session.run.call_count == 4
queries = [call.args[0] for call in session.run.call_args_list]
# Regression guard: the memory blow-up was caused by DETACH DELETE.
assert all("DETACH DELETE" not in query for query in queries)
rel_queries = [query for query in queries if "DELETE r" in query]
node_queries = [query for query in queries if "DELETE n" in query]
assert rel_queries and node_queries
# DISTINCT avoids double-counting relationships matched from both ends.
assert all("DISTINCT r" in query for query in rel_queries)
# Relationships must be fully drained before nodes are deleted.
first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q)
last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q)
assert last_rel < first_node
def test_returns_zero_when_database_not_found(self):
session_ctx = MagicMock()
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
message="Database does not exist",
code="Neo.ClientError.Database.DatabaseNotFound",
)
with patch(
"api.attack_paths.database.get_session",
return_value=session_ctx,
):
assert db_module.drop_subgraph("db-tenant-gone", "provider-123") == 0
def test_raises_on_other_errors(self):
session_ctx = MagicMock()
session_ctx.__enter__.side_effect = db_module.GraphDatabaseQueryException(
message="Connection refused",
code="Neo.TransientError.General.UnknownError",
)
with patch(
"api.attack_paths.database.get_session",
return_value=session_ctx,
):
with pytest.raises(db_module.GraphDatabaseQueryException):
db_module.drop_subgraph("db-tenant-abc", "provider-123")
@@ -10,6 +10,7 @@ from api.compliance import (
get_prowler_provider_checks,
get_prowler_provider_compliance,
load_prowler_checks,
warm_compliance_caches,
)
from api.models import Provider
from prowler.lib.check.compliance_models import (
@@ -267,11 +268,17 @@ def reset_compliance_cache():
"""Reset the module-level cache so each test starts cold."""
previous = dict(compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS)
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
# The warming flags are module-global; clear them so they do not leak
# between tests that call warm_compliance_caches.
compliance_module.COMPLIANCE_WARMING_STARTED.clear()
compliance_module.COMPLIANCE_WARMED.clear()
try:
yield
finally:
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.clear()
compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS.update(previous)
compliance_module.COMPLIANCE_WARMING_STARTED.clear()
compliance_module.COMPLIANCE_WARMED.clear()
class TestGetComplianceFrameworks:
@@ -321,3 +328,89 @@ class TestGetComplianceFrameworks:
f"loadable by get_bulk_compliance_frameworks_universal: "
f"{sorted(missing)}"
)
class TestWarmComplianceCaches:
def test_warms_all_provider_types_by_default(self, reset_compliance_cache):
provider_types = list(Provider.ProviderChoices.values)
with (
patch("api.compliance.get_compliance_frameworks") as mock_frameworks,
patch("api.compliance._ensure_provider_loaded") as mock_ensure,
):
warm_compliance_caches()
warmed = {call.args[0] for call in mock_frameworks.call_args_list}
assert warmed == set(provider_types)
assert mock_frameworks.call_count == len(provider_types)
assert mock_ensure.call_count == len(provider_types)
def test_warms_only_requested_provider_types(self, reset_compliance_cache):
with (
patch("api.compliance.get_compliance_frameworks") as mock_frameworks,
patch("api.compliance._ensure_provider_loaded") as mock_ensure,
):
warm_compliance_caches([Provider.ProviderChoices.AWS])
mock_frameworks.assert_called_once_with(Provider.ProviderChoices.AWS)
mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS)
def test_populates_module_cache(self, reset_compliance_cache):
with (
patch(
"api.compliance.get_bulk_compliance_frameworks_universal"
) as mock_get_bulk,
patch("api.compliance._ensure_provider_loaded"),
):
mock_get_bulk.return_value = {"cis_1.4_aws": MagicMock()}
warm_compliance_caches([Provider.ProviderChoices.AWS])
assert (
Provider.ProviderChoices.AWS
in compliance_module.AVAILABLE_COMPLIANCE_FRAMEWORKS
)
def test_failing_provider_does_not_abort_the_rest(self, reset_compliance_cache):
"""A failing provider (even on SystemExit) is isolated; others warm."""
providers = [Provider.ProviderChoices.AWS, Provider.ProviderChoices.OKTA]
def fake_frameworks(provider_type):
if provider_type == Provider.ProviderChoices.OKTA:
raise SystemExit(1)
return []
with (
patch(
"api.compliance.get_compliance_frameworks", side_effect=fake_frameworks
),
patch("api.compliance._ensure_provider_loaded") as mock_ensure,
):
failed = warm_compliance_caches(providers)
assert failed == [Provider.ProviderChoices.OKTA]
mock_ensure.assert_called_once_with(Provider.ProviderChoices.AWS)
def test_sets_readiness_flags(self, reset_compliance_cache):
assert not compliance_module.COMPLIANCE_WARMING_STARTED.is_set()
assert not compliance_module.COMPLIANCE_WARMED.is_set()
with (
patch("api.compliance.get_compliance_frameworks"),
patch("api.compliance._ensure_provider_loaded"),
):
warm_compliance_caches([Provider.ProviderChoices.AWS])
assert compliance_module.COMPLIANCE_WARMING_STARTED.is_set()
assert compliance_module.COMPLIANCE_WARMED.is_set()
def test_marks_warmed_even_when_a_provider_fails(self, reset_compliance_cache):
"""A failed provider still leaves the caches flagged as warmed."""
with (
patch(
"api.compliance.get_compliance_frameworks",
side_effect=SystemExit(1),
),
patch("api.compliance._ensure_provider_loaded"),
):
warm_compliance_caches([Provider.ProviderChoices.AWS])
assert compliance_module.COMPLIANCE_WARMED.is_set()
+24
View File
@@ -357,6 +357,30 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_oraclecloud_converts_region_string_to_set(
self,
):
secret_dict = {
"user": "ocid1.user.oc1..fake",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "us-ashburn-1",
"pass_phrase": "fake-passphrase",
}
secret_mock = MagicMock()
secret_mock.secret = secret_dict
provider = MagicMock()
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret = secret_mock
provider.uid = "ocid1.tenancy.oc1..fake"
result = get_prowler_provider_kwargs(provider)
expected_result = {**secret_dict, "region": {"us-ashburn-1"}}
assert result == expected_result
def test_get_prowler_provider_kwargs_with_mutelist(self):
provider_uid = "provider_uid"
secret_dict = {"key": "value"}
+232 -164
View File
@@ -9570,6 +9570,188 @@ class TestComplianceOverviewViewSet:
assert "Category" in first_attr
assert "AWSService" in first_attr
def test_compliance_overview_attributes_resolves_provider_from_scan(
self, authenticated_client, tenants_fixture, providers_fixture
):
# csa_ccm_4.0 is a multi-provider universal framework: a single
# compliance_id whose requirements expose different checks per provider.
# Passing a scan must return the check IDs for that scan's provider,
# otherwise the endpoint defaults to the first provider that declares the
# framework and azure/gcp requirements end up with check IDs that match
# no findings.
tenant = tenants_fixture[0]
gcp_provider = providers_fixture[2]
azure_provider = providers_fixture[4]
assert gcp_provider.provider == Provider.ProviderChoices.GCP.value
assert azure_provider.provider == Provider.ProviderChoices.AZURE.value
now = datetime.now(timezone.utc)
gcp_scan = Scan.objects.create(
name="gcp scan",
provider=gcp_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
azure_scan = Scan.objects.create(
name="azure scan",
provider=azure_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
def request_attributes(scan_id=None):
params = {"filter[compliance_id]": "csa_ccm_4.0"}
if scan_id is not None:
params["filter[scan_id]"] = str(scan_id)
return authenticated_client.get(
reverse("complianceoverview-attributes"), params
)
def collect_check_ids(scan_id=None):
response = request_attributes(scan_id)
assert response.status_code == status.HTTP_200_OK
check_ids = set()
for item in response.json()["data"]:
check_ids.update(item["attributes"]["attributes"]["check_ids"])
return check_ids
gcp_check_ids = collect_check_ids(gcp_scan.id)
azure_check_ids = collect_check_ids(azure_scan.id)
# Each scan resolves to its own provider's checks, and they differ.
assert gcp_check_ids
assert azure_check_ids
assert gcp_check_ids != azure_check_ids
# The returned check IDs belong to the SDK's per-provider definition.
from api.compliance import get_prowler_provider_compliance
def expected_check_ids(provider_type):
framework = get_prowler_provider_compliance(provider_type)["csa_ccm_4.0"]
expected = set()
for requirement in framework.requirements:
expected.update(requirement.checks.get(provider_type, []))
return expected
assert gcp_check_ids <= expected_check_ids(Provider.ProviderChoices.GCP.value)
assert azure_check_ids <= expected_check_ids(
Provider.ProviderChoices.AZURE.value
)
# An explicit scan_id is authoritative: a non-existent scan must fail
# closed with 404 instead of silently falling back to another provider.
missing_response = request_attributes("00000000-0000-0000-0000-000000000000")
assert missing_response.status_code == status.HTTP_404_NOT_FOUND
# A malformed scan_id is rejected with 404 as well.
malformed_response = request_attributes("not-a-uuid")
assert malformed_response.status_code == status.HTTP_404_NOT_FOUND
# An empty value (filter[scan_id]=) must not fall back to the legacy
# provider picker: the explicit (if blank) selector fails closed.
empty_response = request_attributes("")
assert empty_response.status_code == status.HTTP_404_NOT_FOUND
# A scan belonging to another tenant is not visible (RLS), so it must
# return 404 rather than leaking the fallback provider's check IDs.
other_tenant = Tenant.objects.create(name="Other Compliance Tenant")
foreign_provider = Provider.objects.create(
provider="gcp",
uid="foreign-gcp-test",
alias="foreign_gcp",
tenant_id=other_tenant.id,
)
foreign_scan = Scan.objects.create(
name="foreign scan",
provider=foreign_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=other_tenant.id,
started_at=now,
completed_at=now,
)
foreign_response = request_attributes(foreign_scan.id)
assert foreign_response.status_code == status.HTTP_404_NOT_FOUND
def test_compliance_overview_attributes_scan_scoped_by_provider_group(
self,
authenticated_client_no_permissions_rbac,
providers_fixture,
):
# A user with limited visibility (no UNLIMITED_VISIBILITY) must only be
# able to resolve scans for providers in its provider groups. Tenant RLS
# alone is not enough here: both scans belong to the same tenant, so the
# endpoint has to scope the scan lookup by provider group, otherwise a
# restricted user could read another provider's compliance metadata.
client = authenticated_client_no_permissions_rbac
limited_user = client.user
membership = Membership.objects.filter(user=limited_user).first()
tenant = membership.tenant
allowed_provider = providers_fixture[2]
denied_provider = providers_fixture[4]
assert allowed_provider.provider == Provider.ProviderChoices.GCP.value
assert denied_provider.provider == Provider.ProviderChoices.AZURE.value
provider_group = ProviderGroup.objects.create(
name="limited-compliance-group",
tenant_id=tenant.id,
)
ProviderGroupMembership.objects.create(
tenant_id=tenant.id,
provider_group=provider_group,
provider=allowed_provider,
)
RoleProviderGroupRelationship.objects.create(
tenant_id=tenant.id,
role=limited_user.roles.first(),
provider_group=provider_group,
)
now = datetime.now(timezone.utc)
allowed_scan = Scan.objects.create(
name="allowed scan",
provider=allowed_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
denied_scan = Scan.objects.create(
name="denied scan",
provider=denied_provider,
trigger=Scan.TriggerChoices.MANUAL,
state=StateChoices.COMPLETED,
tenant_id=tenant.id,
started_at=now,
completed_at=now,
)
def request_attributes(scan_id):
return client.get(
reverse("complianceoverview-attributes"),
{
"filter[compliance_id]": "csa_ccm_4.0",
"filter[scan_id]": str(scan_id),
},
)
# The scan in the user's provider group resolves normally.
assert request_attributes(allowed_scan.id).status_code == status.HTTP_200_OK
# The scan outside the user's provider group is invisible, so it fails
# closed with 404 instead of leaking the other provider's check IDs.
assert (
request_attributes(denied_scan.id).status_code == status.HTTP_404_NOT_FOUND
)
def test_compliance_overview_attributes_missing_compliance_id(
self, authenticated_client
):
@@ -9578,6 +9760,39 @@ class TestComplianceOverviewViewSet:
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_compliance_overview_attributes_503_while_warming(
self, authenticated_client
):
from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED
COMPLIANCE_WARMING_STARTED.set()
COMPLIANCE_WARMED.clear()
try:
response = authenticated_client.get(
reverse("complianceoverview-attributes"),
{"filter[compliance_id]": "aws_account_security_onboarding_aws"},
)
finally:
COMPLIANCE_WARMING_STARTED.clear()
assert response.status_code == status.HTTP_503_SERVICE_UNAVAILABLE
assert response.json()["errors"][0]["code"] == "compliance_warming"
def test_compliance_overview_attributes_serves_when_warming_not_started(
self, authenticated_client
):
# Dev fallback: under runserver warming never runs, so the guard must
# not refuse — the endpoint lazily loads and serves as before.
from api.compliance import COMPLIANCE_WARMED, COMPLIANCE_WARMING_STARTED
COMPLIANCE_WARMING_STARTED.clear()
COMPLIANCE_WARMED.clear()
response = authenticated_client.get(
reverse("complianceoverview-attributes"),
{"filter[compliance_id]": "aws_account_security_onboarding_aws"},
)
assert response.status_code == status.HTTP_200_OK
def test_compliance_overview_task_management_integration(
self, authenticated_client, compliance_requirements_overviews_fixture
):
@@ -12625,7 +12840,7 @@ class TestTenantFinishACSView:
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": ["platform_team"],
"userType": ["no_permissions"],
},
)
@@ -12680,21 +12895,12 @@ class TestTenantFinishACSView:
assert user.name == "John Doe"
assert user.company_name == "testing_company"
role = Role.objects.using(MainRouter.admin_db).get(
name="platform_team", tenant=tenants_fixture[0]
)
role = Role.objects.using(MainRouter.admin_db).get(name="no_permissions")
assert role.tenant == tenants_fixture[0]
assert not role.manage_users
assert not role.manage_account
assert not role.manage_billing
assert not role.manage_providers
assert not role.manage_integrations
assert not role.manage_scans
assert role.unlimited_visibility
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=role, tenant_id=tenants_fixture[0].id)
.filter(user=user, tenant_id=tenants_fixture[0].id)
.exists()
)
@@ -12747,7 +12953,7 @@ class TestTenantFinishACSView:
assert response.status_code == 302
assert "sso_saml_failed=true" in response.url
def test_dispatch_keeps_existing_roles_when_usertype_missing(
def test_dispatch_skips_role_mapping_when_single_manage_account_user(
self,
create_test_user,
tenants_fixture,
@@ -12756,154 +12962,7 @@ class TestTenantFinishACSView:
settings,
monkeypatch,
):
"""Test that roles are left untouched when the IdP does not send userType"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
admin_role = admin_role_fixture
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user, role=admin_role, tenant_id=tenant.id
)
roles_before = Role.objects.using(MainRouter.admin_db).count()
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
},
)
request = RequestFactory().get(
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
)
request.user = user
request.session = {}
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml", client_id="testtenant", name="Test App", settings={}
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = MagicMock()
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
response = view(request, organization_slug="testtenant")
assert response.status_code == 302
# Verify the existing role assignment was not modified
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=admin_role, tenant_id=tenant.id)
.exists()
)
# Verify no new role was created
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
def test_dispatch_assigns_no_role_to_new_user_when_usertype_missing(
self,
create_test_user,
tenants_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that a user without roles gets none assigned when userType is missing"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
roles_before = Role.objects.using(MainRouter.admin_db).count()
social_account = SocialAccount(
user=user,
provider="saml",
extra_data={
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
},
)
request = RequestFactory().get(
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
)
request.user = user
request.session = {}
with (
patch(
"allauth.socialaccount.providers.saml.views.get_app_or_404"
) as mock_get_app_or_404,
patch(
"allauth.socialaccount.models.SocialApp.objects.get"
) as mock_socialapp_get,
patch(
"allauth.socialaccount.models.SocialAccount.objects.get"
) as mock_sa_get,
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
patch("api.models.User.objects.get") as mock_user_get,
):
mock_get_app_or_404.return_value = MagicMock(
provider="saml", client_id="testtenant", name="Test App", settings={}
)
mock_sa_get.return_value = social_account
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
mock_saml_config_get.return_value = MagicMock()
mock_user_get.return_value = user
view = TenantFinishACSView.as_view()
response = view(request, organization_slug="testtenant")
assert response.status_code == 302
# Verify no role was created or assigned
assert Role.objects.using(MainRouter.admin_db).count() == roles_before
assert not (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, tenant_id=tenant.id)
.exists()
)
# Membership is still created so the user belongs to the tenant
assert (
Membership.objects.using(MainRouter.admin_db)
.filter(user=user, tenant=tenant)
.exists()
)
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_new_role(
self,
create_test_user,
tenants_fixture,
admin_role_fixture,
saml_setup,
settings,
monkeypatch,
):
"""Test that a new read-only role is neither created nor assigned if it would remove the last MANAGE_ACCOUNT user"""
"""Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
user = create_test_user
tenant = tenants_fixture[0]
@@ -12920,7 +12979,7 @@ class TestTenantFinishACSView:
"firstName": ["John"],
"lastName": ["Doe"],
"organization": ["testing_company"],
"userType": ["brand_new_role"],
"userType": ["no_permissions"], # This should be ignored
},
)
@@ -12958,15 +13017,24 @@ class TestTenantFinishACSView:
assert response.status_code == 302
# The admin role is still assigned and the new role was not created
# Verify the admin role is still assigned (not changed to no_permissions)
assert (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role=admin_role, tenant_id=tenant.id)
.exists()
)
# Verify no_permissions role was NOT created in the database
assert (
not Role.objects.using(MainRouter.admin_db)
.filter(name="brand_new_role", tenant=tenant)
.filter(name="no_permissions", tenant=tenant)
.exists()
)
# Verify no_permissions role was NOT assigned to the user
assert not (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(user=user, role__name="no_permissions", tenant_id=tenant.id)
.exists()
)
+6
View File
@@ -243,6 +243,12 @@ def get_prowler_provider_kwargs(
**prowler_provider_kwargs,
"filter_accounts": [provider.uid],
}
elif provider.provider == Provider.ProviderChoices.ORACLECLOUD.value:
if isinstance(prowler_provider_kwargs.get("region"), str):
prowler_provider_kwargs = {
**prowler_provider_kwargs,
"region": {prowler_provider_kwargs["region"]},
}
elif provider.provider == Provider.ProviderChoices.OPENSTACK.value:
# clouds_yaml_content, clouds_yaml_cloud and provider_id are validated
# in the provider itself, so it's not needed here.
+116 -61
View File
@@ -30,6 +30,7 @@ from dj_rest_auth.registration.views import SocialLoginView
from django.conf import settings as django_settings
from django.contrib.postgres.aggregates import ArrayAgg, BoolAnd, StringAgg
from django.contrib.postgres.search import SearchQuery
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.db.models import (
BooleanField,
@@ -114,6 +115,8 @@ from api.attack_paths import get_queries_for_provider, get_query_by_id
from api.attack_paths import views_helpers as attack_paths_views_helpers
from api.base_views import BaseRLSViewSet, BaseTenantViewset, BaseUserViewset
from api.compliance import (
COMPLIANCE_WARMED,
COMPLIANCE_WARMING_STARTED,
PROWLER_COMPLIANCE_OVERVIEW_TEMPLATE,
get_compliance_frameworks,
get_prowler_provider_compliance,
@@ -122,6 +125,7 @@ from api.constants import SEVERITY_ORDER
from api.db_router import MainRouter
from api.db_utils import rls_transaction
from api.exceptions import (
ComplianceWarmingError,
TaskFailedException,
UpstreamAccessDeniedError,
UpstreamAuthenticationError,
@@ -797,70 +801,60 @@ class TenantFinishACSView(FinishACSView):
.tenant
)
# Only remap roles when the IdP provides a userType attribute.
# Without it, the user's current roles are left untouched.
role_name = (
extra.get("userType", [""])[0].strip() if extra.get("userType") else ""
extra.get("userType", ["no_permissions"])[0].strip()
if extra.get("userType")
else "no_permissions"
)
role = (
Role.objects.using(MainRouter.admin_db)
.filter(name=role_name, tenant=tenant)
.first()
)
if role_name:
with transaction.atomic(using=MainRouter.admin_db):
role = (
Role.objects.using(MainRouter.admin_db)
.filter(name=role_name, tenant=tenant)
.first()
)
# Only skip mapping if it would remove the last MANAGE_ACCOUNT user
remaining_manage_account_users = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id)
.exclude(user_id=user_id)
.values("user")
.distinct()
.count()
)
user_has_manage_account = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(
role__manage_account=True,
tenant_id=tenant.id,
user_id=user_id,
)
.exists()
)
role_manage_account = role.manage_account if role else False
would_remove_last_manage_account = (
user_has_manage_account
and remaining_manage_account_users == 0
and not role_manage_account
)
# Only skip mapping if it would remove the last MANAGE_ACCOUNT user
remaining_manage_account_users = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id)
.exclude(user_id=user_id)
.values("user")
.distinct()
.count()
)
user_has_manage_account = (
UserRoleRelationship.objects.using(MainRouter.admin_db)
.filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id)
.exists()
)
role_manage_account = role.manage_account if role else False
would_remove_last_manage_account = (
user_has_manage_account
and remaining_manage_account_users == 0
and not role_manage_account
)
if not would_remove_last_manage_account:
if role is None:
# Roles auto-created from userType get read-only access:
# visibility over all providers, no management permissions
role, _ = Role.objects.using(MainRouter.admin_db).get_or_create(
name=role_name,
tenant=tenant,
defaults={
"manage_users": False,
"manage_account": False,
"manage_billing": False,
"manage_providers": False,
"manage_integrations": False,
"manage_scans": False,
"unlimited_visibility": True,
},
)
UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
user=user,
tenant_id=tenant.id,
).delete()
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user,
role=role,
tenant_id=tenant.id,
)
if not would_remove_last_manage_account:
if role is None:
role = Role.objects.using(MainRouter.admin_db).create(
name=role_name,
tenant=tenant,
manage_users=False,
manage_account=False,
manage_billing=False,
manage_providers=False,
manage_integrations=False,
manage_scans=False,
unlimited_visibility=False,
)
UserRoleRelationship.objects.using(MainRouter.admin_db).filter(
user=user,
tenant_id=tenant.id,
).delete()
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
user=user,
role=role,
tenant_id=tenant.id,
)
membership, _ = Membership.objects.using(MainRouter.admin_db).get_or_create(
user=user,
tenant=tenant,
@@ -4651,6 +4645,16 @@ class RoleProviderGroupRelationshipView(RelationshipView, BaseRLSViewSet):
location=OpenApiParameter.QUERY,
description="Compliance framework ID to get attributes for.",
),
OpenApiParameter(
name="filter[scan_id]",
required=False,
type=OpenApiTypes.UUID,
location=OpenApiParameter.QUERY,
description="Scan ID used to resolve the provider for "
"multi-provider universal frameworks (e.g. CSA CCM), so "
"the returned check IDs match the scan's provider. When omitted, "
"the first provider that declares the framework is used.",
),
],
responses={
200: OpenApiResponse(
@@ -5069,6 +5073,13 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
@action(detail=False, methods=["get"], url_name="attributes")
def attributes(self, request):
# While the background warm-up is in progress, refuse immediately
# instead of falling through to the slow cold load on the request
# thread (which would trip the Gunicorn worker timeout). `is_set()` is
# a non-blocking flag read, so this never touches the loader.
if COMPLIANCE_WARMING_STARTED.is_set() and not COMPLIANCE_WARMED.is_set():
raise ComplianceWarmingError()
compliance_id = request.query_params.get("filter[compliance_id]")
if not compliance_id:
raise ValidationError(
@@ -5084,7 +5095,51 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin):
provider_type = None
# If we couldn't determine from database, try each provider type
# When a scan is provided, resolve the provider from it. Multi-provider
# universal frameworks (e.g. CSA CCM) share a single compliance_id
# across providers but expose different checks per provider, so the
# metadata (and therefore the check IDs the UI uses to fetch findings)
# must be returned for the scan's provider. Without this, the endpoint
# falls back to the first provider that declares the framework and
# returns its check IDs, leaving azure/gcp/... requirements with no
# matching findings.
scan_id = request.query_params.get("filter[scan_id]")
if "filter[scan_id]" in request.query_params:
# An explicit scan_id is authoritative: fail closed instead of
# falling back to another provider. Otherwise an invalid, empty
# (filter[scan_id]=) or inaccessible scan would silently return the
# first provider's check IDs, recreating the multi-provider mismatch
# this endpoint fixes.
if not scan_id:
raise NotFound(detail=f"Scan '{scan_id}' not found.")
# Tenant isolation is already enforced by Postgres RLS on the
# connection (see BaseRLSViewSet). Scope the lookup by provider
# group as well so a user with limited visibility can't resolve
# another provider's scan and read its compliance metadata, mirroring
# the RBAC scoping get_queryset() applies to the rest of the ViewSet.
role = get_role(request.user, request.tenant_id)
if getattr(role, Permissions.UNLIMITED_VISIBILITY.value, False):
scan_queryset = Scan.objects.filter(tenant_id=request.tenant_id)
else:
scan_queryset = Scan.objects.filter(provider__in=get_providers(role))
try:
scan = scan_queryset.select_related("provider").get(id=scan_id)
except (Scan.DoesNotExist, DjangoValidationError, ValueError):
raise NotFound(detail=f"Scan '{scan_id}' not found.")
provider_type = scan.provider.provider
if compliance_id not in get_compliance_frameworks(provider_type):
raise NotFound(
detail=(
f"Compliance framework '{compliance_id}' is not "
f"available for scan '{scan_id}'."
)
)
# Fall back to the first provider that declares the framework. Keeps the
# endpoint working for provider-agnostic callers that omit the scan.
if not provider_type:
for pt in Provider.ProviderChoices.values:
if compliance_id in get_compliance_frameworks(pt):
+25
View File
@@ -1,6 +1,7 @@
import logging
import multiprocessing
import os
import threading
from config.env import env
@@ -11,6 +12,7 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.production")
import django # noqa: E402
django.setup()
from api.compliance import warm_compliance_caches # noqa: E402
from config.django.production import LOGGING as DJANGO_LOGGERS, DEBUG # noqa: E402
from config.custom_logging import BackendLogger # noqa: E402
@@ -41,3 +43,26 @@ def on_reload(_):
def when_ready(_):
gunicorn_logger.info("Gunicorn server is ready")
def _warm_compliance_caches_in_background():
"""Warm compliance caches off the request path and log the outcome."""
failed = warm_compliance_caches()
if failed:
gunicorn_logger.warning("Compliance caches warmed (skipped: %s)", failed)
else:
gunicorn_logger.info("Compliance caches warmed")
def post_fork(_server, worker):
"""Warm compliance caches after each worker fork.
Warm compliance caches in a background thread so the worker becomes ready
immediately. A request for a not-yet-warmed provider lazily loads just that
provider, which stays well under the worker timeout.
"""
threading.Thread(
target=_warm_compliance_caches_in_background,
name="warm-compliance-caches",
daemon=True,
).start()
+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),
],
}
+9
View File
@@ -269,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,
@@ -276,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
@@ -704,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
@@ -945,6 +953,7 @@ def _process_finding_micro_batch(
Resource.objects.bulk_update(
resources_to_bulk_update,
[
"name",
"metadata",
"details",
"partition",
+73
View File
@@ -315,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,
@@ -348,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"
@@ -366,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,
@@ -373,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
@@ -1565,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
):
+39
View File
@@ -0,0 +1,39 @@
[tool.towncrier]
directory = "changelog.d"
filename = "CHANGELOG.md"
start_string = "<!-- changelog: release notes start -->\n"
title_format = "## [{version}] ({name})"
issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})"
template = "../.github/towncrier/template.md.jinja"
underlines = ["", "", ""]
ignore = [".gitkeep", "README.md"]
[[tool.towncrier.type]]
directory = "added"
name = "🚀 Added"
showcontent = true
[[tool.towncrier.type]]
directory = "changed"
name = "🔄 Changed"
showcontent = true
[[tool.towncrier.type]]
directory = "deprecated"
name = "⚠️ Deprecated"
showcontent = true
[[tool.towncrier.type]]
directory = "removed"
name = "❌ Removed"
showcontent = true
[[tool.towncrier.type]]
directory = "fixed"
name = "🐞 Fixed"
showcontent = true
[[tool.towncrier.type]]
directory = "security"
name = "🔐 Security"
showcontent = true
Generated
+69 -3
View File
@@ -4415,8 +4415,8 @@ wheels = [
[[package]]
name = "prowler"
version = "5.27.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
version = "5.30.0"
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#f1d741214a60df17158c3fdc97804fd1fde64f3a" }
dependencies = [
{ name = "alibabacloud-actiontrail20200706" },
{ name = "alibabacloud-credentials" },
@@ -4489,9 +4489,14 @@ dependencies = [
{ name = "pygithub" },
{ name = "python-dateutil" },
{ name = "pytz" },
{ name = "scaleway" },
{ name = "schema" },
{ name = "shodan" },
{ name = "slack-sdk" },
{ name = "stackit-core" },
{ name = "stackit-iaas" },
{ name = "stackit-objectstorage" },
{ name = "stackit-resourcemanager" },
{ name = "tabulate" },
{ name = "tzlocal" },
{ name = "uuid6" },
@@ -4499,7 +4504,7 @@ dependencies = [
[[package]]
name = "prowler-api"
version = "1.31.0"
version = "1.32.0"
source = { virtual = "." }
dependencies = [
{ name = "cartography" },
@@ -5531,6 +5536,67 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
]
[[package]]
name = "stackit-core"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pydantic" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
]
[[package]]
name = "stackit-iaas"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
]
[[package]]
name = "stackit-objectstorage"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/80/b790756af40a5c6d979dd688b2557394ac54b594eb4c08edc33157ba890f/stackit_objectstorage-1.4.0.tar.gz", hash = "sha256:4a3812b4de102b199f061706a802909f9e53ae9b0858769d5bd720f814c8bdbe", size = 31814, upload-time = "2026-05-13T09:43:05.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/f1/ffa8d5e2ec9f818c72a6f045691364eb4e927ee86641993a70882d00205a/stackit_objectstorage-1.4.0-py3-none-any.whl", hash = "sha256:1a3285c6840d95cff591d84fd21803575cb0d010c398e6575ed92987b9c39866", size = 65061, upload-time = "2026-05-13T09:43:04.13Z" },
]
[[package]]
name = "stackit-resourcemanager"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "stackit-core" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
]
[[package]]
name = "statsd"
version = "4.0.1"
+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)
+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",
),
+1 -1
View File
@@ -3421,7 +3421,7 @@ Use existing providers as templates, this will help you to understand better the
- **Documentation & Maintenance**
- **README Updates**: Update provider-specific documentation
- **Changelog**: Document changes and new features
- **Changelog**: Document changes and new features with a fragment under `prowler/changelog.d/` (see the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md))
- **Examples**: Provide usage examples and common scenarios
- **Troubleshooting**: Include common issues and solutions
- **Documentation**: Update the provider documentation to include your new tool provider in the examples and implementation guidance.
@@ -599,7 +599,7 @@ Before opening the pull request:
uv run pre-commit run --all-files
uv run pytest -n auto
```
2. Add a changelog entry under the `### 🚀 Added` section of `prowler/CHANGELOG.md`, describing the new framework and the providers it covers.
2. Add a changelog fragment `prowler/changelog.d/<slug>.added.md`, describing the new framework and the providers it covers (no PR link in the text; it is attached automatically at release time).
3. Follow the [Pull Request Template](https://github.com/prowler-cloud/prowler/blob/master/.github/pull_request_template.md) and set the PR title using Conventional Commits, e.g. `feat(compliance): add My Framework 1.0 for AWS`.
4. Request review from the compliance codeowners listed in `.github/CODEOWNERS`.
@@ -128,8 +128,8 @@ To update the environment file:
Edit the `.env` file and change version values:
```env
PROWLER_UI_VERSION="5.29.0"
PROWLER_API_VERSION="5.29.0"
PROWLER_UI_VERSION="5.30.0"
PROWLER_API_VERSION="5.30.0"
```
<Note>
@@ -108,10 +108,10 @@ Prowler App updates user attributes each time a user logs in. Any changes made i
The `userType` attribute controls which Prowler role is assigned to the user:
- If `userType` matches an existing Prowler role name, the user receives that role automatically.
- If `userType` does not match any existing role, Prowler App creates a new role with that name **with read-only access** (visibility over all providers, no management permissions). A Prowler administrator can adjust its permissions afterward through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
- If `userType` is not set, the user's existing roles are left unchanged.
- If `userType` does not match any existing role, Prowler App creates a new role with that name **without permissions**.
- If `userType` is not set, the user receives the `no_permissions` role.
The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles.
In all cases where the resulting role has no permissions, a Prowler administrator must configure the appropriate permissions through the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab. The `userType` value is **case-sensitive** - for example, `Backend` and `backend` are treated as different roles.
</Warning>
@@ -223,9 +223,9 @@ To test the `userType` → role mapping, set the **Department** attribute in the
After a successful SSO login, the user profile in Prowler App reflects the attributes sent by Google Workspace:
- **Name**: Populated from the `firstName` and `lastName` attributes.
- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with read-only access by default.
- **Permissions**: If the assigned permissions need to be adjusted, a Prowler administrator can either:
- Edit the permissions of the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
- **Role**: Created automatically from the `userType` attribute (e.g., `Backend`). If the role did not exist previously, it is created with no permissions by default.
- **Permissions**: In the screenshot below, the user has no permissions because the `Backend` role did not exist prior to login and was created automatically without any permissions. To resolve this, a Prowler administrator can either:
- Assign the appropriate permissions to the new role via the [RBAC Management](/user-guide/tutorials/prowler-app-rbac) tab.
- Set the `userType` attribute in the IdP to match an existing Prowler role that already has the desired permissions. The updated role is applied on the next SAML login.
For more details on role assignment behavior and attribute mapping, refer to the [SAML SSO Configuration](/user-guide/tutorials/prowler-app-sso#configure-attribute-mapping-in-the-idp) page.
@@ -87,7 +87,7 @@ Choose a Method:
|----------------|---------------------------------------------------------------------------------------------------------|----------|
| `firstName` | The user's first name. | Yes |
| `lastName` | The user's last name. | Yes |
| `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name with read-only access (visibility over all providers, no management permissions). If `userType` is not defined, the user's existing roles are left unchanged. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No |
| `userType` | Determines which Prowler role the user receives (e.g., `admin`, `auditor`). If a role with that name already exists, the user receives it automatically; if it does not exist, Prowler App creates a new role with that name without permissions. If `userType` is not defined, the user is assigned the `no_permissions` role. Role permissions can be edited in the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac). | No |
| `organization` | The user's company name. | No |
<Info>
@@ -140,7 +140,7 @@ Choose a Method:
![Okta User Profile — First Name and Last Name](/images/prowler-app/saml/okta-user-profile-name.png)
* **Organization** (`organization`): Maps to the company name displayed in Prowler App. This attribute is optional.
* **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive**: if it matches the exact name of an existing role in Prowler App the user receives that role; if no role with that name exists, a new one is created with read-only access.
* **User type** (`userType`): Determines the Prowler role assigned to the user. This attribute is **case-sensitive** and must match the exact name of an existing role in Prowler App.
![Okta User Profile — User Type and Organization](/images/prowler-app/saml/okta-user-profile-attributes.png)
@@ -152,10 +152,14 @@ Choose a Method:
The `userType` attribute controls which Prowler role is assigned to the user:
* If a role with the specified name already exists in Prowler App, the user automatically receives that role.
* If the role does not exist, Prowler App creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
* If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler App are left unchanged.
* If the role does not exist, Prowler App creates a new role with that exact name but without any permissions, preventing the user from performing any actions.
* If `userType` is not defined in the user's Okta profile, the user is assigned the `no_permissions` role.
**Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed.
In all cases where the resulting role has no permissions, a Prowler administrator (a user whose role includes the "Manage Account" permission) must configure the appropriate permissions through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
This behavior is intentional: by defaulting to no permissions, Prowler App ensures that a misconfiguration in Okta cannot inadvertently grant elevated access.
**Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` without permissions, and a Prowler administrator must configure the desired permissions for it.
</Warning>
+2
View File
@@ -2,6 +2,8 @@
All notable changes to the **Prowler MCP Server** are documented in this file.
<!-- changelog: release notes start -->
## [0.7.2] (Prowler v5.28.1)
### 🐞 Fixed
View File
+10
View File
@@ -0,0 +1,10 @@
# Changelog fragments
Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
- Filename: `<slug>.<type>.md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
- `<type>` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
+39
View File
@@ -0,0 +1,39 @@
[tool.towncrier]
directory = "changelog.d"
filename = "CHANGELOG.md"
start_string = "<!-- changelog: release notes start -->\n"
title_format = "## [{version}] ({name})"
issue_format = "[(#{issue})](https://github.com/prowler-cloud/prowler/pull/{issue})"
template = "../.github/towncrier/template.md.jinja"
underlines = ["", "", ""]
ignore = [".gitkeep", "README.md"]
[[tool.towncrier.type]]
directory = "added"
name = "🚀 Added"
showcontent = true
[[tool.towncrier.type]]
directory = "changed"
name = "🔄 Changed"
showcontent = true
[[tool.towncrier.type]]
directory = "deprecated"
name = "⚠️ Deprecated"
showcontent = true
[[tool.towncrier.type]]
directory = "removed"
name = "❌ Removed"
showcontent = true
[[tool.towncrier.type]]
directory = "fixed"
name = "🐞 Fixed"
showcontent = true
[[tool.towncrier.type]]
directory = "security"
name = "🔐 Security"
showcontent = true
+12 -5
View File
@@ -2,10 +2,13 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [5.30.0] (Prowler UNRELEASED)
<!-- changelog: release notes start -->
## [5.30.0] (Prowler v5.30.0)
### 🚀 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)
@@ -19,6 +22,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `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)
- STACKIT ObjectStorage service with Object Lock, default retention policy, and access key expiration checks [(#11397)](https://github.com/prowler-cloud/prowler/pull/11397)
### 🐞 Fixed
@@ -26,6 +31,12 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `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)
- AWS CloudWatch log metric filter checks now validate `filterPattern` clauses regardless of order [(#11345)](https://github.com/prowler-cloud/prowler/pull/11345)
- AWS `bedrock_api_key_no_long_term_credentials` now applies severity per finding (never-expires keys correctly flag as critical, no leak across findings) and aligns title and wording with AWS guidance to prefer short-term Bedrock API keys [(#11526)](https://github.com/prowler-cloud/prowler/pull/11526)
### 🔐 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) [(#11499)](https://github.com/prowler-cloud/prowler/pull/11499)
---
@@ -38,10 +49,6 @@ All notable changes to the **Prowler SDK** are documented in this file.
- 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)
---
## [5.29.1] (Prowler v5.29.1)
+36 -2
View File
@@ -19,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,
@@ -102,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,
)
@@ -199,7 +202,7 @@ 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:
@@ -1314,6 +1317,33 @@ 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:
@@ -1446,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)
View File
+1
View File
@@ -0,0 +1 @@
`cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover
+1
View File
@@ -0,0 +1 @@
`cosmosdb_account_automatic_failover_enabled` check for Azure provider
+1
View File
@@ -0,0 +1 @@
`sagemaker_clarify_exists` check for AWS provider
+1
View File
@@ -0,0 +1 @@
`config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions
+1
View File
@@ -0,0 +1 @@
`securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on
+1
View File
@@ -0,0 +1 @@
`identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions
+10
View File
@@ -0,0 +1,10 @@
# Changelog fragments
Each PR adds one small file here instead of editing `CHANGELOG.md` directly, so concurrent PRs never conflict.
- Filename: `<slug>.<type>.md`, e.g. `my-new-check.added.md` (slug is free-form: letters, digits, `.`, `_`, `-`)
- `<type>` is one of: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`
- Content: one line with the changelog entry text, without the PR link and without a trailing period (the PR link is attached automatically at release time)
- A PR adds as many fragment files as entries it needs, freely mixing types (one file per entry); same-type entries just use different slugs
Fragments are compiled into `CHANGELOG.md` when a release is prepared. Full conventions: `skills/prowler-changelog/SKILL.md`.
@@ -1293,7 +1293,8 @@
"storage_ensure_private_endpoints_in_storage_accounts",
"storage_secure_transfer_required_is_enabled",
"vm_ensure_using_managed_disks",
"vm_trusted_launch_enabled"
"vm_trusted_launch_enabled",
"cosmosdb_account_automatic_failover_enabled"
]
},
{
+2 -1
View File
@@ -1087,7 +1087,8 @@
"storage_blob_versioning_is_enabled",
"storage_geo_redundant_enabled",
"vm_scaleset_associated_with_load_balancer",
"vm_scaleset_not_empty"
"vm_scaleset_not_empty",
"cosmosdb_account_automatic_failover_enabled"
],
"gcp": [
"compute_instance_automatic_restart_enabled",
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\"."
}
]
}
]
}
@@ -302,7 +302,9 @@
{
"Id": "1.15",
"Description": "Ensure storage service-level admins cannot delete resources they manage",
"Checks": [],
"Checks": [
"identity_storage_service_level_admins_scoped"
],
"Attributes": [
{
"Section": "1. Identity and Access Management",
+1 -1
View File
@@ -49,7 +49,7 @@ class _MutableTimestamp:
timestamp = _MutableTimestamp(datetime.today())
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
prowler_version = "5.30.0"
prowler_version = "5.31.0"
html_logo_url = "https://github.com/prowler-cloud/prowler/"
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
+39 -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,38 @@ 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}Continuous Security Monitoring{Style.RESET_ALL} - scheduled scans with history, trends and alerts.
{bar} {check} {Style.BRIGHT}Lighthouse AI + MCP{Style.RESET_ALL} - autonomous triage, custom dashboards, prioritization with prevention and remediation.
{bar} {check} {Style.BRIGHT}Alerts{Style.RESET_ALL} - get notified when anything you want is happening.
{bar} {check} {Style.BRIGHT}Live Compliance{Style.RESET_ALL} - dashboards for 50+ frameworks, always up to date.
{bar} {check} {Style.BRIGHT}Remediation{Style.RESET_ALL} - complete guided remediation including Autonomous remediation with Lighthouse AI.
{bar} {check} {Style.BRIGHT}Attack Path Visualization{Style.RESET_ALL} - see how attackers chain risks to reach your crown jewels.
{bar} {check} {Style.BRIGHT}Bulk Provisioning{Style.RESET_ALL} - add your entire AWS Organization in seconds.
{bar} {check} {Style.BRIGHT}Integrations{Style.RESET_ALL} - Anything with our MCP + Jira, Slack, AWS Security Hub, Amazon S3, SSO and RBAC.
{bar}
{bar} {Fore.BLUE}Start free at 👉 cloud.prowler.com{Style.RESET_ALL}
"""
)
+21
View File
@@ -283,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):
@@ -303,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,
]
@@ -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,6 +255,15 @@ def display_compliance_table(
output_directory,
compliance_overview,
)
elif compliance_framework.startswith("okta_idaas_stig"):
get_okta_idaas_stig_table(
findings,
bulk_checks_metadata,
compliance_framework,
output_filename,
output_directory,
compliance_overview,
)
else:
# Try provider-specific table first, fall back to generic
from prowler.providers.common.provider import Provider
@@ -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)
@@ -2582,6 +2582,7 @@
"aws": [
"af-south-1",
"ap-east-1",
"ap-east-2",
"ap-northeast-1",
"ap-northeast-2",
"ap-northeast-3",
@@ -2591,6 +2592,9 @@
"ap-southeast-2",
"ap-southeast-3",
"ap-southeast-4",
"ap-southeast-5",
"ap-southeast-6",
"ap-southeast-7",
"ca-central-1",
"ca-west-1",
"eu-central-1",
@@ -2604,6 +2608,7 @@
"il-central-1",
"me-central-1",
"me-south-1",
"mx-central-1",
"sa-east-1",
"us-east-1",
"us-east-2",
@@ -7344,6 +7349,7 @@
"lightsail": {
"regions": {
"aws": [
"ap-east-1",
"ap-northeast-1",
"ap-northeast-2",
"ap-south-1",
@@ -7354,9 +7360,11 @@
"ca-central-1",
"eu-central-1",
"eu-north-1",
"eu-south-2",
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2"
@@ -8269,7 +8277,9 @@
"cn-north-1",
"cn-northwest-1"
],
"aws-eusc": [],
"aws-eusc": [
"eusc-de-east-1"
],
"aws-us-gov": [
"us-gov-east-1",
"us-gov-west-1"
@@ -9220,6 +9230,7 @@
"eu-west-1",
"eu-west-2",
"eu-west-3",
"sa-east-1",
"us-east-1",
"us-east-2",
"us-west-2"
@@ -9986,6 +9997,8 @@
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-southeast-5",
"ap-southeast-7",
"ca-central-1",
"eu-central-1",
"eu-south-2",
@@ -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
@@ -1,7 +1,7 @@
{
"Provider": "aws",
"CheckID": "bedrock_api_key_no_long_term_credentials",
"CheckTitle": "Amazon Bedrock API key is expired",
"CheckTitle": "Amazon Bedrock long-term API key has expired",
"CheckType": [
"Software and Configuration Checks/AWS Security Best Practices",
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices",
@@ -14,23 +14,24 @@
"Severity": "high",
"ResourceType": "AwsIamUser",
"ResourceGroup": "IAM",
"Description": "**Bedrock API keys** are evaluated for **lifetime** and **expiration**.\n\nThe finding identifies keys that are long-lived, set to expire far in the future, or configured to `never expire`, and distinguishes them from keys that have already expired.",
"Risk": "Long-lived or non-expiring keys enable persistent access if compromised.\n- Confidentiality: unauthorized inference and exposure of prompts/outputs\n- Availability/Cost: uncontrolled usage and spend spikes\n- Integrity: actions can continue without timely revocation or rotation",
"Description": "AWS recommends Amazon Bedrock **long-term API keys** only for **exploration**; production workloads should use **short-term API keys** (session-scoped, valid up to **12 hours**). This check fails for any active long-term Bedrock API key, escalating to `critical` severity when configured to **never expire**. Already-expired keys pass — they can no longer authenticate.",
"Risk": "Long-term Bedrock API keys persist beyond a session until their stored expiration, and keys set to **never expire** grant indefinite access until manually revoked, enabling unauthorized inference, uncontrolled usage and spend, and activity that continues past timely revocation.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/getting-started-api-keys.html",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials",
"https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html"
"https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html",
"https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys-generate.html",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/security-creds-programmatic-access.html#security-creds-alternatives-to-long-term-access-keys",
"https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials"
],
"Remediation": {
"Code": {
"CLI": "aws iam delete-service-specific-credential --user-name <username> --service-specific-credential-id <credential-id>",
"NativeIaC": "",
"Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select <example_resource_name> > Security credentials\n3. In \"API keys for Amazon Bedrock\", find the non-expired key and click Delete\n4. Confirm deletion to remove the key (removes the long-term credential so the check passes)",
"Other": "1. Sign in to the AWS Management Console and open IAM\n2. Go to Users > select the IAM user backing the Bedrock API key > Security credentials\n3. In \"API keys for Amazon Bedrock\", select the active long-term key and click Delete\n4. For workloads that still need Bedrock access, generate a short-term API key from the Bedrock console (Short-term API keys tab), or call the Bedrock API with short-term credentials issued by AWS STS",
"Terraform": ""
},
"Recommendation": {
"Text": "Prefer **short-term credentials** and **IAM roles**; avoid `never expire`.\n\nEnforce **least privilege**, strict **rotation**, and automatic **expiration** for any long-term key. Store secrets securely, monitor with audit logs, and revoke unused or stale keys quickly.",
"Text": "Use short-term Amazon Bedrock API keys for any non-exploratory workload — they are bound to the IAM principal's session, valid for at most 12 hours, scoped to a single Region, and can be auto-refreshed by the SDK. For existing long-term keys, delete the underlying IAM service-specific credential. If a long-term key must be retained for an exploration scenario, set an explicit short expiration and never select `never expire`.",
"Url": "https://hub.prowler.com/check/bedrock_api_key_no_long_term_credentials"
}
},
@@ -40,5 +41,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check verifies that Amazon Bedrock API keys have expiration dates set. API keys without expiration dates are considered long-term credentials and pose a security risk. The check follows security best practices for credential management and the principle of least privilege."
"Notes": "AWS recommends against using long-term Amazon Bedrock API keys outside of exploration; production workloads should use short-term API keys (session-scoped, valid up to 12 hours). The IAM `ListServiceSpecificCredentials` API only enumerates long-term keys — short-term keys are session-scoped credentials that never appear here. The check therefore passes only when an existing long-term key has already expired and can no longer authenticate; any active long-term key fails, with critical severity when it is configured to never expire."
}
@@ -1,49 +1,62 @@
from datetime import datetime, timezone
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.lib.check.models import Check, Check_Report_AWS, Severity
from prowler.providers.aws.services.iam.iam_client import iam_client
# Days threshold above which a Bedrock long-term API key is considered effectively non-expiring.
NEVER_EXPIRES_THRESHOLD_DAYS = 10000
class bedrock_api_key_no_long_term_credentials(Check):
"""
Bedrock API keys should be short-lived to reduce the risk of unauthorized access.
This check verifies if there are any long-term Bedrock API keys.
If there are, it checks if they are expired or will be expired.
If they are expired, it will be marked as PASS.
If they are not expired, it will be marked as FAIL and the severity will be critical if the key will never expire.
"""Amazon Bedrock long-term API keys should not be used outside of exploration.
AWS recommends short-term Bedrock API keys (session-scoped, valid up to 12 hours)
for any non-exploratory workload. ``ListServiceSpecificCredentials`` only enumerates
long-term keys, so every key inspected here is by definition a long-term credential.
PASS when the long-term key has already expired (it can no longer authenticate).
FAIL (critical) when the key is configured to never expire.
FAIL (high) for any other active long-term key.
"""
def execute(self):
"""
Execute the Bedrock API key no long-term credentials check.
Iterate over all the Bedrock API keys and check if they are expired or will be expired.
Returns:
List[Check_Report_AWS]: A list of report objects with the results of the check.
"""
findings = []
for api_key in iam_client.service_specific_credentials:
if api_key.service_name != "bedrock.amazonaws.com":
continue
if api_key.expiration_date:
report = Check_Report_AWS(metadata=self.metadata(), resource=api_key)
# Check if the expiration date is in the future
if api_key.expiration_date > datetime.now(timezone.utc):
report.status = "FAIL"
# Get the days until the expiration date
days_until_expiration = (
api_key.expiration_date - datetime.now(timezone.utc)
).days
if days_until_expiration > 10000:
self.Severity = "critical"
report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and never expires."
else:
report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists and will expire in {days_until_expiration} days."
else:
report.status = "PASS"
report.status_extended = f"Long-term Bedrock API key {api_key.id} in user {api_key.user.name} exists but has expired."
findings.append(report)
if not api_key.expiration_date:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=api_key)
now = datetime.now(timezone.utc)
if api_key.expiration_date <= now:
report.status = "PASS"
report.status_extended = (
f"Bedrock long-term API key {api_key.id} in user "
f"{api_key.user.name} has already expired and can no longer "
f"authenticate."
)
elif (api_key.expiration_date - now).days > NEVER_EXPIRES_THRESHOLD_DAYS:
report.status = "FAIL"
report.check_metadata.Severity = Severity.critical
report.status_extended = (
f"Bedrock long-term API key {api_key.id} in user "
f"{api_key.user.name} is configured to never expire. Use "
f"short-term Bedrock API keys (session-scoped, valid up to "
f"12 hours) for non-exploratory workloads instead."
)
else:
days_until_expiration = (api_key.expiration_date - now).days
report.status = "FAIL"
report.status_extended = (
f"Bedrock long-term API key {api_key.id} in user "
f"{api_key.user.name} is active and will expire in "
f"{days_until_expiration} days. Use short-term Bedrock API "
f"keys (session-scoped, valid up to 12 hours) for "
f"non-exploratory workloads instead."
)
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] = []
@@ -17,9 +17,8 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html",
"https://www.clouddefense.ai/compliance-rules/cis-v130/monitoring/cis-v130-4-11",
"https://support.icompaas.com/support/solutions/articles/62000084031-ensure-a-log-metric-filter-and-alarm-exist-for-changes-to-network-access-control-lists-nacl-",
"https://trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html",
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/CloudWatchLogs/network-acl-changes-alarm.html",
"https://support.icompaas.com/support/solutions/articles/62000233134-4-11-ensure-network-access-control-list-nacl-changes-are-monitored-manual-"
],
"Remediation": {
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_acls_alarm_configured(Check):
def execute(self):
pattern = r"\$\.eventName\s*=\s*.?CreateNetworkAcl.+\$\.eventName\s*=\s*.?CreateNetworkAclEntry.+\$\.eventName\s*=\s*.?DeleteNetworkAcl.+\$\.eventName\s*=\s*.?DeleteNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclEntry.+\$\.eventName\s*=\s*.?ReplaceNetworkAclAssociation.?"
pattern = build_metric_filter_pattern(
event_names=[
"CreateNetworkAcl",
"CreateNetworkAclEntry",
"DeleteNetworkAcl",
"DeleteNetworkAclEntry",
"ReplaceNetworkAclEntry",
"ReplaceNetworkAclAssociation",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,16 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_gateways_alarm_configured(Check):
def execute(self):
pattern = r"\$\.eventName\s*=\s*.?CreateCustomerGateway.+\$\.eventName\s*=\s*.?DeleteCustomerGateway.+\$\.eventName\s*=\s*.?AttachInternetGateway.+\$\.eventName\s*=\s*.?CreateInternetGateway.+\$\.eventName\s*=\s*.?DeleteInternetGateway.+\$\.eventName\s*=\s*.?DetachInternetGateway.?"
pattern = build_metric_filter_pattern(
event_names=[
"CreateCustomerGateway",
"DeleteCustomerGateway",
"AttachInternetGateway",
"CreateInternetGateway",
"DeleteInternetGateway",
"DetachInternetGateway",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -37,5 +37,5 @@
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
"Notes": "Logging and Monitoring"
}
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,18 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_network_route_tables_alarm_configured(Check):
def execute(self):
pattern = r"\$\.eventSource\s*=\s*.?ec2.amazonaws.com.+\$\.eventName\s*=\s*.?CreateRoute.+\$\.eventName\s*=\s*.?CreateRouteTable.+\$\.eventName\s*=\s*.?ReplaceRoute.+\$\.eventName\s*=\s*.?ReplaceRouteTableAssociation.+\$\.eventName\s*=\s*.?DeleteRouteTable.+\$\.eventName\s*=\s*.?DeleteRoute.+\$\.eventName\s*=\s*.?DisassociateRouteTable.?"
pattern = build_metric_filter_pattern(
event_source="ec2.amazonaws.com",
event_names=[
"CreateRoute",
"CreateRouteTable",
"ReplaceRoute",
"ReplaceRouteTableAssociation",
"DeleteRouteTable",
"DeleteRoute",
"DisassociateRouteTable",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,21 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_changes_to_vpcs_alarm_configured(Check):
def execute(self):
pattern = r"\$\.eventName\s*=\s*.?CreateVpc.+\$\.eventName\s*=\s*.?DeleteVpc.+\$\.eventName\s*=\s*.?ModifyVpcAttribute.+\$\.eventName\s*=\s*.?AcceptVpcPeeringConnection.+\$\.eventName\s*=\s*.?CreateVpcPeeringConnection.+\$\.eventName\s*=\s*.?DeleteVpcPeeringConnection.+\$\.eventName\s*=\s*.?RejectVpcPeeringConnection.+\$\.eventName\s*=\s*.?AttachClassicLinkVpc.+\$\.eventName\s*=\s*.?DetachClassicLinkVpc.+\$\.eventName\s*=\s*.?DisableVpcClassicLink.+\$\.eventName\s*=\s*.?EnableVpcClassicLink.?"
pattern = build_metric_filter_pattern(
event_names=[
"CreateVpc",
"DeleteVpc",
"ModifyVpcAttribute",
"AcceptVpcPeeringConnection",
"CreateVpcPeeringConnection",
"DeleteVpcPeeringConnection",
"RejectVpcPeeringConnection",
"AttachClassicLinkVpc",
"DetachClassicLinkVpc",
"DisableVpcClassicLink",
"EnableVpcClassicLink",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_aws_config_configuration_change
Check
):
def execute(self):
pattern = r"\$\.eventSource\s*=\s*.?config.amazonaws.com.+\$\.eventName\s*=\s*.?StopConfigurationRecorder.+\$\.eventName\s*=\s*.?DeleteDeliveryChannel.+\$\.eventName\s*=\s*.?PutDeliveryChannel.+\$\.eventName\s*=\s*.?PutConfigurationRecorder.?"
pattern = build_metric_filter_pattern(
event_source="config.amazonaws.com",
event_names=[
"StopConfigurationRecorder",
"DeleteDeliveryChannel",
"PutDeliveryChannel",
"PutConfigurationRecorder",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -15,7 +16,15 @@ class cloudwatch_log_metric_filter_and_alarm_for_cloudtrail_configuration_change
Check
):
def execute(self):
pattern = r"\$\.eventName\s*=\s*.?CreateTrail.+\$\.eventName\s*=\s*.?UpdateTrail.+\$\.eventName\s*=\s*.?DeleteTrail.+\$\.eventName\s*=\s*.?StartLogging.+\$\.eventName\s*=\s*.?StopLogging.?"
pattern = build_metric_filter_pattern(
event_names=[
"CreateTrail",
"UpdateTrail",
"DeleteTrail",
"StartLogging",
"StopLogging",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_authentication_failures(Check):
def execute(self):
pattern = r"\$\.eventName\s*=\s*.?ConsoleLogin.+\$\.errorMessage\s*=\s*.?Failed authentication.?"
pattern = build_metric_filter_pattern(
event_names=["ConsoleLogin"],
extra_clauses=[("errorMessage", "=", "Failed authentication")],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,32 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_aws_organizations_changes(Check):
def execute(self):
pattern = r"\$\.eventSource\s*=\s*.?organizations\.amazonaws\.com.+\$\.eventName\s*=\s*.?AcceptHandshake.+\$\.eventName\s*=\s*.?AttachPolicy.+\$\.eventName\s*=\s*.?CancelHandshake.+\$\.eventName\s*=\s*.?CreateAccount.+\$\.eventName\s*=\s*.?CreateOrganization.+\$\.eventName\s*=\s*.?CreateOrganizationalUnit.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeclineHandshake.+\$\.eventName\s*=\s*.?DeleteOrganization.+\$\.eventName\s*=\s*.?DeleteOrganizationalUnit.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?EnableAllFeatures.+\$\.eventName\s*=\s*.?EnablePolicyType.+\$\.eventName\s*=\s*.?InviteAccountToOrganization.+\$\.eventName\s*=\s*.?LeaveOrganization.+\$\.eventName\s*=\s*.?DetachPolicy.+\$\.eventName\s*=\s*.?DisablePolicyType.+\$\.eventName\s*=\s*.?MoveAccount.+\$\.eventName\s*=\s*.?RemoveAccountFromOrganization.+\$\.eventName\s*=\s*.?UpdateOrganizationalUnit.+\$\.eventName\s*=\s*.?UpdatePolicy.?"
pattern = build_metric_filter_pattern(
event_source="organizations.amazonaws.com",
event_names=[
"AcceptHandshake",
"AttachPolicy",
"CancelHandshake",
"CreateAccount",
"CreateOrganization",
"CreateOrganizationalUnit",
"CreatePolicy",
"DeclineHandshake",
"DeleteOrganization",
"DeleteOrganizationalUnit",
"DeletePolicy",
"EnableAllFeatures",
"EnablePolicyType",
"InviteAccountToOrganization",
"LeaveOrganization",
"DetachPolicy",
"DisablePolicyType",
"MoveAccount",
"RemoveAccountFromOrganization",
"UpdateOrganizationalUnit",
"UpdatePolicy",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,10 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_disable_or_scheduled_deletion_of_kms_cmk(Check):
def execute(self):
pattern = r"\$\.eventSource\s*=\s*.?kms.amazonaws.com.+\$\.eventName\s*=\s*.?DisableKey.+\$\.eventName\s*=\s*.?ScheduleKeyDeletion.?"
pattern = build_metric_filter_pattern(
event_source="kms.amazonaws.com",
event_names=["DisableKey", "ScheduleKeyDeletion"],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -17,8 +17,7 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html",
"https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes",
"https://www.tenable.com/audits/items/CIS_Amazon_Web_Services_Foundations_v5.0.0_L1.audit:8101350d6907e07863ac6748689b3e12"
"https://support.icompaas.com/support/solutions/articles/62000086674-ensure-a-log-metric-filter-and-alarm-exist-for-s3-bucket-policy-changes"
],
"Remediation": {
"Code": {
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,20 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_for_s3_bucket_policy_changes(Check):
def execute(self):
pattern = r"\$\.eventSource\s*=\s*.?s3.amazonaws.com.+\$\.eventName\s*=\s*.?PutBucketAcl.+\$\.eventName\s*=\s*.?PutBucketPolicy.+\$\.eventName\s*=\s*.?PutBucketCors.+\$\.eventName\s*=\s*.?PutBucketLifecycle.+\$\.eventName\s*=\s*.?PutBucketReplication.+\$\.eventName\s*=\s*.?DeleteBucketPolicy.+\$\.eventName\s*=\s*.?DeleteBucketCors.+\$\.eventName\s*=\s*.?DeleteBucketLifecycle.+\$\.eventName\s*=\s*.?DeleteBucketReplication.?"
pattern = build_metric_filter_pattern(
event_source="s3.amazonaws.com",
event_names=[
"PutBucketAcl",
"PutBucketPolicy",
"PutBucketCors",
"PutBucketLifecycle",
"PutBucketReplication",
"DeleteBucketPolicy",
"DeleteBucketCors",
"DeleteBucketLifecycle",
"DeleteBucketReplication",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(
@@ -17,7 +17,6 @@
"RelatedUrl": "",
"AdditionalURLs": [
"https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudwatch-alarms-for-cloudtrail.html",
"https://www.clouddefense.ai/compliance-rules/cis-v140/monitoring/cis-v140-4-4",
"https://www.intelligentdiscovery.io/controls/cloudwatch/cloudwatch-alarm-iam-policy-change"
],
"Remediation": {
@@ -6,6 +6,7 @@ from prowler.providers.aws.services.cloudwatch.cloudwatch_client import (
cloudwatch_client,
)
from prowler.providers.aws.services.cloudwatch.lib.metric_filters import (
build_metric_filter_pattern,
check_cloudwatch_log_metric_filter,
)
from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
@@ -13,7 +14,26 @@ from prowler.providers.aws.services.cloudwatch.logs_client import logs_client
class cloudwatch_log_metric_filter_policy_changes(Check):
def execute(self):
pattern = r"\$\.eventName\s*=\s*.?DeleteGroupPolicy.+\$\.eventName\s*=\s*.?DeleteRolePolicy.+\$\.eventName\s*=\s*.?DeleteUserPolicy.+\$\.eventName\s*=\s*.?PutGroupPolicy.+\$\.eventName\s*=\s*.?PutRolePolicy.+\$\.eventName\s*=\s*.?PutUserPolicy.+\$\.eventName\s*=\s*.?CreatePolicy.+\$\.eventName\s*=\s*.?DeletePolicy.+\$\.eventName\s*=\s*.?CreatePolicyVersion.+\$\.eventName\s*=\s*.?DeletePolicyVersion.+\$\.eventName\s*=\s*.?AttachRolePolicy.+\$\.eventName\s*=\s*.?DetachRolePolicy.+\$\.eventName\s*=\s*.?AttachUserPolicy.+\$\.eventName\s*=\s*.?DetachUserPolicy.+\$\.eventName\s*=\s*.?AttachGroupPolicy.+\$\.eventName\s*=\s*.?DetachGroupPolicy.?"
pattern = build_metric_filter_pattern(
event_names=[
"DeleteGroupPolicy",
"DeleteRolePolicy",
"DeleteUserPolicy",
"PutGroupPolicy",
"PutRolePolicy",
"PutUserPolicy",
"CreatePolicy",
"DeletePolicy",
"CreatePolicyVersion",
"DeletePolicyVersion",
"AttachRolePolicy",
"DetachRolePolicy",
"AttachUserPolicy",
"DetachUserPolicy",
"AttachGroupPolicy",
"DetachGroupPolicy",
],
)
findings = []
report = check_cloudwatch_log_metric_filter(

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