Compare commits

..
2 Commits
2520 changed files with 29143 additions and 201213 deletions

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "prowler-plugins",
"description": "Prowler Cloud security and compliance for Claude",
"description": "Prowler Cloud Security for Claude Code",
"owner": {
"name": "Prowler",
"email": "support@prowler.com"
@@ -9,7 +9,7 @@
{
"name": "prowler",
"source": "./claude_plugins/prowler",
"description": "Connect Claude to Prowler Cloud through the Prowler MCP server. Reads providers, scans, findings, compliance results and resources for AWS, Azure, GCP, Kubernetes, Microsoft 365, GitHub and the other providers listed at https://registry.prowler.com, and returns remediation guidance for failed checks. Writes are limited to Prowler Cloud itself: triggering scans, finding triage, mute rules and the mutelist, providers and integrations. Your cloud resources are not changed by the connector.",
"description": "Prowler for Claude Code — cloud security and compliance skills powered by the Prowler MCP server. Bundles compliance triage and remediation; more skills coming.",
"category": "security",
"homepage": "https://prowler.com"
}
+1 -17
View File
@@ -110,27 +110,11 @@ DJANGO_OUTPUT_S3_AWS_SECRET_ACCESS_KEY=""
DJANGO_OUTPUT_S3_AWS_SESSION_TOKEN=""
# The AWS region where your S3 bucket is located (e.g., "us-east-1")
# Required if the bucket uses SSE-KMS: download URLs are then signed with SigV4, which
# is scoped to this region, so it must match the bucket's
DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION=""
# The name of the S3 bucket where scan output should be stored
DJANGO_OUTPUT_S3_AWS_OUTPUT_BUCKET=""
# The storage endpoint the API and Celery workers use to upload and list scan output
# (e.g. "http://minio:9000"). Leave empty on AWS S3. Set it when scan output is stored on
# S3-compatible object storage such as MinIO instead of real S3.
# If set without DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL below, report download URLs are
# signed against this internal host, and a browser outside the container network cannot open them.
DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL=""
# The storage address the browser can reach, used only to sign report download URLs
# (e.g. "https://storage.example.com"). Leave empty on AWS S3. Set it when storage is
# only reachable inside the container network, such as MinIO on "http://minio:9000".
# The reverse proxy in front of it must forward the Host header unchanged: SigV4 signs
# Host, so rewriting it to the internal name invalidates the signature.
DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL=""
# Django settings
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,prowler-api
DJANGO_BIND_ADDRESS=0.0.0.0
@@ -174,7 +158,7 @@ SENTRY_RELEASE=local
# REO_DEV_CLIENT_ID=
#### Prowler release version ####
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.47.0
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.37.0
# Social login credentials
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
+20 -20
View File
@@ -1,28 +1,28 @@
# SDK
/* @prowler-cloud/engineering
/prowler/ @prowler-cloud/engineering
/tests/ @prowler-cloud/engineering
/dashboard/ @prowler-cloud/engineering
/docs/ @prowler-cloud/engineering
/examples/ @prowler-cloud/engineering
/util/ @prowler-cloud/engineering
/contrib/ @prowler-cloud/engineering
/permissions/ @prowler-cloud/engineering
/codecov.yml @prowler-cloud/engineering
/* @prowler-cloud/detection-remediation
/prowler/ @prowler-cloud/detection-remediation
/tests/ @prowler-cloud/detection-remediation
/dashboard/ @prowler-cloud/detection-remediation
/docs/ @prowler-cloud/detection-remediation
/examples/ @prowler-cloud/detection-remediation
/util/ @prowler-cloud/detection-remediation
/contrib/ @prowler-cloud/detection-remediation
/permissions/ @prowler-cloud/detection-remediation
/codecov.yml @prowler-cloud/detection-remediation @prowler-cloud/api
# API
/api/ @prowler-cloud/engineering
/api/ @prowler-cloud/api
# UI
/ui/ @prowler-cloud/engineering
/ui/ @prowler-cloud/ui
# AI
/mcp_server/ @prowler-cloud/engineering
/mcp_server/ @prowler-cloud/detection-remediation
# CI and infrastructure
/.github/ @prowler-cloud/engineering
/Makefile @prowler-cloud/engineering
/kubernetes/ @prowler-cloud/engineering
**/Dockerfile* @prowler-cloud/engineering
**/docker-compose*.yml @prowler-cloud/engineering
**/docker-compose*.yaml @prowler-cloud/engineering
# Platform
/.github/ @prowler-cloud/platform
/Makefile @prowler-cloud/platform
/kubernetes/ @prowler-cloud/platform
**/Dockerfile* @prowler-cloud/platform
**/docker-compose*.yml @prowler-cloud/platform
**/docker-compose*.yaml @prowler-cloud/platform
-6
View File
@@ -1,6 +0,0 @@
# Generated by gh-aw and marked DO NOT EDIT. It uses concurrency options newer than
# actionlint knows, so the findings are about actionlint's schema, not our workflows.
paths:
.github/workflows/**/*.lock.yml:
ignore:
- '.*'
-179
View File
@@ -1,179 +0,0 @@
name: 'Container Security Scan with Grype'
description: 'Scans container images for vulnerabilities using Grype and reports results'
author: 'Prowler'
inputs:
image-name:
description: 'Container image name to scan'
required: true
image-tag:
description: 'Container image tag to scan'
required: true
default: ${{ github.sha }}
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
required: false
default: 'high'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
default: 'true'
create-pr-comment:
description: 'Create a comment on the PR with scan results'
required: false
default: 'true'
artifact-retention-days:
description: 'Days to retain the Grype report artifact'
required: false
default: '2'
outputs:
critical-count:
description: 'Number of critical vulnerabilities found'
value: ${{ steps.security-check.outputs.critical }}
high-count:
description: 'Number of high vulnerabilities found'
value: ${{ steps.security-check.outputs.high }}
total-count:
description: 'Total number of vulnerabilities found'
value: ${{ steps.security-check.outputs.total }}
runs:
using: 'composite'
steps:
- name: Run Grype vulnerability scan (JSON)
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'json'
output-file: 'grype-report.json'
fail-build: 'false'
by-cve: 'true' # Report CVE ids rather than GHSA, so findings line up with Trivy's
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Run Grype vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7.4.0
with:
image: ${{ inputs.image-name }}:${{ inputs.image-tag }}
output-format: 'sarif'
output-file: 'grype-results.sarif'
fail-build: 'false'
severity-cutoff: 'high'
by-cve: 'true'
only-fixed: 'true' # A finding with no available fix is not actionable, so it must not gate
cache-db: 'true'
grype-version: 'v0.116.1'
- name: Upload Grype results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
uses: github/codeql-action/upload-sarif@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
with:
sarif_file: 'grype-results.sarif'
category: 'grype-container'
- name: Upload Grype report artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: grype-scan-report-${{ inputs.image-name }}-${{ inputs.image-tag }}
path: grype-report.json
retention-days: ${{ inputs.artifact-retention-days }}
- name: Generate security summary
id: security-check
shell: bash
run: |
CRITICAL=$(jq '[.matches[]? | select(.vulnerability.severity=="Critical")] | length' grype-report.json)
HIGH=$(jq '[.matches[]? | select(.vulnerability.severity=="High")] | length' grype-report.json)
TOTAL=$(jq '[.matches[]?] | length' grype-report.json)
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
echo "high=$HIGH" >> $GITHUB_OUTPUT
echo "total=$TOTAL" >> $GITHUB_OUTPUT
echo "### 🔎 Container Security Scan (Grype)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** \`${INPUTS_IMAGE_NAME}:${INPUTS_IMAGE_TAG}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 🔴 Critical: $CRITICAL" >> $GITHUB_STEP_SUMMARY
echo "- 🟠 High: $HIGH" >> $GITHUB_STEP_SUMMARY
echo "- **Total**: $TOTAL" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Reported alongside Trivy, not instead of it. Counts differ by design." >> $GITHUB_STEP_SUMMARY
env:
INPUTS_IMAGE_NAME: ${{ inputs.image-name }}
INPUTS_IMAGE_TAG: ${{ inputs.image-tag }}
# Before the gate, so the comment is there to explain a failure rather than absent because of it
- name: Comment scan results on PR
if: >-
inputs.create-pr-comment == 'true'
&& github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
IMAGE_NAME: ${{ inputs.image-name }}
GITHUB_SHA: ${{ inputs.image-tag }}
CUTOFF: ${{ inputs.fail-on-severity }}
with:
script: |
const comment = require('./.github/scripts/grype-pr-comment.js');
// Unique identifier to find our comment
const marker = `<!-- grype-scan-comment:${process.env.IMAGE_NAME} -->`;
const body = marker + '\n' + comment;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existingComment = comments.find(c => c.body?.includes(marker));
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body: body
});
console.log('✅ Updated existing Grype scan comment');
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body
});
console.log('✅ Created new Grype scan comment');
}
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
shell: bash
run: |
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["Critical"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["Critical","High"]'
fi
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .grype.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.matches[] | select(.vulnerability.severity | IN($severities[]))
| " \(.vulnerability.severity)\t\(.vulnerability.id)\t\(.artifact.name) \(.artifact.version)"' \
grype-report.json | sort -u
exit 1
fi
env:
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
@@ -46,17 +46,6 @@ runs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if grep -q "prowler-cloud/prowler" uv.lock; then
:
else
status=$?
if [ "$status" -ne 1 ]; then
echo "::error::grep failed reading uv.lock (exit code $status)."
exit "$status"
fi
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
exit 0
fi
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
@@ -77,17 +66,6 @@ runs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
if grep -q "prowler-cloud/prowler" uv.lock; then
:
else
status=$?
if [ "$status" -ne 1 ]; then
echo "::error::grep failed reading uv.lock (exit code $status)."
exit "$status"
fi
echo "No prowler-cloud/prowler entry in uv.lock, nothing to update."
exit 0
fi
LATEST_COMMIT=$(curl -sf --retry 3 --retry-all-errors --retry-delay 2 --retry-max-time 60 \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
+11 -34
View File
@@ -14,10 +14,10 @@ inputs:
description: 'Severities to scan for (comma-separated)'
required: false
default: 'CRITICAL,HIGH,MEDIUM,LOW'
fail-on-severity:
description: 'Fail the build on findings at this severity or above: critical, high, or none'
fail-on-critical:
description: 'Fail the build if critical vulnerabilities are found'
required: false
default: 'high'
default: 'false'
upload-sarif:
description: 'Upload results to GitHub Security tab'
required: false
@@ -62,12 +62,8 @@ runs:
severity: ${{ inputs.severity }}
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
version: 'v0.71.2'
- name: Run Trivy vulnerability scan (SARIF)
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
@@ -79,12 +75,8 @@ runs:
severity: 'CRITICAL,HIGH'
exit-code: '0'
scanners: 'vuln'
ignore-unfixed: 'true' # A finding with no available fix is not actionable, so it must not gate
timeout: '5m'
version: 'v0.74.0'
# Not trivyignores: that input drops the .yaml extension Trivy parses by.
env:
TRIVY_IGNOREFILE: '.trivyignore.yaml'
version: 'v0.71.2'
- name: Upload Trivy results to GitHub Security tab
if: inputs.upload-sarif == 'true' && github.event_name == 'push'
@@ -171,28 +163,13 @@ runs:
console.log('✅ Created new Trivy scan comment');
}
- name: Check for blocking vulnerabilities
if: inputs.fail-on-severity != 'none'
- name: Check for critical vulnerabilities
if: inputs.fail-on-critical == 'true' && steps.security-check.outputs.critical != '0'
shell: bash
run: |
if [ "$CUTOFF" = "critical" ]; then
BLOCKING=$CRITICAL
SEVERITIES='["CRITICAL"]'
else
BLOCKING=$((CRITICAL + HIGH))
SEVERITIES='["CRITICAL","HIGH"]'
fi
echo "::error::Found ${STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL} critical vulnerabilities"
echo "::warning::Please update packages or use a different base image"
exit 1
if [ "$BLOCKING" -gt 0 ]; then
echo "::error::Found $BLOCKING vulnerabilities at severity ${CUTOFF} or above ($CRITICAL critical, $HIGH high)"
echo "::warning::Update the package, or add it to .trivyignore.yaml with a reason if nothing can be done"
jq -r --argjson severities "$SEVERITIES" \
'.Results[]?.Vulnerabilities[]? | select(.Severity | IN($severities[]))
| " \(.Severity)\t\(.VulnerabilityID)\t\(.PkgName) \(.InstalledVersion)"' \
trivy-report.json | sort -u
exit 1
fi
env:
CUTOFF: ${{ inputs.fail-on-severity }}
CRITICAL: ${{ steps.security-check.outputs.critical }}
HIGH: ${{ steps.security-check.outputs.high }}
STEPS_SECURITY_CHECK_OUTPUTS_CRITICAL: ${{ steps.security-check.outputs.critical }}
+6 -6
View File
@@ -199,7 +199,7 @@ You MUST structure your response using this EXACT format. Do NOT include anythin
### For Check Logic Bug
```markdown
```
### AI Assessment [Experimental]: Check Logic Bug
**Component**: {component from issue template}
@@ -297,7 +297,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Bug (non-check)
```markdown
```
### AI Assessment [Experimental]: Bug
**Component**: {CLI/SDK | API | UI | Dashboard | MCP Server | Other}
@@ -378,7 +378,7 @@ Write tests FIRST (TDD). The skills contain all testing conventions and patterns
### For Already Fixed
```markdown
```
### AI Assessment [Experimental]: Already Fixed
**Component**: {component}
@@ -401,7 +401,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Feature Request
```markdown
```
### AI Assessment [Experimental]: Feature Request
**Component**: {component}
@@ -419,7 +419,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Not a Bug
```markdown
```
### AI Assessment [Experimental]: Not a Bug
**Component**: {component}
@@ -440,7 +440,7 @@ Upgrade to the latest version. Close the issue as resolved.
### For Needs More Information
```markdown
```
### AI Assessment [Experimental]: Needs More Information
**Component**: {component or "Unknown"}
-15
View File
@@ -52,16 +52,6 @@ provider/alibabacloud:
- any-glob-to-any-file: "prowler/providers/alibabacloud/**"
- any-glob-to-any-file: "tests/providers/alibabacloud/**"
provider/huaweicloud:
- changed-files:
- any-glob-to-any-file: "prowler/providers/huaweicloud/**"
- any-glob-to-any-file: "tests/providers/huaweicloud/**"
provider/image:
- changed-files:
- any-glob-to-any-file: "prowler/providers/image/**"
- any-glob-to-any-file: "tests/providers/image/**"
provider/cloudflare:
- changed-files:
- any-glob-to-any-file: "prowler/providers/cloudflare/**"
@@ -92,11 +82,6 @@ provider/linode:
- any-glob-to-any-file: "prowler/providers/linode/**"
- any-glob-to-any-file: "tests/providers/linode/**"
provider/stackit:
- changed-files:
- any-glob-to-any-file: "prowler/providers/stackit/**"
- any-glob-to-any-file: "tests/providers/stackit/**"
github_actions:
- changed-files:
- any-glob-to-any-file: ".github/workflows/*"
-100
View File
@@ -1,100 +0,0 @@
const fs = require('fs');
// Configuration from environment variables
const REPORT_FILE = process.env.GRYPE_REPORT_FILE || 'grype-report.json';
const IMAGE_NAME = process.env.IMAGE_NAME || 'container-image';
const GITHUB_SHA = process.env.GITHUB_SHA || 'unknown';
const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY || '';
const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID || '';
const CUTOFF = process.env.CUTOFF || 'high';
// A cutoff of 'critical' blocks only on critical; anything else blocks on high and above
const blocking = CUTOFF === 'critical' ? ['Critical'] : ['Critical', 'High'];
const report = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf-8'));
const matches = Array.isArray(report.matches) ? report.matches : [];
const ignored = Array.isArray(report.ignoredMatches) ? report.ignoredMatches : [];
const counts = { Critical: 0, High: 0, Medium: 0, Low: 0, Negligible: 0, Unknown: 0 };
const blockers = new Map();
for (const match of matches) {
const severity = match.vulnerability.severity;
if (counts[severity] !== undefined) {
counts[severity]++;
}
if (blocking.includes(severity)) {
const artifact = match.artifact;
const fixedIn = (match.vulnerability.fix && match.vulnerability.fix.versions || []).join(', ');
// Same CVE can match several install paths of one package; collapse them
blockers.set(`${match.vulnerability.id}|${artifact.name}`, {
id: match.vulnerability.id,
severity,
name: artifact.name,
version: artifact.version,
fixedIn
});
}
}
const ignoredBlocking = ignored.filter(m => blocking.includes(m.vulnerability.severity)).length;
const shortSha = GITHUB_SHA.substring(0, 7);
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const severityConfig = {
Critical: { icon: '🔴', label: 'Critical' },
High: { icon: '🟠', label: 'High' },
Medium: { icon: '🟡', label: 'Medium' },
Low: { icon: '🔵', label: 'Low' }
};
let comment = '## 🔎 Container Security Scan (Grype)\n\n';
comment += `**Image:** \`${IMAGE_NAME}:${shortSha}\`\n`;
comment += `**Last scan:** ${timestamp}\n\n`;
if (blockers.size === 0) {
comment += '### ✅ Nothing Blocking\n\n';
comment += `No findings at **${blocking.join(' or ').toLowerCase()}** severity.\n`;
} else {
comment += `### ⚠️ ${blockers.size} Finding(s) Blocking This PR\n\n`;
comment += '| Severity | CVE | Package | Installed | Fixed in |\n';
comment += '|---|---|---|---|---|\n';
const order = { Critical: 0, High: 1 };
const rows = [...blockers.values()].sort((a, b) =>
(order[a.severity] - order[b.severity]) || a.name.localeCompare(b.name));
for (const row of rows) {
const config = severityConfig[row.severity];
comment += `| ${config.icon} ${config.label} | \`${row.id}\` | \`${row.name}\` | ${row.version} | ${row.fixedIn || '—'} |\n`;
}
comment += '\n**What to do:**\n';
comment += '- Upgrade the package to the version in the "Fixed in" column.\n';
comment += '- If it is pinned by another dependency, or the fix is otherwise out of reach, add it to `.grype.yaml` **with the reason**.\n';
comment += '- Findings with no published fix never appear here: the scan runs with `only-fixed`, so it reports only what can actually be acted on.\n';
}
const otherCounts = Object.entries(counts)
.filter(([severity, count]) => !blocking.includes(severity) && count > 0)
.map(([severity, count]) => `${severity.toLowerCase()}: ${count}`);
if (otherCounts.length > 0) {
comment += `\nNot blocking at this cutoff — ${otherCounts.join(', ')}.\n`;
}
if (ignoredBlocking > 0) {
comment += `\n${ignoredBlocking} finding(s) excluded by \`.grype.yaml\`, each with a documented reason.\n`;
}
comment += '\n---\n';
comment += '📋 **Resources:**\n';
if (GITHUB_REPOSITORY && GITHUB_RUN_ID) {
comment += `- [Download full report](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}) (see artifacts)\n`;
}
comment += '- [View in Security tab](https://github.com/' + (GITHUB_REPOSITORY || 'repository') + '/security/code-scanning)\n';
comment += '- Scanned with [Grype](https://github.com/anchore/grype), alongside Trivy\n';
module.exports = comment;
+5 -5
View File
@@ -8,11 +8,11 @@ These JSON templates are used with the `slackapi/slack-github-action` using the
### Available Templates
#### Container Releases
**Container Releases**
- `container-release-started.json`: Simple one-line notification when container push starts
- `container-release-completed.json`: Simple one-line notification when container release completes
#### Deployments
**Deployments**
- `deployment-started.json`: Deployment start notification with Block Kit formatting
- `deployment-completed.json`: Deployment completion notification (updates the start message)
@@ -416,17 +416,17 @@ For deployments that start with one message and update it with the final status:
### Container Release (Simple One-Line)
**Start message:**
```text
```
API container release 4.5.0 push started... View run
```
**Completion message (success):**
```text
```
[✓] API container release 4.5.0 push completed successfully! View run
```
**Completion message (failure):**
```text
```
[✗] API container release 4.5.0 push failed View run
```
-11
View File
@@ -451,17 +451,6 @@ modules:
e2e:
- ui/tests/home/**
- name: ui-registry
match:
- ui/actions/registry/**
- ui/app/**/registry/**
- ui/components/registry/**
- ui/lib/registry/**
- ui/tests/registry/**
tests: []
e2e:
- ui/tests/registry/**
- name: ui-shadcn
match:
- ui/components/shadcn/**
-1
View File
@@ -42,7 +42,6 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
api.github.com:443
raw.githubusercontent.com:443
+12 -26
View File
@@ -42,7 +42,6 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
@@ -53,9 +52,7 @@ jobs:
- name: Calculate short SHA
id: set-short-sha
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -162,20 +159,9 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler Local Server API
org.opencontainers.image.description=API for Prowler Local Server (Django/DRF)
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -193,12 +179,12 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
auth.docker.io:443
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
@@ -210,9 +196,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -220,10 +206,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -263,9 +249,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> "$GITHUB_OUTPUT"
echo "outcome=success" >> $GITHUB_OUTPUT
else
echo "outcome=failure" >> "$GITHUB_OUTPUT"
echo "outcome=failure" >> $GITHUB_OUTPUT
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+2 -19
View File
@@ -81,11 +81,6 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -112,11 +107,7 @@ jobs:
with:
files: |
api/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
api/docs/**
api/README.md
@@ -154,13 +145,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
fail-on-critical: 'true'
severity: 'CRITICAL'
-1
View File
@@ -85,7 +85,6 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
cli.codecov.io:443
keybase.io:443
raw.githubusercontent.com:443
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
- name: Check labels
id: label_check
uses: agilepathway/label-checker@c324842522fbd012e4f590afe3b4e591301322ed # v1.6.66
uses: agilepathway/label-checker@c3d16ad512e7cea5961df85ff2486bb774caf3c5 # v1.6.65
with:
allow_failure: true
prefix_mode: true
-63
View File
@@ -1,63 +0,0 @@
name: 'CI: Actionlint'
on:
push:
branches:
- 'master'
pull_request:
branches:
- 'master'
schedule:
- cron: '45 06 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
actionlint:
if: github.repository == 'prowler-cloud/prowler'
name: GitHub Actions Schema Check
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
auth.docker.io:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
- name: Checkout repository
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
# Always runs so it always reports; the lint is skipped when nothing changed.
- name: Check for workflow changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: .github/**
# SC2129 is style only: it suggests grouping consecutive redirects.
- name: Run actionlint
if: steps.check-changes.outputs.any_changed == 'true'
env:
SHELLCHECK_OPTS: '-e SC2129'
run: |
docker run --rm -v "$PWD:/repo" --workdir /repo -e SHELLCHECK_OPTS \
rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 \
-color
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Removing 'status/awaiting-response' label from #$ISSUE_NUMBER"
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response" \
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels/status%2Fawaiting-response \
-X DELETE
- name: Add 'status/waiting-for-revision' label
@@ -41,6 +41,6 @@ jobs:
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
echo "Adding 'status/waiting-for-revision' label to #$ISSUE_NUMBER"
gh api "/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels" \
gh api /repos/${{ github.repository }}/issues/$ISSUE_NUMBER/labels \
-X POST \
-f labels[]='status/waiting-for-revision'
-1
View File
@@ -61,7 +61,6 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -1,92 +0,0 @@
name: 'Tools: Sync Docker Hub Descriptions'
on:
push:
branches:
- 'master'
paths:
- 'docs/dockerhub/README.md'
- '.github/workflows/dockerhub-descriptions.yml'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
env:
OVERVIEW_FILE: docs/dockerhub/README.md
permissions: {}
jobs:
prowlercloud:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- repository: prowlercloud/prowler
short_description: 'Prowler CLI: the Open Cloud Security tool for AWS, Azure, Google Cloud, Kubernetes, M365 and GitHub'
- repository: prowlercloud/prowler-api
short_description: 'Prowler Local Server - API: the JSON API and Task Runner components of Prowler'
- repository: prowlercloud/prowler-ui
short_description: 'Prowler Local Server - UI: the web interface to run Prowler scans and explore findings'
- repository: prowlercloud/prowler-mcp
short_description: 'Prowler MCP: the interface for agents, including IDE plugins and agent integrations'
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for ${{ matrix.repository }}
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: ${{ matrix.repository }}
short-description: ${{ matrix.short_description }}
readme-filepath: ${{ env.OVERVIEW_FILE }}
toniblyx:
if: github.repository == 'prowler-cloud/prowler' && github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
hub.docker.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Update Docker Hub description for toniblyx/prowler
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
with:
username: ${{ secrets.TONIBLYX_DOCKERHUB_USERNAME }}
password: ${{ secrets.TONIBLYX_DOCKERHUB_PASSWORD }}
repository: toniblyx/prowler
short-description: 'Prowler CLI (legacy repository, mirrors prowlercloud/prowler)'
readme-filepath: ${{ env.OVERVIEW_FILE }}
+4 -7
View File
@@ -38,16 +38,13 @@ jobs:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Full history: a shallow clone cuts the PR's ancestry, git can no longer exclude commits
# already on the base branch, and TruffleHog scans the cut-off commit as a root (the whole
# tree as added). PRs that merge master in hit this and fail on pre-existing content.
fetch-depth: 0
# PRs only need the diff range; push to master/release walks the new range from event.before.
# 50 is enough headroom for the longest realistic PR/push chain without paying for a full clone.
fetch-depth: 50
persist-credentials: false
- name: Scan diff for secrets with TruffleHog
# Action auto-injects --since-commit/--branch from event payload; passing them in extra_args produces duplicate flags.
uses: trufflesecurity/trufflehog@e76dff8a7e57d6df0c60ad8cbf5d5cba505a8fc9 # v3.99.2
uses: trufflesecurity/trufflehog@37b77001d0174ebec2fcca2bd83ff83a6d45a3ab # v3.95.3
with:
# The action runs the `latest` image unless told otherwise: pin the scanner to the action's release.
version: 3.99.2@sha256:47a84bc18a0d04a165498bbbd3bacfd84176661cbc91f8e0f85467f6a771e99a
extra_args: --results=verified,unknown
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${GITHUB_ACTOR} --password-stdin
- name: Update chart dependencies
run: helm dependency update ${{ env.CHART_PATH }}
+2 -2
View File
@@ -85,10 +85,10 @@ jobs:
# Check if author is in the org members list
if printf '%s\n' "${ORG_MEMBERS[@]}" | grep -q "^${AUTHOR}$"; then
echo "is_member=true" >> "$GITHUB_OUTPUT"
echo "is_member=true" >> $GITHUB_OUTPUT
echo "$AUTHOR is an organization member"
else
echo "is_member=false" >> "$GITHUB_OUTPUT"
echo "is_member=false" >> $GITHUB_OUTPUT
echo "$AUTHOR is not an organization member"
fi
+1 -5
View File
@@ -55,10 +55,6 @@ jobs:
# Pin must match .pre-commit-config.yaml so prek and CI behave identically.
# pnpm dlx doesn't accept --ignore-scripts as a flag; the env var
# disables postinstall scripts on transitives the same way.
#
# Files come from `git ls-files` because markdownlint doesn't traverse
# dot-directories, so `.github/**/*.md` went unlinted.
# `.markdownlintignore` still applies to the listed paths.
env:
pnpm_config_ignore_scripts: 'true'
run: git ls-files -z '*.md' | xargs -0 -r pnpm dlx markdownlint-cli@0.45.0 --
run: pnpm dlx markdownlint-cli@0.45.0 '**/*.md'
+19 -27
View File
@@ -41,7 +41,6 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
@@ -52,9 +51,7 @@ jobs:
- name: Calculate short SHA
id: set-short-sha
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -113,17 +110,15 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
auth.docker.io:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
files.pythonhosted.org:443
ghcr.io:443
github.com:443
pkg-containers.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
registry-1.docker.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -146,20 +141,17 @@ jobs:
with:
context: ${{ env.WORKING_DIRECTORY }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler MCP
org.opencontainers.image.title=Prowler MCP Server
org.opencontainers.image.description=Model Context Protocol server for Prowler
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
org.opencontainers.image.created=${{ github.event_name == 'release' && github.event.release.published_at || github.event.head_commit.timestamp }}
${{ github.event_name == 'release' && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -177,11 +169,11 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
auth.docker.io:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
github.com:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
@@ -195,9 +187,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -205,10 +197,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -248,9 +240,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> "$GITHUB_OUTPUT"
echo "outcome=success" >> $GITHUB_OUTPUT
else
echo "outcome=failure" >> "$GITHUB_OUTPUT"
echo "outcome=failure" >> $GITHUB_OUTPUT
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+2 -20
View File
@@ -77,22 +77,16 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
ghcr.io:443
pkg-containers.githubusercontent.com:443
files.pythonhosted.org:443
pypi.org:443
dl-cdn.alpinelinux.org:443
dualstack.j.sni.global.fastly.net:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -106,11 +100,7 @@ jobs:
with:
files: |
mcp_server/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
@@ -137,13 +127,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan MCP container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
fail-on-critical: 'true'
severity: 'CRITICAL'
+1 -1
View File
@@ -113,7 +113,7 @@ jobs:
- name: Publish prowler-mcp package to PyPI
if: steps.pypi-check.outputs.skip != 'true'
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
packages-dir: ${{ env.WORKING_DIRECTORY }}/dist/
print-hash: true
-100
View File
@@ -1,100 +0,0 @@
name: 'MCP: Tests'
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
MCP_WORKING_DIR: ./mcp_server
permissions: {}
jobs:
mcp-tests:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
matrix:
# requires-python is >=3.12 while the shipped image is 3.13; testing both
# is what keeps that floor honest.
python-version:
- '3.12'
- '3.13'
defaults:
run:
working-directory: ./mcp_server
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
# hub.prowler.com and raw.githubusercontent.com are deliberately absent:
# the suite mocks every outbound call, so a real one must fail the job.
# The sentry.io entry is not the test suite: the Codecov uploader sends
# its own telemetry there, so api-tests.yml and sdk-tests.yml allow it too.
allowed-endpoints: >
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
cli.codecov.io:443
keybase.io:443
ingest.codecov.io:443
o26192.ingest.us.sentry.io:443
storage.googleapis.com:443
api.github.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Check for MCP server changes
id: check-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
mcp_server/**
.github/workflows/mcp-tests.yml
codecov.yml
files_ignore: |
mcp_server/README.md
mcp_server/CHANGELOG.md
mcp_server/changelog.d/**
mcp_server/AGENTS.md
mcp_server/Dockerfile
mcp_server/.dockerignore
mcp_server/entrypoint.sh
- name: Setup Python with uv
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
working-directory: ./mcp_server
- name: Run tests with pytest
if: steps.check-changes.outputs.any_changed == 'true'
run: uv run pytest --cov=./prowler_mcp_server --cov-report=xml tests
- name: Upload coverage reports to Codecov
if: steps.check-changes.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: mcp
+1 -1
View File
@@ -35,7 +35,6 @@ jobs:
api.github.com:443
github.com:443
objects.githubusercontent.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -130,6 +129,7 @@ jobs:
handwritten_changelogs=""
all_changed=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n')
added=$(echo "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" | tr ' ' '\n')
added_or_renamed=$(printf '%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
added_modified_or_renamed=$(printf '%s\n%s\n%s' "${STEPS_CHANGED_FILES_OUTPUTS_ADDED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_MODIFIED_FILES}" "${STEPS_CHANGED_FILES_OUTPUTS_RENAMED_FILES}" | tr ' ' '\n')
@@ -111,7 +111,7 @@ jobs:
done
if [ -n "$found_in" ]; then
found_in="${found_in%, }"
found_in=$(echo "$found_in" | sed 's/, $//')
MAPPED="${MAPPED}- \`${check_id}\` (\`${provider}\`): ${found_in}"$'\n'
else
UNMAPPED="${UNMAPPED}- \`${check_id}\` (\`${provider}\`)"$'\n'
+3 -3
View File
@@ -74,15 +74,15 @@ jobs:
done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES"
if [ "$HAS_CONFLICTS" = true ]; then
echo "has_conflicts=true" >> "$GITHUB_OUTPUT"
echo "has_conflicts=true" >> $GITHUB_OUTPUT
{
echo "conflict_files<<EOF"
echo "$CONFLICT_FILES"
echo "EOF"
} >> "$GITHUB_OUTPUT"
} >> $GITHUB_OUTPUT
echo "Conflict markers detected"
else
echo "has_conflicts=false" >> "$GITHUB_OUTPUT"
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "No conflict markers found in changed files"
fi
env:
+4 -5
View File
@@ -36,7 +36,7 @@ jobs:
id: vars
run: |
SHORT_SHA="${GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA}"
echo "short_sha=${SHORT_SHA::7}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA::7}" >> $GITHUB_OUTPUT
env:
GITHUB_EVENT_PULL_REQUEST_MERGE_COMMIT_SHA: ${{ github.event.pull_request.merge_commit_sha }}
@@ -46,7 +46,6 @@ jobs:
token: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
repository: ${{ secrets.CLOUD_DISPATCH }}
event-type: prowler-pull-request-merged
# repository_dispatch caps client_payload at 10 properties; this is exactly at the cap.
client-payload: |
{
"PROWLER_COMMIT_SHA": "${{ github.event.pull_request.merge_commit_sha }}",
@@ -55,8 +54,8 @@ jobs:
"PROWLER_PR_TITLE": ${{ toJson(github.event.pull_request.title) }},
"PROWLER_PR_LABELS": ${{ toJson(github.event.pull_request.labels.*.name) }},
"PROWLER_PR_BODY": ${{ toJson(github.event.pull_request.body) }},
"PROWLER_PR_URL": ${{ toJson(github.event.pull_request.html_url) }},
"PROWLER_PR_MERGED_BY": "${{ github.event.pull_request.merged_by.login }}",
"PROWLER_PR_STACK_NUMBER": "${{ github.event.pull_request.stack.number }}",
"PROWLER_PR_STACK_POSITION": "${{ github.event.pull_request.stack.position }}",
"PROWLER_PR_STACK_SIZE": "${{ github.event.pull_request.stack.size }}"
"PROWLER_PR_BASE_BRANCH": ${{ toJson(github.event.pull_request.base.ref) }},
"PROWLER_PR_HEAD_BRANCH": ${{ toJson(github.event.pull_request.head.ref) }}
}
+13 -40
View File
@@ -77,15 +77,13 @@ jobs:
echo "Prowler version: $PROWLER_VERSION"
echo "Branch name: $BRANCH_NAME"
echo "Is minor release: $([ "$PATCH_VERSION" -eq 0 ] && echo 'true' || echo 'false')"
echo "Is minor release: $([ $PATCH_VERSION -eq 0 ] && echo 'true' || echo 'false')"
else
echo "Invalid version syntax: '$PROWLER_VERSION' (must be N.N.N)" >&2
exit 1
fi
- name: Checkout release branch
env:
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
run: |
echo "Checking out branch $BRANCH_NAME for release $PROWLER_VERSION..."
if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then
@@ -94,33 +92,8 @@ jobs:
elif git show-ref --verify --quiet "refs/remotes/origin/$BRANCH_NAME"; then
echo "Branch $BRANCH_NAME exists remotely, checking out..."
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
elif [ "$PATCH_VERSION" -eq 0 ]; then
# Only cut the branch once the compiled changelogs are on master
MASTER_SHA=$(git rev-parse origin/master)
VERSION_REGEX="${PROWLER_VERSION//./\\.}"
FOUND_CHANGELOG=false
for changelog in prowler/CHANGELOG.md api/CHANGELOG.md ui/CHANGELOG.md mcp_server/CHANGELOG.md; do
if git show "origin/master:$changelog" 2>/dev/null | grep -qE "^## \[.*\(Prowler v?${VERSION_REGEX}\)"; then
FOUND_CHANGELOG=true
break
fi
done
if [ "$FOUND_CHANGELOG" != "true" ]; then
echo "ERROR: No changelog entry for Prowler $PROWLER_VERSION on master. Merge the compiled changelogs PR first."
exit 1
fi
# Check the SDK version on master before creating the remote branch
if ! git show "origin/master:pyproject.toml" | grep -qE "^version = \"${VERSION_REGEX}\"$" \
|| ! git show "origin/master:prowler/config/config.py" | grep -qE "^prowler_version = \"${VERSION_REGEX}\"$"; then
echo "ERROR: pyproject.toml or prowler/config/config.py on master is not at version $PROWLER_VERSION. Fix master before creating $BRANCH_NAME."
exit 1
fi
echo "Branch $BRANCH_NAME does not exist, creating it from master ($MASTER_SHA)..."
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" -f ref="refs/heads/${BRANCH_NAME}" -f sha="${MASTER_SHA}"
git update-ref "refs/remotes/origin/${BRANCH_NAME}" "$MASTER_SHA"
git checkout -b "$BRANCH_NAME" "origin/$BRANCH_NAME"
else
echo "ERROR: Branch $BRANCH_NAME does not exist. For patch releases (X.Y.Z), the branch should already exist."
echo "ERROR: Branch $BRANCH_NAME does not exist. For minor releases (X.Y.0), create it manually first. For patch releases (X.Y.Z), the branch should already exist."
exit 1
fi
@@ -134,8 +107,7 @@ jobs:
if [ -f "$changelog_file" ]; then
# Extract version that matches this Prowler release
# Format: ## [version] (Prowler X.Y.Z) or ## [vversion] (Prowler vX.Y.Z)
local version
version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
local version=$(grep '^## \[' "$changelog_file" | grep "(Prowler v\?${prowler_version})" | head -1 | sed 's/^## \[\(.*\)\].*/\1/' | sed 's/^v//' | tr -d '[:space:]')
echo "$version"
else
echo ""
@@ -206,55 +178,55 @@ jobs:
# Determine if components have changes for this specific release
if [ -n "$SDK_VERSION" ]; then
echo "HAS_SDK_CHANGES=true" >> "$GITHUB_ENV"
echo "HAS_SDK_CHANGES=true" >> $GITHUB_ENV
HAS_SDK_CHANGES="true"
echo "✓ SDK changes detected - version: $SDK_VERSION"
extract_changelog "prowler/CHANGELOG.md" "$SDK_VERSION" "prowler_changelog.md"
else
echo "HAS_SDK_CHANGES=false" >> "$GITHUB_ENV"
echo "HAS_SDK_CHANGES=false" >> $GITHUB_ENV
HAS_SDK_CHANGES="false"
echo "ℹ No SDK changes for this release"
touch "prowler_changelog.md"
fi
if [ -n "$API_VERSION" ]; then
echo "HAS_API_CHANGES=true" >> "$GITHUB_ENV"
echo "HAS_API_CHANGES=true" >> $GITHUB_ENV
HAS_API_CHANGES="true"
echo "✓ API changes detected - version: $API_VERSION"
extract_changelog "api/CHANGELOG.md" "$API_VERSION" "api_changelog.md"
else
echo "HAS_API_CHANGES=false" >> "$GITHUB_ENV"
echo "HAS_API_CHANGES=false" >> $GITHUB_ENV
HAS_API_CHANGES="false"
echo "ℹ No API changes for this release"
touch "api_changelog.md"
fi
if [ -n "$UI_VERSION" ]; then
echo "HAS_UI_CHANGES=true" >> "$GITHUB_ENV"
echo "HAS_UI_CHANGES=true" >> $GITHUB_ENV
HAS_UI_CHANGES="true"
echo "✓ UI changes detected - version: $UI_VERSION"
extract_changelog "ui/CHANGELOG.md" "$UI_VERSION" "ui_changelog.md"
else
echo "HAS_UI_CHANGES=false" >> "$GITHUB_ENV"
echo "HAS_UI_CHANGES=false" >> $GITHUB_ENV
HAS_UI_CHANGES="false"
echo "ℹ No UI changes for this release"
touch "ui_changelog.md"
fi
if [ -n "$MCP_VERSION" ]; then
echo "HAS_MCP_CHANGES=true" >> "$GITHUB_ENV"
echo "HAS_MCP_CHANGES=true" >> $GITHUB_ENV
HAS_MCP_CHANGES="true"
echo "✓ MCP changes detected - version: $MCP_VERSION"
extract_changelog "mcp_server/CHANGELOG.md" "$MCP_VERSION" "mcp_changelog.md"
else
echo "HAS_MCP_CHANGES=false" >> "$GITHUB_ENV"
echo "HAS_MCP_CHANGES=false" >> $GITHUB_ENV
HAS_MCP_CHANGES="false"
echo "ℹ No MCP changes for this release"
touch "mcp_changelog.md"
fi
# Combine changelogs in order: UI, API, SDK, MCP
: > combined_changelog.md
> combined_changelog.md
if [ "$HAS_UI_CHANGES" = "true" ] && [ -s "ui_changelog.md" ]; then
echo "## UI" >> combined_changelog.md
@@ -417,3 +389,4 @@ jobs:
if: always()
run: |
rm -f prowler_changelog.md api_changelog.md ui_changelog.md mcp_changelog.md combined_changelog.md
-27
View File
@@ -1,27 +0,0 @@
name: 'Tools: Release Freeze Lift'
run-name: 'Lift release freeze after Prowler ${{ github.event.release.tag_name }}'
on:
release:
types:
- 'published'
permissions: {}
jobs:
release-freeze-lift:
if: github.repository == 'prowler-cloud/prowler' && !github.event.release.prerelease
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- name: Disable release freeze
env:
GH_TOKEN: ${{ secrets.PROWLER_BOT_ACCESS_TOKEN }}
run: |
gh variable set RELEASE_FREEZE --body false --repo "${GITHUB_REPOSITORY}"
-1
View File
@@ -40,7 +40,6 @@ jobs:
github.com:443
pypi.org:443
files.pythonhosted.org:443
release-assets.githubusercontent.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+31 -44
View File
@@ -54,7 +54,6 @@ jobs:
timeout-minutes: 5
outputs:
prowler_version: ${{ steps.get-prowler-version.outputs.prowler_version }}
created: ${{ steps.get-prowler-version.outputs.created }}
latest_tag: ${{ steps.get-prowler-version.outputs.latest_tag }}
stable_tag: ${{ steps.get-prowler-version.outputs.stable_tag }}
permissions:
@@ -65,9 +64,9 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
files.pythonhosted.org:443
github.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -87,7 +86,6 @@ jobs:
fi
echo "latest_tag=latest" >> "${GITHUB_OUTPUT}"
echo "stable_tag=stable" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -148,24 +146,24 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
_http._tcp.deb.debian.org:443
aka.ms:443
api.ecr-public.us-east-1.amazonaws.com:443
auth.docker.io:443
cdn.powershellgallery.com:443
debian.map.fastlydns.net:80
files.pythonhosted.org:443
github.com:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
public.ecr.aws:443
pypi.org:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
debian.map.fastlydns.net:80
github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
www.powershellgallery.com:443
aka.ms:443
cdn.powershellgallery.com:443
_http._tcp.deb.debian.org:443
powershellinfraartifacts-gkhedzdeaghdezhr.z01.azurefd.net:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -200,20 +198,9 @@ jobs:
context: .
file: ${{ env.DOCKERFILE_PATH }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.latest_tag }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler CLI
org.opencontainers.image.description=Open Source security tool for cloud security assessments, audits, incident response, continuous monitoring, hardening and forensics readiness
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
org.opencontainers.image.version=${{ needs.setup.outputs.prowler_version }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -232,14 +219,14 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
api.ecr-public.us-east-1.amazonaws.com:443
registry-1.docker.io:443
auth.docker.io:443
github.com:443
public.ecr.aws:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
public.ecr.aws:443
registry-1.docker.io:443
github.com:443
release-assets.githubusercontent.com:443
api.ecr-public.us-east-1.amazonaws.com:443
sts.amazonaws.com:443
sts.us-east-1.amazonaws.com:443
@@ -265,10 +252,10 @@ jobs:
if: github.event_name == 'push'
run: |
docker buildx imagetools create \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
env:
NEEDS_SETUP_OUTPUTS_LATEST_TAG: ${{ needs.setup.outputs.latest_tag }}
@@ -276,12 +263,12 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t "${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64"
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ secrets.PUBLIC_ECR_REPOSITORY }}/${{ env.IMAGE_NAME }}:${NEEDS_SETUP_OUTPUTS_STABLE_TAG} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_LATEST_TAG}-arm64
env:
NEEDS_SETUP_OUTPUTS_PROWLER_VERSION: ${{ needs.setup.outputs.prowler_version }}
NEEDS_SETUP_OUTPUTS_STABLE_TAG: ${{ needs.setup.outputs.stable_tag }}
@@ -306,7 +293,7 @@ jobs:
if: needs.setup.outputs.latest_tag == 'latest' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
run: |
docker buildx imagetools create \
-t "${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION}" \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_PROWLER_VERSION} \
-t ${{ env.TONIBLYX_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:stable
env:
@@ -356,9 +343,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> "$GITHUB_OUTPUT"
echo "outcome=success" >> $GITHUB_OUTPUT
else
echo "outcome=failure" >> "$GITHUB_OUTPUT"
echo "outcome=failure" >> $GITHUB_OUTPUT
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+2 -20
View File
@@ -10,7 +10,6 @@ on:
- 'Dockerfile*'
- 'pyproject.toml'
- 'uv.lock'
- '.trivyignore.yaml'
- '.github/workflows/sdk-container-checks.yml'
pull_request:
branches:
@@ -81,14 +80,9 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
api.github.com:443
mirror.gcr.io:443
check.trivy.dev:443
raw.githubusercontent.com:443
objects.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
debian.map.fastlydns.net:80
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
@@ -118,12 +112,8 @@ jobs:
Dockerfile*
pyproject.toml
uv.lock
.trivyignore.yaml
.github/workflows/sdk-container-checks.yml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
prowler/CHANGELOG.md
prowler/changelog.d/**
@@ -150,13 +140,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan SDK container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
fail-on-critical: 'true'
severity: 'CRITICAL'
-196
View File
@@ -1,196 +0,0 @@
name: 'SDK: Package Checks'
# Rehearses the PyPI release on every packaging change and once a week, from the
# consumer's side. Two incidents this guards against:
#
# - 5.38.0 shipped an unsatisfiable Requires-Dist (cryptography==50.0.0 while
# alibabacloud-tea-openapi and pyopenssl cap it below 49). A [tool.uv] override hid
# the conflict inside the repo; pip could not install the wheel and silently
# resolved `pip install prowler` to 5.37.1 for a week.
# - 5.39.0 never published: an unpinned build backend started emitting core metadata
# 2.5 and the twine bundled in the publish action rejected it.
#
# Both were only detectable at release time because nothing built and installed the
# artifact earlier. The weekly run also catches releases yanked from PyPI after we
# pinned them (zstd 1.5.7.3, "buggy - not thread safe", sat in uv.lock for months).
on:
push:
branches:
- 'master'
- 'v5.*'
pull_request:
branches:
- 'master'
- 'v5.*'
schedule:
# Monday 06:00 UTC. Yanks and upstream releases happen without a commit here.
- cron: '0 6 * * 1'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
env:
# Must equal the twine bundled in the pypa/gh-action-pypi-publish pin used by
# sdk-pypi-release.yml (requirements/runtime.txt in that repo at the pinned tag).
# A metadata check that passes here must pass there.
TWINE_VERSION: '7.0.0'
jobs:
changes:
if: github.repository == 'prowler-cloud/prowler'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
# Scheduled and manual runs always execute; pushes and PRs only when a packaging
# input changed. Jobs skipped this way still report success to branch protection.
run: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.filter.outputs.any_changed == 'true' }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
- name: Checkout repository
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# zizmor: ignore[artipacked]
persist-credentials: true # Required by tj-actions/changed-files to fetch PR branch
- name: Detect packaging changes
if: github.event_name == 'push' || github.event_name == 'pull_request'
id: filter
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
pyproject.toml
uv.lock
README.md
util/replicate_pypi_package.py
util/check_yanked_pins.py
api/pyproject.toml
api/uv.lock
mcp_server/pyproject.toml
mcp_server/uv.lock
.github/workflows/sdk-package-checks.yml
.github/workflows/sdk-pypi-release.yml
.github/actions/setup-python-uv/**
install-from-wheel:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: false
matrix:
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
package:
- 'prowler'
include:
# prowler-cloud is the same tree renamed by util/replicate_pypi_package.py;
# one Python is enough to prove the rename and its build still work.
- python-version: '3.12'
package: 'prowler-cloud'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Python with uv
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ matrix.python-version }}
install-dependencies: 'false'
- name: Rename package to prowler-cloud
if: matrix.package == 'prowler-cloud'
run: |
pip install --no-cache-dir toml
python util/replicate_pypi_package.py
- name: Build sdist and wheel
run: uv build
- name: Check metadata with the release workflow's twine
run: uvx --from "twine==${TWINE_VERSION}" twine check --strict dist/*
- name: Install the wheel with pip into a clean virtualenv
# Plain pip, --isolated, from outside the repo: consumers never see [tool.uv]
# override-dependencies or constraint-dependencies, so neither does this step.
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Smoke test the installed CLI
run: |
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/prowler" --version
# Loads every AWS check module from the installed wheel: catches files missing
# from the package. grep fails the step if the summary line never appears.
"${RUNNER_TEMP}/consumer/bin/prowler" aws --list-checks | grep 'available checks'
pinned-releases-not-yanked:
needs: changes
if: needs.changes.outputs.run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
allowed-endpoints: >
github.com:443
api.github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: '3.12'
- name: Check every pinned and locked release against PyPI
run: python util/check_yanked_pins.py . api mcp_server
+2 -22
View File
@@ -84,18 +84,8 @@ jobs:
- name: Build Prowler package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish Prowler package to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
print-hash: true
@@ -138,17 +128,7 @@ jobs:
- name: Build prowler-cloud package
run: uv build
- name: Verify the wheel installs with pip
# Same check as "SDK: Package Checks", repeated on the exact artifact about to be
# published. Plain pip, --isolated, from outside the repo: an unsatisfiable
# Requires-Dist fails here instead of on users' machines (5.38.0 shipped one).
run: |
python -m venv "${RUNNER_TEMP}/consumer"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --quiet --upgrade pip
cd "${RUNNER_TEMP}"
"${RUNNER_TEMP}/consumer/bin/python" -m pip install --isolated --no-cache-dir --dry-run "${GITHUB_WORKSPACE}"/dist/*.whl
- name: Publish prowler-cloud package to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
print-hash: true
@@ -44,10 +44,7 @@ jobs:
cache: 'pip'
- name: Install dependencies
# Pinned to the versions in pyproject.toml: the ISO partitions region
# data comes from the endpoints.json bundled with botocore, so the
# botocore version is itself a data source and must be deterministic
run: pip install boto3==1.40.61 botocore==1.40.61
run: pip install boto3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1
@@ -76,7 +76,7 @@ jobs:
### Changes
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` and the matching list in `ui/lib/provider-credentials/oci-regions.ts` with the latest regions fetched from the OCI Identity API (`list_regions()`).
This PR updates the `OCI_COMMERCIAL_REGIONS` dictionary in `prowler/providers/oraclecloud/config.py` with the latest regions fetched from the OCI Identity API (`list_regions()`).
- Government regions (`OCI_GOVERNMENT_REGIONS`) are preserved unchanged
- DOD regions (`OCI_US_DOD_REGIONS`) are preserved unchanged
+1 -26
View File
@@ -216,8 +216,7 @@ jobs:
elif [ -z "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}" ]; then
echo "No AWS service paths detected; skipping AWS tests."
else
read -ra service_paths <<< "${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}"
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml "${service_paths[@]}"
uv run pytest -n auto --cov=./prowler/providers/aws --cov-report=xml:aws_coverage.xml ${STEPS_AWS_SERVICES_OUTPUTS_SERVICE_PATHS}
fi
env:
STEPS_AWS_SERVICES_OUTPUTS_RUN_ALL: ${{ steps.aws-services.outputs.run_all }}
@@ -640,30 +639,6 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-e2enetworks
files: ./e2enetworks_coverage.xml
# Huawei Cloud Provider
- name: Check if Huawei Cloud files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-huaweicloud
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
with:
files: |
./prowler/**/huaweicloud/**
./tests/**/huaweicloud/**
./uv.lock
- name: Run Huawei Cloud tests
if: steps.changed-huaweicloud.outputs.any_changed == 'true'
run: uv run pytest -n auto --cov=./prowler/providers/huaweicloud --cov-report=xml:huaweicloud_coverage.xml tests/providers/huaweicloud
- name: Upload Huawei Cloud coverage to Codecov
if: steps.changed-huaweicloud.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 }}-huaweicloud
files: ./huaweicloud_coverage.xml
# External Provider (dynamic loading)
- name: Check if External Provider files changed
if: steps.check-changes.outputs.any_changed == 'true'
+19 -21
View File
@@ -57,7 +57,6 @@ jobs:
egress-policy: block
allowed-endpoints: >
github.com:443
release-assets.githubusercontent.com:443
pypi.org:443
files.pythonhosted.org:443
@@ -85,8 +84,7 @@ jobs:
echo "Changed files:"
echo "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n'
echo ""
read -ra changed <<< "${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}"
python .github/scripts/test-impact.py "${changed[@]}"
python .github/scripts/test-impact.py ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}
env:
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
@@ -94,21 +92,21 @@ jobs:
id: set-flags
run: |
if [[ -n "${STEPS_IMPACT_OUTPUTS_SDK_TESTS}" ]]; then
echo "has-sdk-tests=true" >> "$GITHUB_OUTPUT"
echo "has-sdk-tests=true" >> $GITHUB_OUTPUT
else
echo "has-sdk-tests=false" >> "$GITHUB_OUTPUT"
echo "has-sdk-tests=false" >> $GITHUB_OUTPUT
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_API_TESTS}" ]]; then
echo "has-api-tests=true" >> "$GITHUB_OUTPUT"
echo "has-api-tests=true" >> $GITHUB_OUTPUT
else
echo "has-api-tests=false" >> "$GITHUB_OUTPUT"
echo "has-api-tests=false" >> $GITHUB_OUTPUT
fi
if [[ -n "${STEPS_IMPACT_OUTPUTS_UI_E2E}" ]]; then
echo "has-ui-e2e=true" >> "$GITHUB_OUTPUT"
echo "has-ui-e2e=true" >> $GITHUB_OUTPUT
else
echo "has-ui-e2e=false" >> "$GITHUB_OUTPUT"
echo "has-ui-e2e=false" >> $GITHUB_OUTPUT
fi
env:
STEPS_IMPACT_OUTPUTS_SDK_TESTS: ${{ steps.impact.outputs.sdk-tests }}
@@ -117,22 +115,22 @@ jobs:
- name: Summary
run: |
echo "## Test Impact Analysis" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## Test Impact Analysis" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${STEPS_IMPACT_OUTPUTS_RUN_ALL}" == "true" ]]; then
echo "🚨 **Critical path changed - running ALL tests**" >> "$GITHUB_STEP_SUMMARY"
echo "🚨 **Critical path changed - running ALL tests**" >> $GITHUB_STEP_SUMMARY
else
echo "### Affected Modules" >> "$GITHUB_STEP_SUMMARY"
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Affected Modules" >> $GITHUB_STEP_SUMMARY
echo "\`${STEPS_IMPACT_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Tests to Run" >> "$GITHUB_STEP_SUMMARY"
echo "| Category | Paths |" >> "$GITHUB_STEP_SUMMARY"
echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "### Tests to Run" >> $GITHUB_STEP_SUMMARY
echo "| Category | Paths |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| SDK Tests | \`${STEPS_IMPACT_OUTPUTS_SDK_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| API Tests | \`${STEPS_IMPACT_OUTPUTS_API_TESTS:-none}\` |" >> $GITHUB_STEP_SUMMARY
echo "| UI E2E | \`${STEPS_IMPACT_OUTPUTS_UI_E2E:-none}\` |" >> $GITHUB_STEP_SUMMARY
fi
env:
+18 -32
View File
@@ -41,20 +41,17 @@ jobs:
timeout-minutes: 5
outputs:
short-sha: ${{ steps.set-short-sha.outputs.short-sha }}
created: ${{ steps.set-short-sha.outputs.created }}
permissions:
contents: read
steps:
- name: Harden Runner
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
egress-policy: audit
- name: Calculate short SHA
id: set-short-sha
run: |
echo "short-sha=${GITHUB_SHA::7}" >> "${GITHUB_OUTPUT}"
echo "created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
run: echo "short-sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
notify-release-started:
if: github.repository == 'prowler-cloud/prowler' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
@@ -114,15 +111,15 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
registry-1.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
auth.docker.io:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
fonts.gstatic.com:443
github.com:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
registry.npmjs.org:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -147,20 +144,9 @@ jobs:
build-args: |
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('v{0}', env.RELEASE_TAG) || needs.setup.outputs.short-sha }}
push: true
sbom: true
# max, not the default min: min records little beyond the build ref.
provenance: mode=max
platforms: ${{ matrix.platform }}
tags: |
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ needs.setup.outputs.short-sha }}-${{ matrix.arch }}
labels: |
org.opencontainers.image.title=Prowler Local Server UI
org.opencontainers.image.description=Web UI for Prowler Local Server (Next.js)
org.opencontainers.image.vendor=ProwlerPro, Inc.
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ needs.setup.outputs.created }}
${{ (github.event_name == 'release' || github.event_name == 'workflow_dispatch') && format('org.opencontainers.image.version={0}', env.RELEASE_TAG) || '' }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=${{ github.event_name == 'pull_request' && 'min' || 'max' }},scope=${{ matrix.arch }}
@@ -178,12 +164,12 @@ jobs:
with:
egress-policy: block
allowed-endpoints: >
auth.docker.io:443
github.com:443
release-assets.githubusercontent.com:443
registry-1.docker.io:443
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
registry-1.docker.io:443
release-assets.githubusercontent.com:443
- name: Login to DockerHub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
@@ -196,9 +182,9 @@ jobs:
run: |
docker buildx imagetools create \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.LATEST_TAG }} \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA} \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -206,10 +192,10 @@ jobs:
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
run: |
docker buildx imagetools create \
-t "${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG}" \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${RELEASE_TAG} \
-t ${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${{ env.STABLE_TAG }} \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64" \
"${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64"
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-amd64 \
${{ env.PROWLERCLOUD_DOCKERHUB_REPOSITORY }}/${{ env.PROWLERCLOUD_DOCKERHUB_IMAGE }}:${NEEDS_SETUP_OUTPUTS_SHORT_SHA}-arm64
env:
NEEDS_SETUP_OUTPUTS_SHORT_SHA: ${{ needs.setup.outputs.short-sha }}
@@ -249,9 +235,9 @@ jobs:
id: outcome
run: |
if [[ "${NEEDS_CONTAINER_BUILD_PUSH_RESULT}" == "success" && "${NEEDS_CREATE_MANIFEST_RESULT}" == "success" ]]; then
echo "outcome=success" >> "$GITHUB_OUTPUT"
echo "outcome=success" >> $GITHUB_OUTPUT
else
echo "outcome=failure" >> "$GITHUB_OUTPUT"
echo "outcome=failure" >> $GITHUB_OUTPUT
fi
env:
NEEDS_CONTAINER_BUILD_PUSH_RESULT: ${{ needs.container-build-push.result }}
+2 -18
View File
@@ -78,7 +78,6 @@ jobs:
auth.docker.io:443
production.cloudflare.docker.com:443
production.cloudfront.docker.com:443
tuf-repo-cdn.sigstore.dev:443
registry.npmjs.org:443
dl-cdn.alpinelinux.org:443
fonts.googleapis.com:443
@@ -89,9 +88,6 @@ jobs:
get.trivy.dev:443
release-assets.githubusercontent.com:443
objects.githubusercontent.com:443
raw.githubusercontent.com:443
grype.anchore.io:443
get.anchore.io:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -105,11 +101,7 @@ jobs:
with:
files: |
ui/**
.trivyignore.yaml
.github/actions/trivy-scan/**
.github/actions/grype-scan/**
.grype.yaml
.github/scripts/grype-pr-comment.js
files_ignore: |
ui/CHANGELOG.md
ui/changelog.d/**
@@ -140,13 +132,5 @@ jobs:
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
severity: 'CRITICAL,HIGH'
- name: Scan UI container with Grype
if: steps.check-changes.outputs.any_changed == 'true'
uses: ./.github/actions/grype-scan
with:
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ github.sha }}
fail-on-severity: 'high'
fail-on-critical: 'true'
severity: 'CRITICAL'
+59 -69
View File
@@ -10,13 +10,12 @@ on:
- master
- "v5.*"
paths:
- ".github/workflows/ui-e2e-tests-v2.yml"
- ".github/workflows/test-impact-analysis.yml"
- ".github/test-impact.yml"
- "ui/**"
- "api/**" # API changes can affect UI E2E
- "!ui/CHANGELOG.md"
- "!api/CHANGELOG.md"
- '.github/workflows/ui-e2e-tests-v2.yml'
- '.github/test-impact.yml'
- 'ui/**'
- 'api/**' # API changes can affect UI E2E
- '!ui/CHANGELOG.md'
- '!api/CHANGELOG.md'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -39,16 +38,13 @@ jobs:
github.repository == 'prowler-cloud/prowler' &&
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) &&
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
# targeted for November 19, 2026), where `playwright install` refuses to install
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
env:
AUTH_SECRET: "fallback-ci-secret-for-testing"
AUTH_SECRET: 'fallback-ci-secret-for-testing'
AUTH_TRUST_HOST: true
NEXTAUTH_URL: "http://localhost:3000"
AUTH_URL: "http://localhost:3000"
UI_API_BASE_URL: "http://localhost:8080/api/v1"
NEXTAUTH_URL: 'http://localhost:3000'
AUTH_URL: 'http://localhost:3000'
UI_API_BASE_URL: 'http://localhost:8080/api/v1'
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
@@ -64,7 +60,7 @@ jobs:
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
E2E_KUBERNETES_CONTEXT: "kind-kind"
E2E_KUBERNETES_CONTEXT: 'kind-kind'
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
@@ -112,14 +108,14 @@ jobs:
- name: Show test scope
run: |
echo "## E2E Test Scope" >> "$GITHUB_STEP_SUMMARY"
echo "## E2E Test Scope" >> $GITHUB_STEP_SUMMARY
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
echo "Running **ALL** E2E tests (critical path changed)" >> "$GITHUB_STEP_SUMMARY"
echo "Running **ALL** E2E tests (critical path changed)" >> $GITHUB_STEP_SUMMARY
else
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> "$GITHUB_STEP_SUMMARY"
echo "Running tests matching: \`${E2E_TEST_PATHS}\`" >> $GITHUB_STEP_SUMMARY
fi
echo ""
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
@@ -237,12 +233,12 @@ jobs:
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
- name: Fix API data directory permissions
run: docker run --rm -v "$(pwd)/_data/api:/data" alpine chown -R 1000:1000 /data
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
- name: Add AWS credentials for testing
run: |
echo "AWS_ACCESS_KEY_ID=${E2E_AWS_PROVIDER_ACCESS_KEY}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${E2E_AWS_PROVIDER_SECRET_KEY}" >> .env
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
- name: Build API image from current code
# docker-compose.yml references prowlercloud/prowler-api:latest from the registry,
@@ -271,7 +267,7 @@ jobs:
timeout=150
elapsed=0
while [ $elapsed -lt $timeout ]; do
if curl -s "${UI_API_BASE_URL}/docs" >/dev/null 2>&1; then
if curl -s ${UI_API_BASE_URL}/docs >/dev/null 2>&1; then
echo "Prowler API is ready!"
exit 0
fi
@@ -296,7 +292,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: "ui/.nvmrc"
node-version-file: 'ui/.nvmrc'
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
@@ -305,7 +301,7 @@ jobs:
run_install: false
- name: Get pnpm store directory
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm and Next.js cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -341,65 +337,59 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm run test:e2e:install
- name: Run standard E2E tests
id: standard-e2e
- name: Run E2E tests
working-directory: ./ui
run: |
if [[ "${RUN_ALL_TESTS}" == "true" ]]; then
echo "Running all standard E2E tests..."
echo "Running ALL E2E tests..."
pnpm run test:e2e
else
echo "Running targeted standard E2E tests: ${E2E_TEST_PATHS}"
echo "Running targeted E2E tests: ${E2E_TEST_PATHS}"
# Convert glob patterns to playwright test paths
# e.g., "ui/tests/providers/**" -> "tests/providers"
TEST_PATHS="${E2E_TEST_PATHS}"
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vE '^tests/(setups|registry)/' || true)
# Drop auth setup helpers (not runnable test suites)
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
# Safety net: if bare "tests/" appears (from broad patterns like ui/tests/**),
# expand to specific subdirs to avoid Playwright discovering setup files
if echo "$TEST_PATHS" | grep -qx 'tests/'; then
echo "Expanding bare 'tests/' to specific subdirs (excluding setups)..."
SPECIFIC_DIRS=""
for dir in tests/*/; do
[[ "$dir" == "tests/setups/" || "$dir" == "tests/registry/" ]] && continue
[[ "$dir" == "tests/setups/" ]] && continue
SPECIFIC_DIRS="${SPECIFIC_DIRS}${dir}"$'\n'
done
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/' || true)
# Replace "tests/" with specific dirs, keep other paths
TEST_PATHS=$(echo "$TEST_PATHS" | grep -vx 'tests/')
TEST_PATHS="${TEST_PATHS}"$'\n'"${SPECIFIC_DIRS}"
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^$' | sort -u)
fi
if [[ -z "$TEST_PATHS" ]]; then
echo "No runnable E2E test paths after filtering setups"
exit 0
fi
# Filter out directories that don't contain any test files
VALID_PATHS=""
while IFS= read -r path; do
[[ -z "$path" ]] && continue
if find "$path" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
VALID_PATHS="${VALID_PATHS}${path}"$'\n'
while IFS= read -r p; do
[[ -z "$p" ]] && continue
if find "$p" -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | head -1 | grep -q .; then
VALID_PATHS="${VALID_PATHS}${p}"$'\n'
else
echo "Skipping empty test directory: $path"
echo "Skipping empty test directory: $p"
fi
done <<< "$TEST_PATHS"
VALID_PATHS=$(echo "$VALID_PATHS" | grep -v '^$' || true)
if [[ -n "$VALID_PATHS" ]]; then
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
echo "Resolved standard test paths: $TEST_PATHS"
read -ra test_paths <<< "$TEST_PATHS"
pnpm exec playwright test "${test_paths[@]}"
else
echo "No standard E2E test paths selected."
if [[ -z "$VALID_PATHS" ]]; then
echo "No test files found in any resolved paths — skipping E2E"
exit 0
fi
TEST_PATHS=$(echo "$VALID_PATHS" | tr '\n' ' ')
echo "Resolved test paths: $TEST_PATHS"
pnpm exec playwright test $TEST_PATHS
fi
- name: Run Registry fixture E2E tests
if: |
!cancelled() &&
(steps.standard-e2e.outcome == 'success' || steps.standard-e2e.outcome == 'failure') &&
(env.RUN_ALL_TESTS == 'true' || contains(format(' {0} ', env.E2E_TEST_PATHS), ' ui/tests/registry/'))
working-directory: ./ui
run: pnpm run test:e2e:registry
# A worker that crashes on import or a connection test that never resolves
# only shows up here; Playwright just reports a missing footer button.
- name: Dump API service logs
if: failure()
run: docker compose logs --no-color --tail=300 api worker worker-beat || true
- name: Upload test reports
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: failure()
@@ -454,12 +444,12 @@ jobs:
- name: No E2E tests needed
run: |
echo "## E2E Tests Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No UI E2E tests needed for this change." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> "$GITHUB_STEP_SUMMARY"
echo "## E2E Tests Skipped" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No UI E2E tests needed for this change." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Affected modules: \`${NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "To run all tests, modify a file in a critical path (e.g., \`ui/lib/**\`)." >> $GITHUB_STEP_SUMMARY
env:
NEEDS_IMPACT_ANALYSIS_OUTPUTS_MODULES: ${{ needs.impact-analysis.outputs.modules }}
+7 -7
View File
@@ -21,10 +21,7 @@ permissions: {}
jobs:
ui-tests:
# ubuntu-latest is moving to 26.04 (rollout began October 19, 2026; completion
# targeted for November 19, 2026), where `playwright install` refuses to install
# Chromium for the pinned Playwright. Stay on 24.04 until the suite upgrades.
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
@@ -116,7 +113,7 @@ jobs:
- name: Get pnpm store directory
if: steps.check-changes.outputs.any_changed == 'true'
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm and Next.js cache
if: steps.check-changes.outputs.any_changed == 'true'
@@ -139,6 +136,10 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run healthcheck
- name: Check product-tour alignment
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run tour:check
- name: Run pnpm audit
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run audit
@@ -156,8 +157,7 @@ jobs:
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
read -ra changed <<< "$CHANGED_FILES"
pnpm exec vitest related "${changed[@]}" --run --project unit
pnpm exec vitest related $CHANGED_FILES --run --project unit
env:
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
-104
View File
@@ -1,104 +0,0 @@
# Findings excluded from the Grype gate, each with a reason.
# Anything not listed here blocks the pull request at critical or high severity.
# Pairs are explicit: a new CVE against an already-listed package still blocks.
#
# Every entry below has a published fix we cannot take. Findings with no fix at all are
# not listed: the scan runs with only-fixed, so they never reach the gate.
ignore:
# Shipped inside the PowerShell tarball, in its bundled MicrosoftTeams module.
# Not a dependency we declare, and not one we can upgrade independently.
- vulnerability: CVE-2026-26127
package:
name: Microsoft.Bcl.Memory
# Documented in .trivyignore.yaml: ships inside ExchangeOnlineManagement
# 3.10.1, the latest gallery release. Re-check on the next EXO release.
- vulnerability: CVE-2026-84008
package:
name: System.Security.Cryptography.Pkcs
version: 10.0.0
# The .NET runtime bundled inside the PowerShell tarball the Dockerfile pins.
# CVE-2026-62901 is the same temporary exception documented in .trivyignore.yaml:
# fixed in .NET 9.0.19 / 10.0.11 (2026-08-11), but no published PowerShell release
# ships a patched runtime yet (7.5.9 bundles 9.0.18; 7.6.4 bundles 10.0.x < 10.0.11).
# pwsh runs only local M365 module cmdlets; nothing listens for inbound WebSocket
# connections. Remove with the Trivy exception by 2026-09-15.
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-x64
- vulnerability: CVE-2026-62901
package:
name: Microsoft.NETCore.App.Runtime.linux-arm64
# The CPython interpreter, compiled into the official base image.
# TEMPORARY, unlike the entries above: moving to Python 3.13 clears seven of these, and
# that is a runtime upgrade pending its own evaluation. The remaining three need 3.15 and
# are unfixable either way -- the MCP image already runs 3.13.14 and still reports them.
- vulnerability: CVE-2026-11940
package:
name: python
- vulnerability: CVE-2026-11972
package:
name: python
- vulnerability: CVE-2026-15308
package:
name: python
- vulnerability: CVE-2026-3298
package:
name: python
- vulnerability: CVE-2026-3644
package:
name: python
- vulnerability: CVE-2026-4224
package:
name: python
- vulnerability: CVE-2026-4786
package:
name: python
- vulnerability: CVE-2026-6100
package:
name: python
- vulnerability: CVE-2026-7210
package:
name: python
- vulnerability: CVE-2026-9669
package:
name: python
# CVE-2026-82049 (tarfile data/tar filter bypass via a hard link to a symlink) has no
# fixed CPython release on any branch: the fix is merged on main and 3.13 only, and the
# 3.12 backport is still open. Grype records 3.14.0b1 as the fix, so only-fixed does not
# drop it, yet python:3.12.14-slim-trixie reports it too. Prowler never extracts tar
# archives to disk: the ECR image inspection reads members in memory with extractfile().
# Remove once the base image ships a 3.12 release that includes the backport.
# https://github.com/python/cpython/issues/157190
# https://github.com/python/cpython/pull/157454
- vulnerability: CVE-2026-82049
package:
name: python
# zlib in the Alpine base image of the MCP (python:3.13.16-alpine3.23) container.
# CVE-2026-85091 is a heap overflow in gz_vacate() reached only through non-blocking
# gzwrite() followed by gzprintf() after a write stall; the MCP server never writes
# gzip files, so the image never calls that path. The UI image no longer needs this:
# node:24.22.1-alpine is built on Alpine 3.24 and ships 1.3.2-r1. TEMPORARY: the fix
# landed in the 3.23 index on 2026-10-06, two days after the pinned python image was
# built, and could be taken with `apk add --upgrade`, but we wait for the base image
# to ship it instead (python:3.13.17-alpine3.23, built 2026-10-22, already does).
# Pinned to that version so the rule stops matching on its own once the base image
# moves forward. Remove when the MCP digest is bumped, by 2026-11-07.
# https://security.alpinelinux.org/vuln/CVE-2026-85091
- vulnerability: CVE-2026-85091
package:
name: zlib
version: 1.3.2-r0
# Documented in .trivyignore.yaml: go-jose 4.1.5 compiled into the Trivy binary (0.76.1,
# the latest release). Pinned to 4.1.5 so it stops matching with the next Trivy bump.
- vulnerability: CVE-2026-116587
package:
name: github.com/go-jose/go-jose/v4
version: v4.1.5
-8
View File
@@ -47,14 +47,6 @@ repos:
priority: 20
## GITHUB ACTIONS
- repo: https://github.com/rhysd/actionlint
rev: v1.7.12
hooks:
- id: actionlint
# SC2129 only suggests grouping consecutive redirects; not worth restructuring for.
args: ['-shellcheck=-e SC2129']
priority: 30
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
hooks:
+123
View File
@@ -0,0 +1,123 @@
# Trivy ignore file for prowlercloud/prowler SDK container image.
# Each entry below documents (a) the affected package and why it ships in the
# image, (b) why the CVE is not exploitable in Prowler's runtime, and (c) the
# upstream fix status. Entries carry an expiry so they auto-force re-review.
# Entries are scoped per-package so suppressions cannot drift onto unrelated
# packages that may be assigned the same CVE in the future.
#
# Scanned by: .github/actions/trivy-scan via .github/workflows/sdk-container-checks.yml
# CVE-2026-42496 — perl-archive-tar path traversal via crafted symlinks.
# CVE-2026-8376 — perl heap buffer overflow when compiling regex.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. The Prowler SDK does not invoke perl at runtime;
# neither vulnerable code path (Archive::Tar parsing or regex compilation of
# attacker-controlled input) is reachable from Prowler. No Debian bookworm fix
# is available yet.
CVE-2026-42496 pkg:perl exp:2026-08-15
CVE-2026-42496 pkg:perl-base exp:2026-08-15
CVE-2026-42496 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-42496 pkg:libperl5.36 exp:2026-08-15
CVE-2026-8376 pkg:perl exp:2026-08-15
CVE-2026-8376 pkg:perl-base exp:2026-08-15
CVE-2026-8376 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-8376 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-13221 - Perl regex trie overflow.
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: upstream confirms Perl 5.36.0 is not affected; the regression
# was introduced after this version. Debian currently marks bookworm as
# vulnerable, which causes Trivy to report a false positive.
# Ref: https://github.com/Perl/perl5/issues/23388
CVE-2026-13221 pkg:perl exp:2026-08-15
CVE-2026-13221 pkg:perl-base exp:2026-08-15
CVE-2026-13221 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-13221 pkg:libperl5.36 exp:2026-08-15
# CVE-2026-57433 — Perl Storable signed integer overflow when deserializing a
# crafted SX_HOOK record (retrieve_hook_common passes a wrapped negative count
# to av_extend).
# Packages: perl, perl-base, perl-modules-5.36, libperl5.36.
# Why ignored: perl-base is part of Debian's "Essential: yes" set; it cannot be
# removed without breaking dpkg. Prowler does not invoke perl at runtime and
# never calls Storable's thaw/retrieve on attacker-controlled blobs, so the
# vulnerable deserialization path is unreachable. Fixed upstream in
# Storable 3.41; no Debian bookworm fix is available yet.
CVE-2026-57433 pkg:perl exp:2026-08-15
CVE-2026-57433 pkg:perl-base exp:2026-08-15
CVE-2026-57433 pkg:perl-modules-5.36 exp:2026-08-15
CVE-2026-57433 pkg:libperl5.36 exp:2026-08-15
# CVE-2025-7458 — SQLite integer overflow.
# Package: libsqlite3-0.
# Why ignored: transitive dependency of CPython's stdlib sqlite3 module. The
# Prowler SDK does not open user-supplied SQLite databases; SQLite usage is
# internal and bounded. No Debian bookworm fix is available.
CVE-2025-7458 pkg:libsqlite3-0 exp:2026-08-15
# CVE-2026-43185 — Linux kernel ksmbd signedness bug.
# Package: linux-libc-dev.
# Why ignored: linux-libc-dev ships kernel headers for build-time compilation,
# not a running kernel. Containers execute against the host kernel, so these
# headers are inert at runtime. The upstream fix landed in kernel 7.0-rc2 and
# has not been backported to Debian's 6.1 LTS line.
CVE-2026-43185 pkg:linux-libc-dev exp:2026-08-15
# CVE-2023-45853 — zlib MiniZip integer overflow / heap overflow in
# zipOpenNewFileInZip4_64.
# Packages: zlib1g, zlib1g-dev.
# Why ignored: Debian Security Tracker status for bookworm is <ignored>, with
# the published rationale "contrib/minizip not built and src:zlib not producing
# binary packages" — i.e. the vulnerable symbol is not present in the libz.so
# shipped by Debian. Real-not-affected, not unpatched. Upstream fix is in
# zlib 1.3.1, available in Debian trixie (13); migrating the base image would
# clear it fully.
# Ref: https://security-tracker.debian.org/tracker/CVE-2023-45853
CVE-2023-45853 pkg:zlib1g exp:2026-08-15
CVE-2023-45853 pkg:zlib1g-dev exp:2026-08-15
# CVE-2026-55200 — libssh2 out-of-bounds write in ssh2_transport_read() due to
# an unchecked packet_length field in transport.c (heap corruption, possible RCE).
# Package: libssh2-1.
# Why ignored: libssh2-1 is pulled in only as a transitive dependency of libcurl4
# (installed in the SDK Dockerfile for the networking/PowerShell stack). The
# vulnerable path is reached exclusively when libssh2 acts as an SSH/SCP/SFTP
# client parsing transport packets from a server. Prowler never uses libcurl's
# SSH/SCP/SFTP transports; it talks to cloud provider HTTPS endpoints only, so the
# affected code is unreachable at runtime. Fixed upstream in libssh2 commit
# 97acf3df (PR #2052); no Debian bookworm fix is available yet.
# Ref: https://security-tracker.debian.org/tracker/CVE-2026-55200
CVE-2026-55200 pkg:libssh2-1 exp:2026-08-15
# --- API container image (api/Dockerfile) ---
# The entries below are specific to the Prowler API image, which ships
# PowerShell and additional build tooling on top of the same bookworm base.
# CVE-2026-7210 — CPython/Expat hash-flooding denial of service in
# `xml.parsers.expat` and `xml.etree.ElementTree`.
# Packages: the Debian system Python 3.11 (python3.11*, libpython3.11*).
# Why ignored: the API runs under the Python 3.12 interpreter shipped in its
# `.venv`; the system `python3.11` is only present because `python3-dev` is
# pulled in to compile native extensions (xmlsec, lxml) and is never executed
# at runtime. The vulnerable path requires parsing attacker-controlled XML with
# the affected interpreter, which Prowler does not do with the system Python.
# Full mitigation also needs libexpat >= 2.8.0; no Debian bookworm fix yet.
CVE-2026-7210 pkg:python3.11 exp:2026-08-15
CVE-2026-7210 pkg:python3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:python3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11 exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-dev exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-minimal exp:2026-08-15
CVE-2026-7210 pkg:libpython3.11-stdlib exp:2026-08-15
# CVE-2026-33278 — Unbound DNSSEC validator use-after-free (DoS, possible RCE).
# CVE-2026-42960 — Unbound DNS cache poisoning via promiscuous additional records.
# Package: libunbound8.
# Why ignored: libunbound8 is a transitive apt dependency of the TLS/networking
# stack (GnuTLS DANE support); only the shared library ships in the image. Both
# vulnerabilities require operating a live Unbound recursive DNSSEC validator
# that processes attacker-influenced DNS responses. Prowler never starts an
# Unbound resolver, so neither code path is reachable. No Debian bookworm fix yet.
CVE-2026-33278 pkg:libunbound8 exp:2026-08-15
CVE-2026-42960 pkg:libunbound8 exp:2026-08-15
-236
View File
@@ -1,236 +0,0 @@
# Trivy suppressions for the prowlercloud/prowler SDK and API container images.
#
# This file replaces the classic .trivyignore, which parsed only the CVE id: the
# `pkg:` selector written on each line was documentation and the entry suppressed
# its CVE across every package in the image. The `purls` field below is honoured,
# so each entry is scoped to the package it names. Verified against Trivy 0.71.2:
# an entry given the wrong purl leaves the finding reported, where the classic
# format suppressed it.
#
# `expired_at` forces re-review. Keep the dates staggered.
#
# The four entries below are currently redundant: the scan runs with ignore-unfixed,
# and none of them has a published fix, so they never reach the gate either way. They
# are kept because the reasoning is what justifies accepting them, and because they
# apply again the moment any of them gains a fix we do not take.
#
# perl-base is Debian "Essential: yes". Trivy spreads src:perl CVEs across every
# binary package built from that source, so perl-base is flagged for modules only
# perl-modules-* ships. Neither image installs those, and nothing in either
# invokes perl.
#
# Why these four are accepted rather than fixed (reviewed 2026-07-31):
#
# 1. No fix exists. All four report no fixed version on perl-base 5.40.1-6.
# Debian marks CVE-2026-42496 "fix_deferred" and the other three "affected".
# A newer base image, apt upgrade, or a newer Debian release changes nothing.
# 2. The package cannot be removed. "Essential: yes" means removal needs
# dpkg --force-remove-essential, which breaks apt for anything built
# downstream from these images.
# 3. Changing base distribution was evaluated and rejected. Alpine drops perl
# entirely, but PowerShell publishes no linux-musl-arm64 build in any
# release, so M365 scanning would break on arm64 -- which is what we run in
# production. Wolfi keeps glibc and drops perl, but pinnable versioned tags
# are a paid tier, so builds would not be reproducibly pinnable.
#
# Not-invoked claim verified by sweeping both images for files with a perl
# shebang, shell/python callers of perl, ELF binaries containing "perl", and
# .pl/.pm files or perl subprocess calls anywhere in site-packages. The only
# consumers found are dpkg/debconf/adduser/pam tooling, none of which runs at
# runtime, plus one build-time script inside the ExchangeOnlineManagement
# PowerShell module that is never invoked.
vulnerabilities:
# Archive::Tar path traversal. Not installed: `perl -MArchive::Tar -e1` cannot locate it.
- id: CVE-2026-42496
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Storable integer overflow. Not installed: `perl -MStorable -e1` cannot locate it.
- id: CVE-2026-57433
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Regex heap overflow on 32-bit builds only; both published arches are 64-bit.
- id: CVE-2026-8376
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2027-01-31
# Regex trie bug giving silently wrong matches above 65535 alternation branches.
# perl 5.40.1 is in range, so this rests on nothing invoking perl. Short expiry
# to force a re-look. Ref: https://github.com/Perl/perl5/issues/23388
- id: CVE-2026-13221
purls:
- "pkg:deb/debian/perl-base"
expired_at: 2026-11-30
# Declared in the SPDX manifest that ships inside PowerShell's MicrosoftTeams module
# (Modules/MicrosoftTeams/8.0.0/_manifest/spdx_2.2/manifest.spdx.json). Trivy reads that
# SBOM and reports what it declares, which is not the same as what the image contains:
# there is no Node runtime and no node_modules anywhere in the image, and the .NET
# assemblies target net472, a Windows-only framework. Nothing here is reachable, and none
# of it is a dependency we declare -- only Microsoft can change the module's contents.
# proxy-addr 2.0.7 (CVE-2026-90711) is one more such declaration: no file named proxy-addr
# exists in the image, only its purl inside that manifest. The UI lock lists proxy-addr 2.0.7
# through express, but the Next standalone output does not ship it, so the shared entry
# hides nothing real in the UI image either (checked on prowlercloud/prowler-ui:latest).
# @modelcontextprotocol/sdk 1.27.1 (CVE-2026-104850) is another: only its purl is in that
# manifest. The entry is pinned to 1.27.1 because the UI really depends on the SDK (1.26.0
# via @langchain/mcp-adapters), and a version-less purl would hide that finding too.
- id: CVE-2020-0606
purls:
- "pkg:nuget/Microsoft.WindowsDesktop.App.Ref"
expired_at: 2027-01-31
- id: CVE-2019-0820
purls:
- "pkg:nuget/System.Text.RegularExpressions"
expired_at: 2027-01-31
- id: CVE-2026-47302
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-47304
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50525
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50527
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
- id: CVE-2026-50648
purls:
- "pkg:nuget/System.Security.Cryptography.Xml"
expired_at: 2027-01-31
# Same manifest, pinned to 8.0.4: PowerShell's own copy is also flagged by these CVEs.
- id: CVE-2026-96222
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-96958
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-96959
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-96960
purls:
- "pkg:nuget/System.Security.Cryptography.Xml@8.0.4"
expired_at: 2027-01-31
- id: CVE-2026-13676
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-16221
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-18446
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75899
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75975
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-76172
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-75931
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-84292
purls:
- "pkg:npm/fast-uri"
expired_at: 2027-01-31
- id: CVE-2026-69192
purls:
- "pkg:npm/ip-address"
expired_at: 2027-01-31
- id: CVE-2026-101898
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101901
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101903
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101905
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101906
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101907
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-101909
purls:
- "pkg:npm/axios"
expired_at: 2027-01-31
- id: CVE-2026-90711
purls:
- "pkg:npm/proxy-addr"
expired_at: 2027-01-31
- id: CVE-2026-104850
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
# Two more SDK declarations and follow-redirects 1.16.0 from the same manifest. The SDK
# entries stay pinned to 1.27.1 for the reason above. No lock in this repo lists
# follow-redirects, so its pin only keeps a future real dependency from being hidden.
- id: CVE-2026-111767
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
- id: CVE-2026-111768
purls:
- "pkg:npm/%40modelcontextprotocol%2Fsdk@1.27.1"
expired_at: 2027-01-31
- id: CVE-2026-111755
purls:
- "pkg:npm/follow-redirects@1.16.0"
expired_at: 2027-01-31
- id: CVE-2026-111756
purls:
- "pkg:npm/follow-redirects@1.16.0"
expired_at: 2027-01-31
# Unlike the manifest entries above, this DLL ships in the image:
# ExchangeOnlineManagement/3.10.1/netCore/System.Security.Cryptography.Pkcs.dll. It is a
# DoS in CMS/PKCS parsing; Prowler only feeds it the tenant's own certificate. 3.10.1 is
# the latest gallery release, so only Microsoft can update it. Re-check on the next EXO
# release.
- id: CVE-2026-84008
purls:
- "pkg:nuget/System.Security.Cryptography.Pkcs@10.0.0"
expired_at: 2026-12-15
# go-jose 4.1.5 is compiled into the Trivy binary (usr/local/bin/trivy). Trivy merged the
# 4.1.6 bump upstream (aquasecurity/trivy#11488) but 0.76.1 is still the latest release.
# Bump TRIVY_VERSION and drop this entry once a release ships it.
- id: CVE-2026-116587
purls:
- "pkg:golang/github.com/go-jose/go-jose/v4@v4.1.5"
expired_at: 2026-11-20
+6
View File
@@ -51,6 +51,7 @@ Use these skills for detailed patterns on-demand:
| `django-migration-psql` | Django migration best practices for PostgreSQL | [SKILL.md](skills/django-migration-psql/SKILL.md) |
| `postgresql-indexing` | PostgreSQL indexing, EXPLAIN, monitoring, maintenance | [SKILL.md](skills/postgresql-indexing/SKILL.md) |
| `prowler-attack-paths-query` | Create Attack Paths openCypher queries | [SKILL.md](skills/prowler-attack-paths-query/SKILL.md) |
| `prowler-tour` | Keep product-tour definitions aligned with the UI | [SKILL.md](skills/prowler-tour/SKILL.md) |
| `gh-aw` | GitHub Agentic Workflows (gh-aw) | [SKILL.md](skills/gh-aw/SKILL.md) |
| `skill-creator` | Create new AI agent skills | [SKILL.md](skills/skill-creator/SKILL.md) |
@@ -68,10 +69,12 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Adding new providers | `prowler-provider` |
| Adding privilege escalation detection queries | `prowler-attack-paths-query` |
| Adding services to existing providers | `prowler-provider` |
| Adding, updating, or removing a tour definition (*.tour.ts) | `prowler-tour` |
| After creating/modifying a skill | `skill-sync` |
| App Router / Server Actions | `nextjs-16` |
| Auditing check-to-requirement mappings as a cloud auditor | `prowler-compliance` |
| Building AI chat features | `ai-sdk-5` |
| Changing button labels or section headings on a tour-covered page | `prowler-tour` |
| Committing changes | `prowler-commit` |
| Configuring MCP servers in agentic workflows | `gh-aw` |
| Create PR that requires changelog entry | `prowler-changelog` |
@@ -91,6 +94,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Creating/updating compliance frameworks | `prowler-compliance` |
| Debug why a GitHub Actions job is failing | `prowler-ci` |
| Debugging gh-aw compilation errors | `gh-aw` |
| Editing a UI file containing data-tour-id attributes | `prowler-tour` |
| Fill .github/pull_request_template.md (Context/Description/Steps to review/Checklist) | `prowler-pr` |
| Fixing bug | `tdd` |
| Fixing compliance JSON bugs (duplicate IDs, empty Section, stale refs) | `prowler-compliance` |
@@ -107,6 +111,8 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
| Modifying gh-aw workflow frontmatter or safe-outputs | `gh-aw` |
| Refactoring code | `tdd` |
| Regenerate AGENTS.md Auto-invoke tables (sync.sh) | `skill-sync` |
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
| Review changelog format and conventions | `prowler-changelog` |
| Reviewing JSON:API compliance | `jsonapi` |
+7 -55
View File
@@ -1,47 +1,23 @@
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
LABEL maintainer="https://github.com/prowler-cloud/prowler"
LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
ARG POWERSHELL_VERSION=7.5.12
ARG POWERSHELL_VERSION=7.5.0
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.76.1
ARG TRIVY_VERSION=0.71.2
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
# gzip 1.13-1+deb13u1 CVE-2026-41992
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
# libbrotli1 1.1.0-2+deb13u1 CVE-2026-111497
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
# openssl packages are flagged separately, so all are named.
# Drop each one once the base image ships its fixed version.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libicu76 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
build-essential pkg-config libzstd-dev zlib1g-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 libbrotli1 \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -53,9 +29,6 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -72,9 +45,6 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -93,9 +63,6 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -122,15 +89,13 @@ ENV HOME='/home/prowler'
ENV PATH="${HOME}/.local/bin:${PATH}"
#hadolint ignore=DL3013
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.13.4
pip install --no-cache-dir uv==0.11.14
RUN uv sync --locked --compile-bytecode && \
rm -rf ~/.cache/uv
# Install PowerShell modules. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py && \
rm -rf "${HOME}"/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
# Install PowerShell modules
RUN .venv/bin/python prowler/providers/m365/lib/powershell/m365_powershell.py
USER root
@@ -140,9 +105,6 @@ RUN apt-get purge -y --auto-remove \
pkg-config \
libzstd-dev \
zlib1g-dev \
wget \
gnupg \
apt-transport-https \
&& rm -rf /var/lib/apt/lists/*
USER prowler
@@ -151,15 +113,5 @@ USER prowler
RUN pip uninstall dash-html-components -y && \
pip uninstall dash-core-components -y
USER root
# pip is build-only; the entrypoint runs the venv directly.
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
/home/prowler/.local/lib/python3.12/site-packages/pip \
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
USER prowler
ENTRYPOINT ["/home/prowler/.venv/bin/prowler"]
-3
View File
@@ -34,9 +34,6 @@ test: ## Test with pytest
rm -rf .coverage && \
pytest -n auto -vvv -s --cov=./prowler --cov-report=xml tests
test-mcp: ## Test MCP server with pytest (mirrors CI)
cd mcp_server && uv run pytest --cov=./prowler_mcp_server --cov-report=term-missing tests
coverage: ## Show Test Coverage
coverage run --skip-covered -m pytest -v && \
coverage report -m && \
+7 -7
View File
@@ -126,12 +126,12 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/user-guide/compliance/tutorials/compliance) | [Categories](https://docs.prowler.com/user-guide/cli/tutorials/misc#categories) | Support | Interface |
|---|---|---|---|---|---|---|
| AWS | 662 | 86 | 50 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 25 | 16 | Official | UI, API, CLI |
| GCP | 110 | 20 | 22 | 12 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 11 | 11 | Official | UI, API, CLI |
| AWS | 621 | 86 | 47 | 19 | Official | UI, API, CLI |
| Azure | 191 | 22 | 21 | 16 | Official | UI, API, CLI |
| GCP | 109 | 20 | 19 | 12 | Official | UI, API, CLI |
| Kubernetes | 92 | 7 | 8 | 11 | Official | UI, API, CLI |
| GitHub | 24 | 3 | 2 | 5 | Official | UI, API, CLI |
| M365 | 144 | 10 | 9 | 10 | Official | UI, API, CLI |
| M365 | 111 | 10 | 6 | 10 | Official | UI, API, CLI |
| OCI | 52 | 14 | 5 | 10 | Official | UI, API, CLI |
| Alibaba Cloud | 63 | 9 | 6 | 9 | Official | UI, API, CLI |
| Cloudflare | 29 | 3 | 2 | 5 | Official | UI, API, CLI |
@@ -139,7 +139,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| MongoDB Atlas | 10 | 3 | 1 | 8 | Official | UI, API, CLI |
| LLM | [See `promptfoo` docs.](https://www.promptfoo.dev/docs/red-team/plugins/) | N/A | N/A | N/A | Official | CLI |
| Image | N/A | N/A | N/A | N/A | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 4 | 6 | Official | UI, API, CLI |
| Google Workspace | 65 | 11 | 3 | 6 | Official | UI, API, CLI |
| OpenStack | 34 | 5 | 1 | 9 | Official | UI, API, CLI |
| Vercel | 26 | 6 | 1 | 8 | Official | UI, API, CLI |
| Okta | 29 | 8 | 2 | 2 | Official | UI, API, CLI |
@@ -147,7 +147,7 @@ Every AWS provider scan will enqueue an Attack Paths ingestion job automatically
| Huawei Cloud [Contact us](https://prowler.com/contact) | 25 | 10 | 1 | 6 | Unofficial | CLI |
| E2E Networks [Contact us](https://prowler.com/contact) | 27 | 6 | 0 | 2 | Unofficial | CLI |
| Scaleway [Contact us](https://prowler.com/contact) | 1 | 1 | 1 | 1 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 8 | 2 | 1 | 3 | Unofficial | CLI |
| StackIT [Contact us](https://prowler.com/contact) | 7 | 2 | 1 | 3 | Unofficial | CLI |
| NHN | 6 | 2 | 2 | 0 | Unofficial | CLI |
> [!Note]
-4
View File
@@ -59,9 +59,5 @@ DJANGO_GITHUB_OAUTH_CLIENT_ID=""
DJANGO_GITHUB_OAUTH_CLIENT_SECRET=""
DJANGO_GITHUB_OAUTH_CALLBACK_URL=""
# Public base URL of the Prowler UI, used to link Jira issues back to findings.
# Leave empty to omit the link.
DJANGO_UI_BASE_URL=""
# Deletion Task Batch Size
DJANGO_DELETION_BATCH_SIZE=5000
-199
View File
@@ -4,205 +4,6 @@ All notable changes to the **Prowler API** are documented in this file.
<!-- changelog: release notes start -->
## [1.48.0] (Prowler v5.47.0)
### 🔐 Security
- pymongo to 4.18.3, dnspython to 2.9.0 and oauthlib to 4.0.0 in the API container image, with django-allauth from 65.15.0 to 65.19.7 because earlier releases cap oauthlib below 4, patching the BSON decoding and DNS compression pointer denial of service advisories and the OAuth device flow token disclosure [(#13185)](https://github.com/prowler-cloud/prowler/pull/13185)
---
## [1.47.0] (Prowler v5.46.0)
### 🐞 Fixed
- Compliance report PDF cover table wraps every value, so long account IDs such as OCI tenancy OCIDs no longer overflow their cell [(#13063)](https://github.com/prowler-cloud/prowler/pull/13063)
- Scan ingestion compares the serialized resource metadata with the stored value and serializes sets in a stable order, so unchanged resources no longer get their metadata, details and updated_at rewritten on every scan [(#13148)](https://github.com/prowler-cloud/prowler/pull/13148)
- Report generation shares the parsed resource metadata between the findings of a batch and releases each batch before building the next one [(#13149)](https://github.com/prowler-cloud/prowler/pull/13149)
### 🔐 Security
- `GET /resources/metadata`, `GET /resources/metadata/latest`, `GET /findings/metadata` and `GET /findings/metadata/latest` now honour the provider groups of the caller role instead of aggregating every provider in the tenant [(#12964)](https://github.com/prowler-cloud/prowler/pull/12964)
- Trivy from 0.75.0 to 0.76.0 in the API container image, built with Go 1.27.2 and x/net 0.60.0, patching the HTTP/2 and TLS CVEs bundled in Trivy 0.75.0 [(#13064)](https://github.com/prowler-cloud/prowler/pull/13064)
- Pillow from 12.3.0 to 13.0.0, certifi to 2026.10.19, aiohttp to 3.14.5 with yarl 1.25.1, uv to 0.13.4 and Trivy to 0.76.1 in the API container image, patching the Pillow JPEG decoder heap overflow, the compromised emSign root CAs bundled in certifi and uv, the aiohttp resource exhaustion advisories and the Go 1.27.2 stdlib CVE in the Trivy binary [(#13107)](https://github.com/prowler-cloud/prowler/pull/13107)
- Attack Paths custom queries strip block comments before the blocked-operation check and drop APOC virtual nodes and relationships from results, so `LOAD CSV` can no longer be split with a comment nor its response returned under a forged provider label [(#13120)](https://github.com/prowler-cloud/prowler/pull/13120)
- SAML SSO configuration endpoints require the `manage_account` permission instead of `manage_integrations`, since the configured IdP controls who signs in and which role they receive [(#13121)](https://github.com/prowler-cloud/prowler/pull/13121)
- djangorestframework from 3.15.2 to 3.18.3 in the API, patching the JSON parser charset and Accept header denial of service and the TokenProxy revocation bypass, and the API container image no longer ships the MicrosoftTeams build SBOM that Trivy read as installed zod and body-parser [(#13157)](https://github.com/prowler-cloud/prowler/pull/13157)
---
## [1.46.0] (Prowler v5.45.0)
### 🐞 Fixed
- OCI provider secrets keep the region as home region for credential validation and scans, instead of always using us-ashburn-1 [(#12865)](https://github.com/prowler-cloud/prowler/pull/12865)
- Gunicorn workers are now recycled after 10000 requests (plus up to 1000 of jitter) to cap memory growth, configurable with `GUNICORN_MAX_REQUESTS` and `GUNICORN_MAX_REQUESTS_JITTER` [(#13047)](https://github.com/prowler-cloud/prowler/pull/13047)
### 🔐 Security
- `pyjwt` from 2.13.0 to 2.14.0, patching GHSA-ffc3-869f-jxw9 [(#12911)](https://github.com/prowler-cloud/prowler/pull/12911)
- Sentry error events no longer include user identity, IP addresses, cookies, headers or request bodies, which could contain personal data or provider credentials [(#12912)](https://github.com/prowler-cloud/prowler/pull/12912)
- Kubernetes kubeconfig validation accepts only inline credentials and known cluster fields, rejecting file-path fields (`tokenFile`, `client-certificate`, `client-key`, `certificate-authority`), command-based authentication (`exec`, `cmd-path`), `auth-provider` directives, proxy URLs and unknown user or cluster keys; stored kubeconfigs are re-validated before each scan and connection test [(#12952)](https://github.com/prowler-cloud/prowler/pull/12952)
- `django` from 5.1.15 to 5.2.17, patching CVE-2026-15307 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
- `dulwich` from 1.2.5 to 1.2.9, patching GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf, GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
- `urllib3` from 2.7.0 to 2.8.0, patching CVE-2026-97687 and CVE-2026-97689 [(#12953)](https://github.com/prowler-cloud/prowler/pull/12953)
- `pymongo` from 4.15.1 to 4.18.2, patching CVE-2026-96748 and CVE-2026-96749 [(#12955)](https://github.com/prowler-cloud/prowler/pull/12955)
- `GET /api/v1/users/{id}` no longer exposes another user's memberships, roles or tenant names from tenants other than the one in the request token [(#12965)](https://github.com/prowler-cloud/prowler/pull/12965)
- Python base image from 3.12.13 to 3.12.15 and Trivy from 0.74.0 to 0.75.0 in the API container image, patching CVE-2026-19445, CVE-2026-19553 and the grpc-go and x/crypto CVEs bundled in Trivy [(#13009)](https://github.com/prowler-cloud/prowler/pull/13009)
- PowerShell from 7.5.11 to 7.5.12 in the API container image, bundling .NET runtime 9.0.21 [(#13037)](https://github.com/prowler-cloud/prowler/pull/13037)
---
## [1.45.0] (Prowler v5.44.0)
### 🚀 Added
- Scan output uploads and downloads can now target S3-compatible object storage such as MinIO directly via `DJANGO_OUTPUT_S3_AWS_ENDPOINT_URL`, instead of relying on process-wide AWS environment variables that also hijacked unrelated AWS API calls [(#12871)](https://github.com/prowler-cloud/prowler/pull/12871)
### 🔄 Changed
- Unify how every endpoint resolves a provider latest completed scan, so overlapping scans no longer make findings, compliance and mute rules read from different scans [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
### 🐞 Fixed
- Celery loggers are now declared explicitly in `custom_logging.py` so fatal worker errors are no longer silenced by `disable_existing_loggers=True`. All long-running services in `docker-compose.yml` now have `restart: unless-stopped` so containers recover automatically after unexpected crashes. [(#12465)](https://github.com/prowler-cloud/prowler/pull/12465)
- Scan report downloads from an S3 bucket with default SSE-KMS encryption no longer fail with an `InvalidArgument` error: when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is set, presigned download URLs are signed with AWS Signature Version 4 for that region [(#12746)](https://github.com/prowler-cloud/prowler/pull/12746)
- Adds a periodic sweep that drops orphaned Attack Paths temp Neo4j scan databases left behind when a worker or Neo4j crashes mid-scan, before they accumulate unbounded [(#12832)](https://github.com/prowler-cloud/prowler/pull/12832)
- Providers whose most recent completed scan has no `completed_at` timestamp are no longer missing from every endpoint that reports a provider's latest scan, which now falls back to scan creation order instead of skipping the provider [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- Resources no longer keep a stale failed findings count forever when a scoped or imported scan for the same provider completes after a full scan, which used to make the full scan skip its own cleanup [(#12858)](https://github.com/prowler-cloud/prowler/pull/12858)
- `POST /api/v1/scans` again returns the new scan id in the response `task_args`, which had been empty since the scan broker publish moved to transaction commit [(#12878)](https://github.com/prowler-cloud/prowler/pull/12878)
- API key authentication no longer locks the key row on every request and now throttles `last_used_at` updates to once per 60 seconds, preventing a hot key from serializing all its requests onto a single locked row [(#12882)](https://github.com/prowler-cloud/prowler/pull/12882)
- Provider deletion no longer fails when the provider has Attack Paths scans recorded on a sink that is no longer configured, such as Neptune after moving back to Neo4j [(#12894)](https://github.com/prowler-cloud/prowler/pull/12894)
### 🔐 Security
- `DELETE /api/v1/tasks/{id}` requires the permission of the operation that queued the task and rejects provider deletions, and `GET /api/v1/tasks` hides tasks of providers outside the visibility of the role [(#12893)](https://github.com/prowler-cloud/prowler/pull/12893)
---
## [1.44.0] (Prowler v5.43.0)
### 🐞 Fixed
- Report download URLs can be signed against a browser-reachable storage host via `DJANGO_OUTPUT_S3_AWS_PUBLIC_ENDPOINT_URL`, so downloads complete on deployments where storage is only reachable inside the container network [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
- A scan report download no longer fails with a server error when `DJANGO_OUTPUT_S3_AWS_DEFAULT_REGION` is unset, which is common on storage with no meaningful region [(#12552)](https://github.com/prowler-cloud/prowler/pull/12552)
- Lapsed pending invitations are reported as expired and no longer block a new invitation for the same email [(#12831)](https://github.com/prowler-cloud/prowler/pull/12831)
### 🔐 Security
- `libsqlite3-0`, `gzip`, `perl-base` and `libpcre2-8-0` upgraded in the API container image, patching high Debian CVEs [(#12804)](https://github.com/prowler-cloud/prowler/pull/12804)
- PowerShell from 7.5.9 to 7.5.11 in the API container image, bundling .NET runtime 9.0.20 and patching CVE-2026-62901 [(#12811)](https://github.com/prowler-cloud/prowler/pull/12811)
- Bumped `anyio` to 4.14.2 to resolve CVE-2026-63374 [(#12848)](https://github.com/prowler-cloud/prowler/pull/12848)
---
## [1.43.0] (Prowler v5.42.0)
### 🔄 Changed
- Speed up compliance overview ingestion by reading ThreatScore mappings from the compliance template instead of each finding, generating time-ordered `uuid7` row ids and grouping inserted rows by framework and requirement [(#12738)](https://github.com/prowler-cloud/prowler/pull/12738)
---
## [1.42.0] (Prowler v5.41.0)
### 🚀 Added
- Jira issues created from Prowler Cloud now carry the `prowler`, `prowler-<provider>`, `prowler-<severity>`, `prowler-<check-id>`, and `prowler-finding-<finding-uid>` labels, a link back to the finding when `DJANGO_UI_BASE_URL` is configured, and the tenant name [(#12540)](https://github.com/prowler-cloud/prowler/pull/12540)
### 🐞 Fixed
- `POST /api/v1/mute-rules` now updates only each affected provider's latest completed scan and future scans, preventing historical reaggregation from flooding Celery queues [(#12681)](https://github.com/prowler-cloud/prowler/pull/12681)
---
## [1.41.0] (Prowler v5.40.0)
### 🐞 Fixed
- `FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS` is now applied in months instead of days, and negative values are rejected [(#12580)](https://github.com/prowler-cloud/prowler/pull/12580)
### 🔐 Security
- `sqlparse` upgraded to 0.6.0, patching CVE-2026-54284, CVE-2026-59893, and CVE-2026-71491 [(#12509)](https://github.com/prowler-cloud/prowler/pull/12509)
- `openssl`, `libssl3t64` and `openssl-provider-legacy` upgraded to 3.5.7-1~deb13u2 in the API container image, patching ten high OpenSSL CVEs [(#12549)](https://github.com/prowler-cloud/prowler/pull/12549)
---
## [1.40.1] (Prowler v5.39.1)
### 🔄 Changed
- Bump alibabacloud-tea-openapi to 0.4.6, oci to 2.184.1 and pyopenssl to 26.4.0 to match the SDK; the cryptography override now names its actual blockers (azure-cli-core pins msal below 1.37, workos 8.3.0 requires cryptography 48) [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🐞 Fixed
- Pin zstd to 1.5.7.2; 1.5.7.3 was yanked from PyPI as not thread safe [(#12477)](https://github.com/prowler-cloud/prowler/pull/12477)
### 🔐 Security
- Trivy from v0.72.0 to v0.73.0 in the container image, fixing HIGH CVE-2026-46600 in the bundled `golang.org/x/net` [(#12445)](https://github.com/prowler-cloud/prowler/pull/12445)
- Trivy v0.74.0 and Debian util-linux 2.41.5-0+deb13u1 in the API container image, patching Go standard library vulnerabilities and CVE-2026-53615 [(#12470)](https://github.com/prowler-cloud/prowler/pull/12470)
---
## [1.40.0] (Prowler v5.39.0)
### 🔄 Changed
- `GET /api/v1/users/me` membership relationships identify the active tenant with `meta.active` for JWT and API key authentication [(#12388)](https://github.com/prowler-cloud/prowler/pull/12388)
### 🐞 Fixed
- Tenant deletion no longer leaves memberships partially removed when exclusive-user cleanup fails [(#12379)](https://github.com/prowler-cloud/prowler/pull/12379)
- `/api/v1/accounts/saml/{organization_slug}/acs/` rejects non-POST requests before SAML response processing [(#12393)](https://github.com/prowler-cloud/prowler/pull/12393)
- Social login derives a valid user name when identity providers omit the profile name [(#12413)](https://github.com/prowler-cloud/prowler/pull/12413)
---
## [1.39.0] (Prowler v5.38.0)
### 🚀 Added
- Attack Paths adds 20 AWS privilege-escalation detection queries from pathfinding.cloud, covering service PassRole escalations (Batch, Braket, Cognito Identity, ECS, EMR, EMR Serverless, GameLift, Glue, EC2 Image Builder, Kinesis Analytics, HealthOmics, EventBridge Scheduler, SSM, Step Functions), CodeDeploy and Step Functions existing-resource abuse, role permissions-boundary removal with role assumption, and IAM Identity Center permission-set policy injection [(#12237)](https://github.com/prowler-cloud/prowler/pull/12237)
- Attack Paths query metadata now carries an outcome (Code execution, Privilege escalation, Public exposure, or Resource inventory), exposed on the queries endpoint so the graph can show a terminal outcome node [(#12344)](https://github.com/prowler-cloud/prowler/pull/12344)
- Container images now ship an SBOM and build provenance as OCI attestations [(#12352)](https://github.com/prowler-cloud/prowler/pull/12352)
### 🔄 Changed
- Pin the container vulnerability scanner to Trivy v0.72.0, matching prowler-registry and partner-portal [(#12346)](https://github.com/prowler-cloud/prowler/pull/12346)
### 🐞 Fixed
- Compliance report output directory failures are now logged with the exception attached and fingerprinted by `errno` in Sentry, so `ENOSPC`, `ENOENT` and `EACCES` no longer share a single issue [(#12142)](https://github.com/prowler-cloud/prowler/pull/12142)
- Restored the SDK dependency to `@master` now that the dependency bumps have landed there, and regenerated the lock. The API image no longer builds against a temporary integration branch [(#12309)](https://github.com/prowler-cloud/prowler/pull/12309)
### 🔐 Security
- The API container image now verifies the checksum of every third-party binary it downloads (PowerShell, Trivy, zizmor) before installing it [(#12334)](https://github.com/prowler-cloud/prowler/pull/12334)
- Upgrade aiohttp to 3.14.3 to pick up the fix for CVE-2026-69244 [(#12340)](https://github.com/prowler-cloud/prowler/pull/12340)
- Upgrade cryptography to 50.0.0, closing CVE-2026-69247 and CVE-2026-69249 [(#12356)](https://github.com/prowler-cloud/prowler/pull/12356)
---
## [1.38.1] (Prowler v5.37.1)
### 🐞 Fixed
- Entra Conditional Access guest-user checks no longer report false FAILs in M365 scans: microsoft-kiota packages overridden to 1.9.10 so `guestOrExternalUserTypes` (a flags enum Graph serializes as a comma-separated string) deserializes correctly instead of returning an empty list [(#12315)](https://github.com/prowler-cloud/prowler/pull/12315)
### 🔐 Security
- The API container image now builds on Debian 13 (trixie), taking its critical CVE count from 18 to 4 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped PowerShell, Trivy and uv in the API container image, clearing 14 high-severity CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped `workos` and `pyopenssl` so the API can move to `cryptography` 48.0.1 [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Removed `gnupg` and `apt-transport-https` from the API container image [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- The API container image no longer ships `git`; removing it also dropped `perl`, `perl-modules`, `libperl` and `liberror-perl`, clearing 12 critical CVEs. Only `perl-base` remains, which Debian marks Essential and cannot be removed [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Removed `pip` from the API container image, clearing two high-severity CVEs in the vendored copies of `setuptools` and `msgpack` [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
- Bumped `pillow` to 12.3.0, `httplib2` to 0.32.0 and `pyasn1` to 0.6.4 to resolve known CVEs [(#12311)](https://github.com/prowler-cloud/prowler/pull/12311)
---
## [1.38.0] (Prowler v5.37.0)
### 🚀 Added
+8 -56
View File
@@ -1,43 +1,23 @@
FROM python:3.12.15-slim-trixie@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f AS build
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
LABEL maintainer="https://github.com/prowler-cloud/api"
ARG POWERSHELL_VERSION=7.5.12
ARG POWERSHELL_VERSION=7.5.0
ENV POWERSHELL_VERSION=${POWERSHELL_VERSION}
# Opt out of PowerShell telemetry (Application Insights -> dc.services.visualstudio.com)
ENV POWERSHELL_TELEMETRY_OPTOUT=1
ARG TRIVY_VERSION=0.76.1
ARG TRIVY_VERSION=0.71.2
ENV TRIVY_VERSION=${TRIVY_VERSION}
ARG ZIZMOR_VERSION=1.24.1
ENV ZIZMOR_VERSION=${ZIZMOR_VERSION}
# Pinned here, not fetched with the artefact: a compromised release ships its own checksum.
ARG TRIVY_SHA256_AMD64=c7cbe3137fb8a48a17574bede3dd6beb16742d1e9772929940fc90082c39d9c0
ARG TRIVY_SHA256_ARM64=e174dff2a6dba5e3ae41f8d0537a6324405bd08132497340304c6b20e0f16a1f
ARG POWERSHELL_SHA256_AMD64=db85ae1f45005ffd20e456890585167257151fa99406ccd7ab344f7cf3a766fd
ARG POWERSHELL_SHA256_ARM64=29eb382cd510e4bd246902650ff6cf9f3b56f959d1bd0be75e0a1e425177c831
ARG ZIZMOR_SHA256_AMD64=a8000f3c683319a523d3b20df0e75457ba591f049cfcbfa98966631b56733c03
ARG ZIZMOR_SHA256_ARM64=d66e37ef8a375fb07939c630ebf9709a6e0f20242bdc3faf672a7ed97e0b768d
# High CVEs fixed in Debian trixie but not yet in the pinned base image:
# openssl/libssl3t64/openssl-provider-legacy 3.5.7-1~deb13u2 CVE-2026-14456,
# -14457, -18798, -54874, -63072, -63073, -63074, -63075, -63076, -75803
# libsqlite3-0 3.46.1-7+deb13u2 CVE-2026-11822, -11824
# gzip 1.13-1+deb13u1 CVE-2026-41992
# perl-base 5.40.1-6+deb13u1 CVE-2026-42497, -48962, -57432
# libssh2-1t64 1.11.1-1+deb13u2 CVE-2026-58050
# libpcre2-8-0 10.46-1~deb13u2 CVE-2026-86145, -89161
# Taken as a targeted --only-upgrade rather than by moving the digest: the newest
# published python:3.12-slim-trixie carries the same vulnerable versions. The three
# openssl packages are flagged separately, so all are named.
# Drop each one once the base image ships its fixed version.
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
git \
libicu76 \
libicu72 \
gcc \
g++ \
make \
@@ -48,9 +28,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libtool \
libxslt1-dev \
python3-dev \
&& apt-get install -y --no-install-recommends --only-upgrade \
util-linux libssl3t64 openssl openssl-provider-legacy \
libsqlite3-0 gzip perl-base libssh2-1t64 libpcre2-8-0 \
git \
&& rm -rf /var/lib/apt/lists/*
# Install PowerShell
@@ -62,9 +40,6 @@ RUN ARCH=$(uname -m) && \
else \
echo "Unsupported architecture: $ARCH" && exit 1 ; \
fi && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$POWERSHELL_SHA256_AMD64" ; else EXPECT="$POWERSHELL_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/powershell.tar.gz" > /tmp/powershell.sha256 && \
sha256sum -c /tmp/powershell.sha256 && rm /tmp/powershell.sha256 && \
mkdir -p /opt/microsoft/powershell/7 && \
tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/7 && \
chmod +x /opt/microsoft/powershell/7/pwsh && \
@@ -81,9 +56,6 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for Trivy: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_${TRIVY_ARCH}.tar.gz" -O /tmp/trivy.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$TRIVY_SHA256_AMD64" ; else EXPECT="$TRIVY_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/trivy.tar.gz" > /tmp/trivy.sha256 && \
sha256sum -c /tmp/trivy.sha256 && rm /tmp/trivy.sha256 && \
tar zxf /tmp/trivy.tar.gz -C /tmp && \
mv /tmp/trivy /usr/local/bin/trivy && \
chmod +x /usr/local/bin/trivy && \
@@ -102,9 +74,6 @@ RUN ARCH=$(uname -m) && \
echo "Unsupported architecture for zizmor: $ARCH" && exit 1 ; \
fi && \
wget --progress=dot:giga "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-${ZIZMOR_ARCH}.tar.gz" -O /tmp/zizmor.tar.gz && \
if [ "$ARCH" = "x86_64" ]; then EXPECT="$ZIZMOR_SHA256_AMD64" ; else EXPECT="$ZIZMOR_SHA256_ARM64" ; fi && \
echo "$EXPECT /tmp/zizmor.tar.gz" > /tmp/zizmor.sha256 && \
sha256sum -c /tmp/zizmor.sha256 && rm /tmp/zizmor.sha256 && \
mkdir -p /tmp/zizmor-extract && \
tar zxf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract && \
mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor && \
@@ -125,7 +94,7 @@ RUN mkdir -p /tmp/prowler_api_output
COPY --chown=prowler:prowler pyproject.toml uv.lock ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir uv==0.13.4
pip install --no-cache-dir uv==0.11.14
ENV PATH="/home/prowler/.local/bin:$PATH"
@@ -134,42 +103,25 @@ RUN uv sync --locked --no-install-project && \
rm -rf ~/.cache/uv
# Invoked as a module so the base image's Python minor version is not baked
# into a site-packages path. Drop the MicrosoftTeams build SBOM: Trivy reports the npm
# tooling it declares as installed, though none ships (see .trivyignore.yaml).
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell && \
rm -rf /home/prowler/.local/share/powershell/Modules/MicrosoftTeams/*/_manifest
# into a site-packages path.
RUN .venv/bin/python -m prowler.providers.m365.lib.powershell.m365_powershell
USER root
# Remove build-only packages from the final image after Python dependencies are installed.
# git is only needed by uv sync for the `prowler @ git+...` dependency; purging it drops perl too.
# wget stays: the compose healthcheck shells out to it.
RUN apt-get purge -y --auto-remove \
gcc \
g++ \
git \
make \
libxml2-dev \
libxmlsec1-dev \
libxmlsec1-openssl \
libxmlsec1t64 \
libxmlsec1t64-openssl \
pkg-config \
libtool \
libxslt1-dev \
python3-dev \
gnupg \
apt-transport-https \
&& rm -rf /var/lib/apt/lists/*
# pip is build-only; the entrypoint runs uv against the prepared venv. uv stays.
RUN rm -rf /usr/local/lib/python3.12/site-packages/pip \
/usr/local/lib/python3.12/site-packages/pip-*.dist-info \
/home/prowler/.local/lib/python3.12/site-packages/pip \
/home/prowler/.local/lib/python3.12/site-packages/pip-*.dist-info \
/usr/local/bin/pip /usr/local/bin/pip3 /usr/local/bin/pip3.12 \
/home/prowler/.local/bin/pip /home/prowler/.local/bin/pip3 /home/prowler/.local/bin/pip3.12
USER prowler
COPY --chown=prowler:prowler src/backend/ ./backend/
+50 -69
View File
@@ -25,8 +25,8 @@ authors = [{name = "Prowler Engineering", email = "engineering@prowler.com"}]
dependencies = [
"celery (==5.6.2)",
"dj-rest-auth[with_social,jwt] (==7.0.1)",
"django (==5.2.17)",
"django-allauth[saml] (==65.19.7)",
"django (==5.1.15)",
"django-allauth[saml] (==65.15.0)",
"django-celery-beat (==2.9.0)",
"django-celery-results (==2.6.0)",
"django-cors-headers==4.4.0",
@@ -34,7 +34,7 @@ dependencies = [
"django-filter==24.3",
"django-guid==3.5.0",
"django-postgres-extra (==2.0.9)",
"djangorestframework==3.18.3",
"djangorestframework==3.15.2",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt (==5.5.1)",
"drf-nested-routers (==0.95.0)",
@@ -45,7 +45,7 @@ dependencies = [
"gunicorn==26.0.0",
"uvloop==0.22.1",
"lxml==6.1.0",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.47",
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.37",
"psycopg2-binary==2.9.9",
"pytest-celery[redis] (==1.3.0)",
"sentry-sdk[django] (==2.56.0)",
@@ -61,9 +61,9 @@ dependencies = [
"cartography (==0.138.1)",
"gevent (==25.9.1)",
"werkzeug (==3.1.7)",
"sqlparse (==0.6.0)",
"sqlparse (==0.5.5)",
"fonttools (==4.62.1)",
"uvicorn-worker (==0.4.0)"
"uvicorn-worker (==0.4.0)",
]
description = "Prowler's API (Django/DRF)"
license = "Apache-2.0"
@@ -71,7 +71,7 @@ name = "prowler-api"
package-mode = false
# Needed for the SDK compatibility
requires-python = ">=3.11,<3.13"
version = "1.48.0"
version = "1.38.0"
# Shared ruff baseline (kept in sync with mcp_server/pyproject.toml).
# target-version tracks this project's lowest supported Python.
@@ -92,7 +92,6 @@ extend-select = [
[tool.uv]
# Transitive pins matching master to avoid silent drift; bump deliberately.
# workos is api-only; pyopenssl matches master (PROWLER-2310).
constraint-dependencies = [
"about-time==4.2.1",
"adal==1.2.7",
@@ -100,7 +99,7 @@ constraint-dependencies = [
"aiobotocore==2.25.1",
"aiofiles==24.1.0",
"aiohappyeyeballs==2.6.1",
"aiohttp==3.14.5",
"aiohttp==3.14.0",
"aioitertools==0.13.0",
"aiosignal==1.4.0",
"alibabacloud-actiontrail20200706==2.4.1",
@@ -129,7 +128,7 @@ constraint-dependencies = [
"alibabacloud-sls20201230==5.9.0",
"alibabacloud-sts20150401==1.1.6",
"alibabacloud-tea==0.4.3",
"alibabacloud-tea-openapi==0.4.6",
"alibabacloud-tea-openapi==0.4.4",
"alibabacloud-tea-util==0.3.14",
"alibabacloud-tea-xml==0.0.3",
"alibabacloud-vpc20160428==6.13.0",
@@ -137,7 +136,7 @@ constraint-dependencies = [
"aliyun-log-fastpb==0.2.0",
"amqp==5.3.1",
"annotated-types==0.7.0",
"anyio==4.14.2",
"anyio==4.12.1",
"applicationinsights==0.11.10",
"apscheduler==3.11.2",
"argcomplete==3.5.3",
@@ -196,7 +195,7 @@ constraint-dependencies = [
"botocore==1.40.61",
"cartography==0.138.1",
"celery==5.6.2",
"certifi==2026.10.19",
"certifi==2026.1.4",
"cffi==2.0.0",
"charset-normalizer==3.4.4",
"circuitbreaker==2.1.3",
@@ -211,9 +210,9 @@ constraint-dependencies = [
"coverage==7.5.4",
"cron-descriptor==1.4.5",
"crowdstrike-falconpy==1.6.0",
"cryptography==50.0.0",
"cryptography==46.0.7",
"cycler==0.12.1",
"darabonba-core==1.0.8",
"darabonba-core==1.0.5",
"dash==3.1.1",
"dash-bootstrap-components==2.0.3",
"debugpy==1.8.20",
@@ -222,8 +221,8 @@ constraint-dependencies = [
"dill==0.4.1",
"distro==1.9.0",
"dj-rest-auth==7.0.1",
"django==5.2.17",
"django-allauth==65.19.7",
"django==5.1.15",
"django-allauth==65.15.0",
"django-celery-beat==2.9.0",
"django-celery-results==2.6.0",
"django-cors-headers==4.4.0",
@@ -234,10 +233,10 @@ constraint-dependencies = [
"django-postgres-extra==2.0.9",
"django-silk==5.3.2",
"django-timezone-field==7.2.1",
"djangorestframework==3.18.3",
"djangorestframework==3.15.2",
"djangorestframework-jsonapi==7.0.2",
"djangorestframework-simplejwt==5.5.1",
"dnspython==2.9.0",
"dnspython==2.8.0",
"docker==7.1.0",
"dogpile-cache==1.5.0",
"dparse==0.6.4",
@@ -246,7 +245,7 @@ constraint-dependencies = [
"drf-simple-apikey==2.2.1",
"drf-spectacular==0.27.2",
"drf-spectacular-jsonapi==0.5.1",
"dulwich==1.2.9",
"dulwich==1.2.5",
"duo-client==5.5.0",
"durationpy==0.10",
"email-validator==2.2.0",
@@ -278,7 +277,7 @@ constraint-dependencies = [
"h2==4.3.0",
"hpack==4.1.0",
"httpcore==1.0.9",
"httplib2==0.32.0",
"httplib2==0.31.2",
"httpx==0.28.1",
"humanfriendly==10.0",
"hyperframe==6.1.0",
@@ -315,13 +314,13 @@ constraint-dependencies = [
"matplotlib==3.10.8",
"mccabe==0.7.0",
"mdurl==0.1.2",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
"microsoft-kiota-http==1.9.10",
"microsoft-kiota-serialization-form==1.9.10",
"microsoft-kiota-serialization-json==1.9.10",
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"microsoft-kiota-abstractions==1.9.9",
"microsoft-kiota-authentication-azure==1.9.9",
"microsoft-kiota-http==1.9.9",
"microsoft-kiota-serialization-form==1.9.9",
"microsoft-kiota-serialization-json==1.9.9",
"microsoft-kiota-serialization-multipart==1.9.9",
"microsoft-kiota-serialization-text==1.9.9",
"microsoft-security-utilities-secret-masker==1.0.0b4",
"msal==1.35.0b1",
"msal-extensions==1.2.0",
@@ -337,8 +336,8 @@ constraint-dependencies = [
"nest-asyncio==1.6.0",
"nltk==3.9.4",
"numpy==2.2.6",
"oauthlib==4.0.0",
"oci==2.184.1",
"oauthlib==3.3.1",
"oci==2.169.0",
"openai==1.109.1",
"openstacksdk==4.2.0",
"opentelemetry-api==1.39.1",
@@ -350,7 +349,7 @@ constraint-dependencies = [
"pagerduty==6.1.0",
"pandas==2.2.3",
"pbr==7.0.3",
"pillow==13.0.0",
"pillow==12.2.0",
"pkginfo==1.12.1.2",
"platformdirs==4.5.1",
"plotly==6.5.2",
@@ -366,8 +365,8 @@ constraint-dependencies = [
"psycopg2-binary==2.9.9",
"py-deviceid==0.1.1",
"py-iam-expand==0.3.0",
"py-ocsf-models==0.10.0",
"pyasn1==0.6.4",
"py-ocsf-models==0.8.1",
"pyasn1==0.6.3",
"pyasn1-modules==0.4.2",
"pycodestyle==2.14.0",
"pycparser==3.0",
@@ -375,11 +374,11 @@ constraint-dependencies = [
"pydantic-core==2.41.5",
"pygithub==2.8.0",
"pygments==2.20.0",
"pyjwt==2.14.0",
"pyjwt==2.13.0",
"pylint==3.2.5",
"pymsalruntime==0.18.1",
"pynacl==1.6.2",
"pyopenssl==26.4.0",
"pyopenssl==26.0.0",
"pyparsing==3.3.2",
"pyreadline3==3.5.4",
"pysocks==1.7.1",
@@ -423,7 +422,7 @@ constraint-dependencies = [
"six==1.17.0",
"slack-sdk==3.39.0",
"sniffio==1.3.1",
"sqlparse==0.6.0",
"sqlparse==0.5.5",
"statsd==4.0.1",
"std-uritemplate==2.0.8",
"stevedore==5.6.0",
@@ -439,7 +438,7 @@ constraint-dependencies = [
"tzdata==2025.3",
"tzlocal==5.3.1",
"uritemplate==4.2.0",
"urllib3==2.8.0",
"urllib3==2.7.0",
"uuid6==2024.7.10",
"uvicorn==0.49.0",
"uvloop==0.22.1",
@@ -448,16 +447,16 @@ constraint-dependencies = [
"wcwidth==0.5.3",
"websocket-client==1.9.0",
"werkzeug==3.1.7",
"workos==8.3.0",
"workos==6.0.8",
"wrapt==1.17.3",
"xlsxwriter==3.2.9",
"xmlsec==1.3.17",
"xmltodict==1.0.2",
"yarl==1.25.1",
"yarl==1.22.0",
"zipp==3.23.0",
"zope-event==6.1",
"zope-interface==8.2",
"zstd==1.5.7.2"
"zstd==1.5.7.3"
]
# prowler@master needs okta==3.4.2, but cartography 0.138.1 requires okta<1.0.0.
# Attack Paths does not ingest Okta today, so override the Cartography
@@ -467,40 +466,22 @@ constraint-dependencies = [
# 0.138.1 requires azure-mgmt-containerservice>=41.0.0. Attack Paths does not
# ingest Azure today, so override the Cartography dependency to the Prowler pin.
#
# prowler@master hard-pins the microsoft-kiota packages in [project.dependencies].
# microsoft-kiota-serialization-json 1.9.10 fixes get_collection_of_enum_values
# returning [] for flags enums serialized as CSV strings (microsoft/kiota-python#515),
# which broke the Entra Conditional Access guest-user checks; the kiota packages
# release in lockstep and 1.9.10 requires microsoft-kiota-abstractions>=1.9.10, which
# a constraint cannot satisfy against the SDK's hard pins, so override the whole set
# to 1.9.10 until the SDK bump propagates to the pinned master rev.
# prowler@master hard-pins microsoft-kiota-abstractions==1.9.2 in [project.dependencies].
# The microsoft-kiota-http security bump to 1.9.9 (GHSA-7j59-v9qr-6fq9) requires
# microsoft-kiota-abstractions>=1.9.9, which a constraint cannot satisfy against the
# SDK's hard pin; override it to the patched, kiota-aligned version.
#
# The API installs prowler from a pinned master git rev whose dulwich and pyjwt pins trail
# the root pyproject.toml. dulwich 1.2.9 patches GHSA-5fqc-mrg8-w798, GHSA-8mcx-5rqc-vhmf,
# GHSA-8w8g-wq8h-fq33 and GHSA-cm62-gvxx-vmxx, and pyjwt 2.14.0 patches GHSA-ffc3-869f-jxw9,
# so override both until that rev is refreshed. pyjwt keeps the [crypto] extra because an
# prowler@master hard-pins dulwich==0.23.0 and pyjwt==2.12.1 in [project.dependencies].
# dulwich 1.2.5 patches GHSA-897w-fcg9-f6xj (arbitrary file write) and pyjwt 2.13.0
# patches PYSEC-2026-179 (HMAC/JWK key-confusion); a constraint cannot satisfy these
# against the SDK's hard pins, so override them to the patched versions until the SDK
# bump propagates to the pinned master rev. pyjwt keeps the [crypto] extra because an
# override replaces the whole requirement; bare pyjwt would drop it from the consumers
# that request pyjwt[crypto] and leave cryptography (needed for RS256) only transitive.
override-dependencies = [
"okta==3.4.2",
# huaweicloudsdkcore (via prowler) pins pymongo==4.15.1 exactly; CVE-2026-96748 and
# CVE-2026-96749 are fixed in 4.18.2. Only bson types are used, so the override is safe.
"pymongo==4.18.3",
# prowler requires cryptography==50.0.0. Two api-only dependencies still cap it below
# 49 and cannot move yet: msal, pinned exactly by azure-cli-core (2.83.0 -> 1.35.0b1,
# 2.89.1 -> 1.36.0, both <49; cartography needs azure-cli-core), and workos 8.3.0
# (~=48.0; workos 10.1.1+ needs ~=50.0 and is a separate SDK upgrade). This api is
# deployed from this lock with `uv sync --locked`, so the override applies to what runs.
# Remove when azure-cli-core pins msal>=1.37.0 and workos is on 10.x.
"cryptography==50.0.0",
"azure-mgmt-containerservice==34.1.0",
"microsoft-kiota-abstractions==1.9.10",
"microsoft-kiota-authentication-azure==1.9.10",
"microsoft-kiota-http==1.9.10",
"microsoft-kiota-serialization-form==1.9.10",
"microsoft-kiota-serialization-json==1.9.10",
"microsoft-kiota-serialization-multipart==1.9.10",
"microsoft-kiota-serialization-text==1.9.10",
"dulwich==1.2.9",
"pyjwt[crypto]==2.14.0"
"microsoft-kiota-abstractions==1.9.9",
"dulwich==1.2.5",
"pyjwt[crypto]==2.13.0"
]
+4 -27
View File
@@ -12,37 +12,11 @@ from api.models import (
UserRoleRelationship,
)
from api.utils import accept_invitation_for_user
from django.core.exceptions import ValidationError
from django.db import transaction
from django.http import HttpResponseForbidden
class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
@staticmethod
def _get_social_account_name(extra_data: dict, email: str) -> str:
name_field = User._meta.get_field("name")
for value in (
extra_data.get("name"),
extra_data.get("login"),
extra_data.get("username"),
email,
):
if not isinstance(value, str):
continue
candidate = value.strip()[: name_field.max_length].rstrip()
if not candidate:
continue
try:
name_field.run_validators(candidate)
except ValidationError:
continue
return candidate
raise ValueError("Social account does not provide a valid user identity.")
@staticmethod
def get_user_by_email(email: str):
try:
@@ -142,8 +116,11 @@ class ProwlerSocialAccountAdapter(DefaultSocialAccountAdapter):
if provider != "saml":
# Handle other providers (e.g., GitHub, Google)
user.name = self._get_social_account_name(extra, user.email)
user.save(using=MainRouter.admin_db)
social_account_name = extra.get("name")
if social_account_name:
user.name = social_account_name
user.save(using=MainRouter.admin_db)
invitation_token = self._get_invitation_token(request)
if invitation_token:
@@ -1,6 +1,5 @@
from api.attack_paths.queries import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
get_queries_for_provider,
get_query_by_id,
@@ -8,7 +7,6 @@ from api.attack_paths.queries import (
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
@@ -12,7 +12,7 @@ Two responsibilities:
Label-injection pipeline:
1. **Protect** string literals and comments (placeholder replacement).
1. **Protect** string literals and line comments (placeholder replacement).
2. **Split** by top-level clause keywords to track clause context.
3. **Pass A** - inject into *labeled* node patterns in ALL segments.
4. **Pass B** - inject into *bare* node patterns in MATCH segments only.
@@ -25,12 +25,10 @@ from rest_framework.exceptions import ValidationError
from tasks.jobs.attack_paths.config import get_provider_label
# Step 1 - String / comment protection
# Single combined regex: strings first, then line and block comments
# Single combined regex: strings first, then line comments
# The regex engine finds the leftmost match, so a string like 'https://prowler.com'
# is consumed as a string before the // inside it can match as a comment
_PROTECTED_RE = re.compile(
r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*|/\*[\s\S]*?\*/"
)
_PROTECTED_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|//[^\n]*")
# Step 2 - Clause splitting
# `OPTIONAL MATCH` must come before `MATCH` to avoid partial matching
@@ -145,26 +143,18 @@ def inject_label(cypher: str, label: str) -> str:
# Patterns that indicate SSRF or dangerous procedure calls
# Defense-in-depth layer - the primary control is `neo4j.READ_ACCESS`
# Neo4j accepts whitespace (and therefore comments) around the dots of a
# qualified procedure name, so `apoc . load` is `apoc.load`.
_BLOCKED_APOC_NAMESPACES = (
"load",
"import",
"export",
"cypher",
"systemdb",
"config",
"periodic",
"do",
"trigger",
"custom",
)
_BLOCKED_PATTERNS = [
re.compile(r"\bLOAD\s+CSV\b", re.IGNORECASE),
*(
re.compile(rf"\bapoc\s*\.\s*{namespace}\b", re.IGNORECASE)
for namespace in _BLOCKED_APOC_NAMESPACES
),
re.compile(r"\bapoc\.load\b", re.IGNORECASE),
re.compile(r"\bapoc\.import\b", re.IGNORECASE),
re.compile(r"\bapoc\.export\b", re.IGNORECASE),
re.compile(r"\bapoc\.cypher\b", re.IGNORECASE),
re.compile(r"\bapoc\.systemdb\b", re.IGNORECASE),
re.compile(r"\bapoc\.config\b", re.IGNORECASE),
re.compile(r"\bapoc\.periodic\b", re.IGNORECASE),
re.compile(r"\bapoc\.do\b", re.IGNORECASE),
re.compile(r"\bapoc\.trigger\b", re.IGNORECASE),
re.compile(r"\bapoc\.custom\b", re.IGNORECASE),
]
@@ -172,11 +162,10 @@ def validate_custom_query(cypher: str) -> None:
"""Reject queries containing known SSRF or dangerous procedure patterns.
Raises ValidationError if a blocked pattern is found.
String literals and comments are replaced by a space before matching:
literals cannot trigger false positives and a comment cannot split a
keyword pair such as `LOAD /**/ CSV`, which Neo4j still parses as `LOAD CSV`.
String literals and comments are stripped before matching to avoid
false positives.
"""
stripped = _PROTECTED_RE.sub(" ", cypher)
stripped = _PROTECTED_RE.sub("", cypher)
for pattern in _BLOCKED_PATTERNS:
if pattern.search(stripped):
raise ValidationError({"query": "Query contains a blocked operation"})
@@ -207,11 +207,6 @@ def drop_database(database: str) -> None:
sink_module.get_backend().drop_database(database)
def list_databases() -> list[str]:
"""List database names on the ingest cluster. Temp scan DBs always live here."""
return ingest.list_databases()
def drop_subgraph(database: str, provider_id: str) -> int:
return sink_module.get_backend().drop_subgraph(database, provider_id)
@@ -13,7 +13,6 @@ from api.attack_paths.ingest.driver import (
get_session,
get_uri,
init_driver,
list_databases,
run_cypher,
)
@@ -26,6 +25,5 @@ __all__ = [
"get_session",
"get_uri",
"init_driver",
"list_databases",
"run_cypher",
]
@@ -165,14 +165,6 @@ def drop_database(database: str) -> None:
session.run(f"DROP DATABASE `{database}` IF EXISTS DESTROY DATA")
def list_databases() -> list[str]:
"""List every database name on the Neo4j temp-database cluster."""
# A cluster returns one row per hosting server, so dedupe on name
with get_session() as session:
result = session.run("SHOW DATABASES YIELD name RETURN DISTINCT name")
return [record["name"] for record in result]
def clear_cache(database: str) -> None:
"""Best-effort cache clear for a Neo4j database."""
from api.attack_paths.database import GraphDatabaseQueryException
@@ -4,13 +4,11 @@ from api.attack_paths.queries.registry import (
)
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
AttackPathsQueryParameterDefinition,
)
__all__ = [
"AttackPathsQueryDefinition",
"AttackPathsQueryOutcome",
"AttackPathsQueryParameterDefinition",
"get_queries_for_provider",
"get_query_by_id",
File diff suppressed because it is too large. Load diff
@@ -1,38 +1,4 @@
from dataclasses import dataclass, field
from enum import Enum
@dataclass(frozen=True)
class AttackPathsQueryOutcomeMeta:
"""Display metadata for an outcome kind.
`label` and `partial` are properties of the outcome *kind*, not of an
individual query, so they live here once and every query just references a
kind. `partial` marks a latent/posture outcome (e.g. inventory) that the UI
renders as a marker rather than a full realized outcome.
"""
kind: str
label: str
partial: bool = False
class AttackPathsQueryOutcome(Enum):
"""The terminal impact an attack-path query leads to.
Set per query and exposed by the API so the UI can render the graph's
terminal outcome node. The taxonomy is shared with Prowler Hub's attack-path
diagram (whose terminal labels match these values).
"""
CODE_EXECUTION = AttackPathsQueryOutcomeMeta("code_execution", "Code execution")
PRIVILEGE_ESCALATION = AttackPathsQueryOutcomeMeta(
"privilege_escalation", "Privilege escalation"
)
PUBLIC_EXPOSURE = AttackPathsQueryOutcomeMeta("public_exposure", "Public exposure")
RESOURCE_INVENTORY = AttackPathsQueryOutcomeMeta(
"resource_inventory", "Resource inventory", partial=True
)
@dataclass
@@ -70,5 +36,4 @@ class AttackPathsQueryDefinition:
provider: str
cypher: str
attribution: AttackPathsQueryAttribution | None = None
outcome: AttackPathsQueryOutcome | None = None
parameters: list[AttackPathsQueryParameterDefinition] = field(default_factory=list)
@@ -45,10 +45,6 @@ class SinkDatabase(Protocol):
parameters: dict[str, Any] | None = None,
) -> neo4j.graph.Graph: ...
def is_persisted_element_id(self, element_id: str) -> bool:
"""Whether an element id belongs to a stored entity rather than one minted at query time."""
return True
def create_database(self, database: str) -> None: ...
def drop_database(self, database: str) -> None: ...
@@ -7,7 +7,6 @@ path while phase-1 drains tenant DBs.
import atexit
import logging
import re
import threading
import time
from collections.abc import Iterator
@@ -187,13 +186,6 @@ class Neo4jSink(SinkDatabase):
return session.execute_read(_run)
# APOC virtual nodes report a bare negative number and virtual
# relationships a random UUID, neither in the stored-entity format.
_PERSISTED_ELEMENT_ID_RE = re.compile(r"^\d+:[^:]+:\d+$")
def is_persisted_element_id(self, element_id: str) -> bool:
return bool(self._PERSISTED_ELEMENT_ID_RE.match(element_id))
def create_database(self, database: str) -> None:
with self.get_session() as session:
session.run(
@@ -15,7 +15,6 @@ from api.attack_paths.queries.schema import (
RAW_SCHEMA_URL,
get_cartography_schema_query,
)
from api.attack_paths.sink.base import SinkDatabase
from api.models import AttackPathsScan
from config.custom_logging import BackendLogger
from config.env import env
@@ -136,7 +135,7 @@ def execute_query(
cypher = inject_provider_label(cypher, provider_id)
graph = backend.execute_read_query(database_name, cypher, parameters)
return _serialize_graph(graph, provider_id, backend)
return _serialize_graph(graph, provider_id)
except graph_database.WriteQueryNotAllowedException:
raise PermissionDenied(
@@ -175,8 +174,6 @@ def execute_custom_query(
# 1. `neo4j.READ_ACCESS` - prevents mutations at the driver level
# 2. `inject_provider_label()` - regex-based label injection scopes node patterns
# 3. `_serialize_graph()` - post-query filter drops nodes without the provider label
# and any node or relationship the backend did not store (APOC virtual
# entities can carry an arbitrary label and arbitrary properties)
# 4. `USING QUERY:TIMEOUTMILLISECONDS` on Neptune - server-side runaway cutoff
#
# Layer 2 is best-effort (regex can't fully parse Cypher);
@@ -196,7 +193,7 @@ def execute_custom_query(
try:
graph = backend.execute_read_query(database_name, cypher, None)
serialized = _serialize_graph(graph, provider_id, backend)
serialized = _serialize_graph(graph, provider_id)
return _truncate_graph(serialized)
except graph_database.ClientStatementException as exc:
@@ -268,15 +265,13 @@ def _truncate_graph(graph: dict[str, Any]) -> dict[str, Any]:
return graph
def _serialize_graph(graph, provider_id: str, backend: SinkDatabase) -> dict[str, Any]:
def _serialize_graph(graph, provider_id: str) -> dict[str, Any]:
provider_label = get_provider_label(provider_id)
nodes = []
kept_node_ids = set()
for node in graph.nodes:
if provider_label not in node.labels or not backend.is_persisted_element_id(
node.element_id
):
if provider_label not in node.labels:
continue
kept_node_ids.add(node.element_id)
@@ -291,7 +286,7 @@ def _serialize_graph(graph, provider_id: str, backend: SinkDatabase) -> dict[str
filtered_count = len(graph.nodes) - len(nodes)
if filtered_count > 0:
logger.debug(
f"Filtered {filtered_count} nodes without provider label {provider_label} or not stored"
f"Filtered {filtered_count} nodes without provider label {provider_label}"
)
relationships = []
@@ -299,7 +294,6 @@ def _serialize_graph(graph, provider_id: str, backend: SinkDatabase) -> dict[str
if (
relationship.start_node.element_id not in kept_node_ids
or relationship.end_node.element_id not in kept_node_ids
or not backend.is_persisted_element_id(relationship.element_id)
):
continue
+33 -45
View File
@@ -1,5 +1,4 @@
import logging
from datetime import timedelta
from math import isfinite
from uuid import UUID
@@ -7,7 +6,7 @@ from api.db_router import MainRouter
from api.models import TenantAPIKey, TenantAPIKeyManager
from cryptography.fernet import InvalidToken
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from django.db import transaction
from django.utils import timezone
from drf_simple_apikey.backends import APIKeyAuthentication as BaseAPIKeyAuth
from drf_simple_apikey.crypto import get_crypto
@@ -19,15 +18,12 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
logger = logging.getLogger(__name__)
# Writing on every request makes all requests of a busy key contend on one row
API_KEY_LAST_USED_AT_THROTTLE_SECONDS = 60
class OrphanedAPIKeyError(Exception):
"""Raised when an API key outlived the user that owns it.
The revocation is written by a plain `update()` before this is raised, so it is
already persisted by the time `authenticate` catches it and rejects the request.
Handled by `authenticate`, which commits the revocation written while detecting it
and then rejects the request with `AuthenticationFailed`.
"""
@@ -41,9 +37,8 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
"""
Override to use admin connection, bypassing RLS during authentication.
Returns the validated API key row from a single read. `authenticate` builds
the auth claims from that same row instead of looking it up again, so a key
revoked or orphaned right after validation can't still authenticate.
Returns the validated API key row, locked with `select_for_update`, so callers
must run inside `transaction.atomic(using=MainRouter.admin_db)`.
"""
try:
payload = self.key_crypto.decrypt(key)
@@ -72,11 +67,9 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
raise AuthenticationFailed("API Key has already expired.")
try:
# Loading `entity` in the same query keeps a user deleted after this read
# from turning the later `api_key.entity` access into a 500
api_key = (
self.model.objects.using(MainRouter.admin_db)
.select_related("entity")
.select_for_update()
.get(id=api_key_pk)
)
except ObjectDoesNotExist:
@@ -92,9 +85,8 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
# Revoke it as well, so it stops showing up as active and later attempts fail
# the `revoked` check above like any other revoked key.
if api_key.entity_id is None:
self.model.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).update(revoked=True)
api_key.revoked = True
api_key.save(update_fields=["revoked"], using=MainRouter.admin_db)
logger.warning(
"Revoked orphaned API key: prefix=%s tenant=%s",
api_key.prefix,
@@ -120,38 +112,34 @@ class TenantAPIKeyAuthentication(BaseAPIKeyAuth):
except ValueError:
raise AuthenticationFailed("Invalid API Key.")
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
raise AuthenticationFailed("No entity matching this api key.")
# Validation, the `last_used_at` update and the auth claims all read the same
# row, locked until the transaction ends. Looking the key up a second time to
# build the claims used to leave a window where a key revoked or orphaned right
# after passing validation still authenticated.
with transaction.atomic(using=MainRouter.admin_db):
try:
api_key = self._authenticate_credentials(request, key)
except OrphanedAPIKeyError:
# Rejected below instead of here: leaving the block normally commits
# the revocation `_authenticate_credentials` wrote, while raising from
# inside would roll it back.
pass
else:
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
# The prefix used to be checked by the second lookup
if api_key.prefix != prefix:
raise AuthenticationFailed("Invalid API Key.")
api_key.last_used_at = timezone.now()
api_key.save(update_fields=["last_used_at"], using=MainRouter.admin_db)
self._throttled_touch_last_used_at(api_key)
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
entity = api_key.entity
return entity, {
"tenant_id": str(api_key.tenant_id),
"sub": str(entity.id),
"api_key_prefix": api_key.prefix,
}
@staticmethod
def _throttled_touch_last_used_at(api_key: TenantAPIKey) -> None:
"""Write `last_used_at` at most once per throttle interval, without locking the row."""
now = timezone.now()
stale_before = now - timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS)
if api_key.last_used_at is not None and api_key.last_used_at >= stale_before:
return
TenantAPIKey.objects.using(MainRouter.admin_db).filter(
id=api_key.id, revoked=False
).filter(
Q(last_used_at__isnull=True) | Q(last_used_at__lt=stale_before)
).update(last_used_at=now)
raise AuthenticationFailed("No entity matching this api key.")
class CombinedJWTOrAPIKeyAuthentication(BaseAuthentication):
+1 -1
View File
@@ -409,7 +409,7 @@ def batch_delete(tenant_id, queryset, batch_size=settings.DJANGO_DELETION_BATCH_
Args:
tenant_id (str): Tenant ID the queryset belongs to.
queryset: The queryset of objects to delete.
queryset (QuerySet): The queryset of objects to delete.
batch_size (int): The number of objects to delete in each batch.
Returns:
+2 -14
View File
@@ -1439,20 +1439,8 @@ class InvitationFilter(FilterSet):
inserted_at = DateFilter(field_name="inserted_at", lookup_expr="date")
updated_at = DateFilter(field_name="updated_at", lookup_expr="date")
expires_at = DateFilter(field_name="expires_at", lookup_expr="date")
state = ChoiceFilter(choices=Invitation.State.choices, method="filter_state")
state__in = ChoiceInFilter(
choices=Invitation.State.choices, lookup_expr="in", method="filter_state_in"
)
def filter_state(self, queryset, name, value):
return self.filter_state_in(queryset, name, [value])
def filter_state_in(self, queryset, name, value):
lapsed = Invitation.lapsed_q()
query = Q(state__in=value) & ~lapsed
if Invitation.State.EXPIRED in value:
query |= lapsed
return queryset.filter(query)
state = ChoiceFilter(choices=Invitation.State.choices)
state__in = ChoiceInFilter(choices=Invitation.State.choices, lookup_expr="in")
class Meta:
model = Invitation
@@ -1,116 +0,0 @@
import uuid
import api.rls
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("api", "0097_attack_paths_scan_db_defaults"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="TenantOnboardingProfile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("inserted_at", models.DateTimeField(auto_now_add=True)),
(
"declared_cloud_accounts",
models.CharField(
blank=True,
choices=[
("1", "1"),
("2-10", "2-10"),
("11-50", "11-50"),
("51-200", "51-200"),
("200+", "200+"),
],
max_length=16,
null=True,
),
),
(
"declared_role",
models.CharField(
blank=True,
choices=[
("security", "Security"),
("devops_platform", "DevOps / Platform"),
("developer", "Developer"),
("compliance_grc", "Compliance / GRC"),
("other", "Other"),
],
max_length=32,
null=True,
),
),
(
"declared_seniority",
models.CharField(
blank=True,
choices=[
("practitioner", "Practitioner / IC"),
("lead", "Team lead / Manager"),
("director", "Director / Head of"),
("executive", "VP / C-level"),
("founder", "Founder / Owner"),
],
max_length=32,
null=True,
),
),
("skipped", models.BooleanField(default=False)),
(
"submitted_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="tenant_onboarding_profiles",
related_query_name="tenant_onboarding_profile",
to=settings.AUTH_USER_MODEL,
),
),
(
"tenant",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="api.tenant"
),
),
],
options={
"db_table": "tenant_onboarding_profiles",
"abstract": False,
},
),
migrations.AddConstraint(
model_name="tenantonboardingprofile",
constraint=models.UniqueConstraint(
fields=("tenant_id",), name="unique_tenant_onboarding_profile"
),
),
migrations.AddConstraint(
model_name="tenantonboardingprofile",
# `statements` written out explicitly: RowLevelSecurityConstraint
# .deconstruct() does not serialize it, so an autogenerated
# migration falls back to ["SELECT"] and leaves the table without
# INSERT/UPDATE/DELETE policies.
constraint=api.rls.RowLevelSecurityConstraint(
"tenant_id",
name="rls_on_tenantonboardingprofile",
statements=["SELECT", "INSERT", "UPDATE", "DELETE"],
),
),
]
@@ -1,15 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
# The onboarding profile step was reverted after 0098 had been merged, so
# the table goes away through a new migration rather than by deleting 0098.
dependencies = [
("api", "0098_tenant_onboarding_profile"),
]
operations = [
migrations.DeleteModel(
name="TenantOnboardingProfile",
),
]
@@ -1,48 +0,0 @@
from django.db import migrations
TASK_NAME = "attack-paths-reap-orphaned-tmp-databases"
INTERVAL_HOURS = 6
def create_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
schedule, _ = IntervalSchedule.objects.get_or_create(
every=INTERVAL_HOURS,
period="hours",
)
PeriodicTask.objects.update_or_create(
name=TASK_NAME,
defaults={
"task": TASK_NAME,
"interval": schedule,
"enabled": True,
},
)
def delete_periodic_task(apps, schema_editor):
IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule")
PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
PeriodicTask.objects.filter(name=TASK_NAME).delete()
# Clean up the schedule if no other task references it
IntervalSchedule.objects.filter(
every=INTERVAL_HOURS,
period="hours",
periodictask__isnull=True,
).delete()
class Migration(migrations.Migration):
dependencies = [
("api", "0099_delete_tenant_onboarding_profile"),
("django_celery_beat", "0019_alter_periodictasks_options"),
]
operations = [
migrations.RunPython(create_periodic_task, delete_periodic_task),
]
+2 -74
View File
@@ -617,66 +617,9 @@ class Task(RowLevelSecurityProtectedModel):
resource_name = "tasks"
class ScanQuerySet(models.QuerySet):
"""Shared selectors for "the latest scan of a provider".
The queryset must already be scoped by the caller: manager, tenant, RBAC,
providers and database alias.
"""
# How "which completed scan is the provider's current one" is ordered.
LATEST_ORDER_BY = (
models.F("completed_at").desc(nulls_last=True),
models.F("inserted_at").desc(),
models.F("id").desc(),
)
def _eligible_for_latest(self) -> "ScanQuerySet":
"""Restrict to the scans that may be a provider's latest.
Returns:
ScanQuerySet: The completed scans.
"""
return self.filter(state=StateChoices.COMPLETED)
def latest_per_provider(self) -> "ScanQuerySet":
"""Pick each provider's latest scan with `DISTINCT ON (provider_id)`.
Returns:
ScanQuerySet: One scan per provider, the latest one.
"""
return (
self._eligible_for_latest()
.order_by("provider_id", *self.LATEST_ORDER_BY)
.distinct("provider_id")
)
def latest_ids_per_provider(self) -> list[UUID]:
"""Evaluate `latest_per_provider` and return the scan ids.
The ids are materialised so callers can pass them as a literal `IN`
list; as a subquery Postgres misestimates the row count and picks a
slow nested loop.
Returns:
list[UUID]: The id of each provider's latest scan.
"""
return list(self.latest_per_provider().values_list("id", flat=True))
def latest_first(self) -> "ScanQuerySet":
"""Order eligible scans newest first, without deduplicating per provider.
Expects the queryset to be already filtered to a single provider.
Returns:
ScanQuerySet: The eligible scans, latest first.
"""
return self._eligible_for_latest().order_by(*self.LATEST_ORDER_BY)
class Scan(RowLevelSecurityProtectedModel):
objects = ActiveProviderManager.from_queryset(ScanQuerySet)()
all_objects = ScanQuerySet.as_manager()
objects = ActiveProviderManager()
all_objects = models.Manager()
_SCOPING_SCANNER_ARG_KEYS_CACHE: tuple[str, ...] | None = None
@@ -783,12 +726,6 @@ class Scan(RowLevelSecurityProtectedModel):
name="scans_prov_state_ins_desc_idx",
),
# TODO This might replace `scans_prov_state_ins_desc_idx` completely. Review usage
# Since `ScanQuerySet`, no code path reads a provider's
# completed scans by `-inserted_at`. The only query left that
# matches this index (and `scans_prov_state_ins_desc_idx` above)
# is `GET /scans?filter[provider]=…&filter[state]=completed` with
# the default sort. Both are candidates to drop in a follow-up
# once production `pg_stat_user_indexes.idx_scan` confirms it.
models.Index(
fields=["tenant_id", "provider_id", "-inserted_at"],
condition=Q(state=StateChoices.COMPLETED),
@@ -1443,15 +1380,6 @@ class Invitation(RowLevelSecurityProtectedModel):
self.email = self.email.strip().lower()
super().save(*args, **kwargs)
@classmethod
def lapsed_q(cls):
"""Pending invitations whose expiry date has already passed."""
return Q(state=cls.State.PENDING, expires_at__lte=datetime.now(UTC))
@property
def is_lapsed(self):
return self.state == self.State.PENDING and self.expires_at <= datetime.now(UTC)
class Meta(RowLevelSecurityProtectedModel.Meta):
db_table = "invitations"
+5 -13
View File
@@ -6,7 +6,6 @@ from api.rls import RowLevelSecurityConstraint
from api.uuid_utils import datetime_to_uuid7
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import (
PostgresPartitioningError,
PostgresPartitioningManager,
@@ -154,17 +153,10 @@ class PostgresUUIDv7PartitioningStrategy(PostgresRangePartitioningStrategy):
)
def relative_months_or_none(value):
# A negative value would set the cutoff in the future and delete every
# partition, so it is rejected rather than silently ignored.
if value is not None and value < 0:
raise ImproperlyConfigured(
"FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS must not be negative; "
"leave it unset or use 0 to keep partitions indefinitely"
)
if not value:
def relative_days_or_none(value):
if value is None:
return None
return relativedelta(months=value)
return relativedelta(days=value)
#
@@ -181,7 +173,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_months_or_none(
max_age=relative_days_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
@@ -197,7 +189,7 @@ manager = PostgresPartitioningManager(
months=settings.FINDINGS_TABLE_PARTITION_MONTHS
),
count=settings.FINDINGS_TABLE_PARTITION_COUNT,
max_age=relative_months_or_none(
max_age=relative_days_or_none(
settings.FINDINGS_TABLE_PARTITION_MAX_AGE_MONTHS
),
name_format="%Y_%b",
+11 -66
View File
@@ -1,7 +1,7 @@
from enum import Enum
from api.db_router import MainRouter
from api.models import Integration, Provider, Role, Task, User
from api.models import Integration, Provider, Role, User
from django.db.models import Q, QuerySet
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission
@@ -17,50 +17,6 @@ class Permissions(Enum):
UNLIMITED_VISIBILITY = "unlimited_visibility"
# Revoking a task needs the permission of the operation that queued it.
# None and unmapped names are not revocable; a revoked provider deletion
# would leave the provider soft-deleted with nothing re-queuing the cleanup.
TASK_REVOKE_PERMISSIONS: dict[str, list[Permissions] | None] = {
"provider-connection-check": [Permissions.MANAGE_PROVIDERS],
"provider-deletion": None,
"integration-connection-check": [Permissions.MANAGE_INTEGRATIONS],
"integration-s3": [Permissions.MANAGE_INTEGRATIONS],
"integration-security-hub": [Permissions.MANAGE_INTEGRATIONS],
"integration-jira": [Permissions.MANAGE_INTEGRATIONS],
"scan-perform": [Permissions.MANAGE_SCANS],
"scan-perform-scheduled": [Permissions.MANAGE_SCANS],
"scan-compliance-overviews": [Permissions.MANAGE_SCANS],
"scan-compliance-reports": [Permissions.MANAGE_SCANS],
"scan-finding-group-summaries": [Permissions.MANAGE_SCANS],
"scan-report": [Permissions.MANAGE_SCANS],
"attack-paths-scan-perform": [Permissions.MANAGE_SCANS],
"findings-mute-latest-scans": [Permissions.MANAGE_SCANS],
"lighthouse-connection-check": [],
"lighthouse-provider-connection-check": [],
"lighthouse-provider-models-refresh": [],
}
def get_user_roles(user: User, tenant_id: str) -> list[Role]:
"""Return every role assigned to the user in the tenant."""
return list(
User.objects.using(MainRouter.admin_db)
.get(id=user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
def roles_have_permissions(
roles: list[Role], required_permissions: list[Permissions]
) -> bool:
"""Return True when every required permission is granted by at least one role."""
return all(
any(getattr(role, permission.value, False) for role in roles)
for permission in required_permissions
)
class HasPermissions(BasePermission):
"""
Custom permission to check if the user's role has the required permissions.
@@ -78,11 +34,19 @@ class HasPermissions(BasePermission):
if not tenant_id:
return False
user_roles = get_user_roles(request.user, tenant_id)
user_roles = list(
User.objects.using(MainRouter.admin_db)
.get(id=request.user.id)
.roles.using(MainRouter.admin_db)
.filter(tenant_id=tenant_id)
)
if not user_roles:
return False
return roles_have_permissions(user_roles, required_permissions)
return all(
any(getattr(role, permission.value, False) for role in user_roles)
for permission in required_permissions
)
def get_role(user: User, tenant_id: str) -> Role:
@@ -121,25 +85,6 @@ def get_providers(role: Role) -> QuerySet[Provider]:
).distinct()
def get_tasks(role: Role) -> QuerySet[Task]:
"""Return the tasks visible to the role: tenant-wide ones and those of its providers."""
queryset = Task.objects.filter(tenant_id=role.tenant_id)
if role.unlimited_visibility:
return queryset
# Task has no provider FK, so match provider ids inside the stored kwargs.
# all_objects keeps a soft-deleted provider visible to its own groups, so the
# role that queued its deletion can still follow the task.
hidden = Q()
for provider_id in (
Provider.all_objects.filter(tenant_id=role.tenant_id)
.exclude(provider_groups__in=role.provider_groups.all())
.values_list("id", flat=True)
):
hidden |= Q(task_runner_task__task_kwargs__contains=str(provider_id))
return queryset.exclude(hidden) if hidden else queryset
def get_integrations(
role: Role, providers: QuerySet[Provider] | None = None
) -> QuerySet[Integration]:
+4 -9
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.48.0
version: 1.38.0
description: |-
Prowler API specification.
@@ -14823,9 +14823,7 @@ paths:
get:
operationId: api_v1_tasks_list
description: Retrieve a list of all tasks with options for filtering by name,
state, and other criteria. Tasks that reference a provider are only returned
when the role can access it; tasks without a provider reference are returned
for every role.
state, and other criteria.
summary: List all tasks
parameters:
- in: query
@@ -14924,8 +14922,7 @@ paths:
/api/v1/tasks/{id}:
get:
operationId: api_v1_tasks_retrieve
description: Fetch detailed information about a specific task by its ID. Tasks
tied to a provider outside the visibility of the role are not found.
description: Fetch detailed information about a specific task by its ID.
summary: Retrieve data from a specific task
parameters:
- in: query
@@ -14966,9 +14963,7 @@ paths:
delete:
operationId: api_v1_tasks_destroy
description: Try to revoke a task using its ID. Only tasks that are not yet
in progress can be revoked, and the caller needs the same permission as the
operation that queued the task (for example MANAGE_SCANS for a scan). Provider
deletions cannot be revoked.
in progress can be revoked.
summary: Revoke a task
parameters:
- in: path
@@ -1,9 +1,9 @@
import json
import time
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from api.authentication import API_KEY_LAST_USED_AT_THROTTLE_SECONDS
from api.db_router import MainRouter
from api.models import Membership, Role, TenantAPIKey, User, UserRoleRelationship
from api.signals import revoke_membership_api_keys, revoke_user_api_keys
@@ -11,7 +11,6 @@ from conftest import TEST_PASSWORD, get_api_tokens, get_authorization_header
from django.db.utils import ConnectionDoesNotExist
from django.urls import reverse
from drf_simple_apikey.crypto import get_crypto
from freezegun import freeze_time
from rest_framework.test import APIClient
from rest_framework_simplejwt.token_blacklist.models import (
BlacklistedToken,
@@ -528,7 +527,7 @@ class TestAPIKeyAuthentication:
def test_last_used_at_tracking(
self, create_test_user, tenants_fixture, api_keys_fixture
):
"""Verify last_used_at timestamp is set on first use and throttled after that."""
"""Verify last_used_at timestamp updates on each authentication."""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -537,11 +536,7 @@ class TestAPIKeyAuthentication:
# Use API key to authenticate
api_key_headers = get_api_key_header(api_key._raw_key)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert first_response.status_code == 200
# Reload from database and check last_used_at is set
@@ -549,23 +544,17 @@ class TestAPIKeyAuthentication:
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Using the same key again within the throttle interval does not rewrite it
# Use the same key again after a small delay
time.sleep(0.1)
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert second_response.status_code == 200
# Reload and verify last_used_at was updated
api_key.refresh_from_db()
assert api_key.last_used_at == first_used_at
# Past the throttle interval, the next use refreshes it
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
third_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
assert third_response.status_code == 200
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
second_used_at = api_key.last_used_at
assert second_used_at is not None
assert second_used_at > first_used_at
@pytest.mark.django_db
@@ -1452,7 +1441,6 @@ class TestAPIKeyRLSBypass:
The update to last_used_at during authentication must also use the
admin database since it occurs before RLS context is established.
Past the throttle interval, using the key again refreshes the timestamp.
"""
client = APIClient()
api_key = api_keys_fixture[0]
@@ -1460,11 +1448,7 @@ class TestAPIKeyRLSBypass:
assert api_key.last_used_at is None
api_key_headers = get_api_key_header(api_key._raw_key)
start = datetime.now(UTC)
with freeze_time(start):
first_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
first_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert first_response.status_code == 200
@@ -1472,11 +1456,9 @@ class TestAPIKeyRLSBypass:
first_timestamp = api_key.last_used_at
assert first_timestamp is not None
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
second_response = client.get(
reverse("provider-list"), headers=api_key_headers
)
time.sleep(0.1)
second_response = client.get(reverse("provider-list"), headers=api_key_headers)
assert second_response.status_code == 200
api_key.refresh_from_db()
@@ -1608,8 +1590,8 @@ class TestAPIKeyMultiTenantWorkflows:
tenant1 = tenants_fixture[0]
tenant2 = tenants_fixture[1]
membership1 = Membership.objects.create(user=user, tenant=tenant1)
membership2 = Membership.objects.create(user=user, tenant=tenant2)
Membership.objects.create(user=user, tenant=tenant1)
Membership.objects.create(user=user, tenant=tenant2)
role1 = Role.objects.create(
tenant_id=tenant1.id,
@@ -1664,27 +1646,6 @@ class TestAPIKeyMultiTenantWorkflows:
assert me_response1.json()["data"]["id"] == str(user.id)
assert me_response2.json()["data"]["id"] == str(user.id)
memberships1 = {
item["id"]: item["meta"]["active"]
for item in me_response1.json()["data"]["relationships"]["memberships"][
"data"
]
}
memberships2 = {
item["id"]: item["meta"]["active"]
for item in me_response2.json()["data"]["relationships"]["memberships"][
"data"
]
}
assert memberships1 == {
str(membership1.id): True,
str(membership2.id): False,
}
assert memberships2 == {
str(membership1.id): False,
str(membership2.id): True,
}
def test_api_key_cannot_access_different_tenant_resources(
self, tenants_fixture, aws_provider
):
-104
View File
@@ -111,110 +111,6 @@ def _verify_local_email(user):
)
def test_social_account_name_falls_back_to_login_for_blank_name():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": " ", "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
@pytest.mark.parametrize("provider_name", [None, "", " ", 123, ["name"]])
def test_social_account_name_ignores_unusable_provider_names(provider_name):
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"name": provider_name, "login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_uses_login_when_name_is_missing():
adapter = ProwlerSocialAccountAdapter()
name = adapter._get_social_account_name(
{"login": "octocat"},
"verified@example.com",
)
assert name == "octocat"
def test_social_account_name_falls_back_to_username_then_email():
adapter = ProwlerSocialAccountAdapter()
username_name = adapter._get_social_account_name(
{"name": "ab", "login": None, "username": " monalisa "},
"verified@example.com",
)
email_name = adapter._get_social_account_name({}, " verified@example.com ")
assert username_name == "monalisa"
assert email_name == "verified@example.com"
def test_social_account_name_trims_and_limits_provider_name():
adapter = ProwlerSocialAccountAdapter()
max_length = User._meta.get_field("name").max_length
trimmed_name = adapter._get_social_account_name(
{"name": " Ada Lovelace "},
"verified@example.com",
)
limited_name = adapter._get_social_account_name(
{"name": "a" * (max_length + 1)},
"verified@example.com",
)
assert trimmed_name == "Ada Lovelace"
assert limited_name == "a" * max_length
def test_social_account_name_rejects_missing_identity():
adapter = ProwlerSocialAccountAdapter()
with pytest.raises(
ValueError,
match="Social account does not provide a valid user identity",
):
adapter._get_social_account_name({}, "")
def test_save_user_applies_normalized_social_account_name(rf):
adapter = ProwlerSocialAccountAdapter()
request = rf.post("/")
request.session = {}
sociallogin = MagicMock(spec=SocialLogin)
sociallogin.provider = MagicMock()
sociallogin.provider.id = "github"
sociallogin.account = MagicMock()
sociallogin.account.extra_data = {"name": None, "login": " octocat "}
user = User(email="verified@example.com")
user.save = MagicMock()
invitation = SimpleNamespace(tenant_id="tenant-id")
with (
patch("api.adapters.super") as mock_super,
patch("api.adapters.transaction.atomic"),
patch("api.adapters.write_db_alias"),
patch.object(adapter, "_get_invitation_token", return_value="token"),
patch(
"api.adapters.accept_invitation_for_user",
return_value=(invitation, True),
),
):
mock_super.return_value.save_user.return_value = user
saved_user = adapter.save_user(request, sociallogin)
assert saved_user.name == "octocat"
assert request.prowler_invitation_token == "token"
@pytest.mark.django_db
class TestProwlerSocialAccountAdapter:
def test_get_user_by_email_returns_user(self, create_test_user):
+1 -66
View File
@@ -321,7 +321,7 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
relationships=[rel_keep, rel_drop_orphaned],
)
result = views_helpers._serialize_graph(graph, provider_id, _all_persisted())
result = views_helpers._serialize_graph(graph, provider_id)
assert len(result["nodes"]) == 1
assert result["nodes"][0]["id"] == "n1"
@@ -329,71 +329,6 @@ def test_serialize_graph_filters_by_provider_label(attack_paths_graph_stub_class
assert result["relationships"][0]["id"] == "r1"
def _all_persisted():
return SimpleNamespace(is_persisted_element_id=lambda element_id: True)
def test_serialize_graph_drops_virtual_entities(attack_paths_graph_stub_classes):
provider_id = "provider-keep"
plabel = get_provider_label(provider_id)
stored_ids = {"n1", "n2", "r1"}
backend = SimpleNamespace(
is_persisted_element_id=lambda element_id: element_id in stored_ids
)
node_1 = attack_paths_graph_stub_classes.Node("n1", ["AWSAccount", plabel], {})
node_2 = attack_paths_graph_stub_classes.Node("n2", ["AWSRole", plabel], {})
# apoc.create.vNode can carry the provider label and any payload
node_virtual = attack_paths_graph_stub_classes.Node(
"-1", ["AWSAccount", plabel], {"body": "fetched"}
)
rel_stored = attack_paths_graph_stub_classes.Relationship(
"r1", "OWNS", node_1, node_2, {}
)
# apoc.create.vRelationship between two stored nodes passes the endpoint check
rel_virtual = attack_paths_graph_stub_classes.Relationship(
"45cc10ea-9618-47fb-abaa-8e82a58d6b40", "V", node_1, node_2, {"body": "fetched"}
)
rel_to_virtual = attack_paths_graph_stub_classes.Relationship(
"r2", "OWNS", node_1, node_virtual, {}
)
graph = SimpleNamespace(
nodes=[node_1, node_2, node_virtual],
relationships=[rel_stored, rel_virtual, rel_to_virtual],
)
result = views_helpers._serialize_graph(graph, provider_id, backend)
assert [node["id"] for node in result["nodes"]] == ["n1", "n2"]
assert [rel["id"] for rel in result["relationships"]] == ["r1"]
assert "fetched" not in str(result)
@pytest.mark.parametrize(
"element_id, persisted",
[
("4:9f1d9d76-bb08-478c-b25d-7e577f967732:0", True),
("5:9f1d9d76-bb08-478c-b25d-7e577f967732:12", True),
("-2", False),
("45cc10ea-9618-47fb-abaa-8e82a58d6b40", False),
("", False),
],
ids=[
"stored_node",
"stored_relationship",
"virtual_node",
"virtual_relationship",
"empty",
],
)
def test_neo4j_sink_is_persisted_element_id(element_id, persisted):
from api.attack_paths.sink.neo4j import Neo4jSink
assert Neo4jSink().is_persisted_element_id(element_id) is persisted
# -- serialize_graph_as_text -------------------------------------------------------
@@ -187,27 +187,6 @@ class TestRoutingByDatabasePrefix:
sink_backend_stub.drop_database.assert_called_once_with("db-tenant-abc")
mock_ingest.drop_database.assert_not_called()
def test_list_databases_always_routes_to_ingest(self, sink_backend_stub):
with patch("api.attack_paths.database.ingest") as mock_ingest:
mock_ingest.list_databases.return_value = ["db-tmp-scan-uuid-1"]
assert db_module.list_databases() == ["db-tmp-scan-uuid-1"]
mock_ingest.list_databases.assert_called_once_with()
def test_ingest_list_databases_dedupes_cluster_rows(self):
from api.attack_paths.ingest import driver as ingest_driver
with patch.object(ingest_driver, "get_session") as mock_get_session:
session = mock_get_session.return_value.__enter__.return_value
session.run.return_value = [{"name": "db-tmp-scan-uuid-1"}]
assert ingest_driver.list_databases() == ["db-tmp-scan-uuid-1"]
session.run.assert_called_once_with(
"SHOW DATABASES YIELD name RETURN DISTINCT name"
)
def test_clear_cache_routes_temp_to_ingest(self, sink_backend_stub):
with patch("api.attack_paths.database.ingest") as mock_ingest:
db_module.clear_cache("db-tmp-scan-uuid-1")
@@ -22,10 +22,7 @@ from api.attack_paths.queries.aws import (
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AWS_STS_PRIVESC_WILDCARD_TRUST,
)
from api.attack_paths.queries.types import (
AttackPathsQueryDefinition,
AttackPathsQueryOutcome,
)
from api.attack_paths.queries.types import AttackPathsQueryDefinition
# The pathfinding.cloud privilege-escalation queries added for PROWLER-2278.
NEW_PATHFINDING_QUERIES = [
@@ -241,50 +238,6 @@ class TestAllQueriesUniqueIds:
assert not duplicates, f"Duplicate query IDs found: {duplicates}"
class TestQueryOutcomes:
"""Every query carries a valid outcome (the graph's terminal impact)."""
def test_every_query_has_an_outcome(self):
# Completeness guard: a new query must be given an outcome, so the UI can
# always render a terminal outcome node.
missing = [q.id for q in AWS_QUERIES if q.outcome is None]
assert not missing, f"Queries without an outcome: {missing}"
def test_every_outcome_is_a_valid_member(self):
for query in AWS_QUERIES:
assert isinstance(query.outcome, AttackPathsQueryOutcome)
assert query.outcome.value.kind
assert query.outcome.value.label
@pytest.mark.parametrize(
"query, expected",
[
(
AWS_STS_PRIVESC_CROSS_ACCOUNT_TRUST,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
(
AWS_IAM_PRIVESC_DELETE_USER_PERMISSIONS_BOUNDARY,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
),
],
ids=lambda v: getattr(v, "id", getattr(v, "name", "")),
)
def test_representative_outcomes(self, query, expected):
assert query.outcome is expected
def test_inventory_outcome_is_partial(self):
assert AttackPathsQueryOutcome.RESOURCE_INVENTORY.value.partial is True
def test_realized_outcomes_are_not_partial(self):
for outcome in (
AttackPathsQueryOutcome.CODE_EXECUTION,
AttackPathsQueryOutcome.PRIVILEGE_ESCALATION,
AttackPathsQueryOutcome.PUBLIC_EXPOSURE,
):
assert outcome.value.partial is False
def _strip_comment_lines(cypher: str) -> str:
"""Drop `//` comment lines so keyword scans ignore prose in comments."""
return "\n".join(
@@ -1,133 +0,0 @@
"""
Structural validation for the pathfinding.cloud service privilege-escalation
Attack Paths queries added in PROWLER-2279.
These assert the conventions documented in
`docs/developer-guide/attack-paths-queries.mdx`: list-typed policy properties are
reached through `HAS_*` child-item traversals (never read as node fields),
predicate functions unsupported on Neptune (`any`/`all`/`none`, regex `=~`) are
absent, the finding probe is typed and filters only on `status`, and the
`RETURN` shape preserves the `paths, dpf, dpfr` contract.
"""
import re
import pytest
from api.attack_paths.queries.aws import AWS_QUERIES
from api.attack_paths.queries.types import AttackPathsQueryDefinition
# IDs of the queries introduced for PROWLER-2279 (pathfinding.cloud coverage).
PATHFINDING_2279_QUERY_IDS = [
"aws-batch-privesc-passrole-submit-job",
"aws-braket-privesc-passrole-create-job",
"aws-cognito-privesc-passrole-set-identity-pool-roles",
"aws-ecs-privesc-passrole-start-existing-task",
"aws-emr-privesc-passrole-run-job-flow",
"aws-emrserverless-privesc-passrole-start-job",
"aws-gamelift-privesc-passrole-create-fleet",
"aws-glue-privesc-passrole-create-session",
"aws-imagebuilder-privesc-passrole-create-image",
"aws-kinesisanalytics-privesc-passrole-create-application",
"aws-omics-privesc-passrole-start-run",
"aws-scheduler-privesc-passrole-create-schedule",
"aws-ssm-privesc-passrole-automation",
"aws-stepfunctions-privesc-passrole-create-state-machine",
"aws-batch-privesc-submit-existing-job",
"aws-codedeploy-privesc-create-deployment",
"aws-stepfunctions-privesc-update-state-machine",
"aws-iam-privesc-delete-role-boundary-assume-role",
"aws-sso-privesc-attach-managed-policy-permission-set",
"aws-sso-privesc-put-inline-policy-permission-set",
]
_BY_ID = {q.id: q for q in AWS_QUERIES}
NEW_QUERIES = [_BY_ID[qid] for qid in PATHFINDING_2279_QUERY_IDS if qid in _BY_ID]
NEPTUNE_UNSUPPORTED_PREDICATES = re.compile(r"\b(any|all|none)\s*\(", re.IGNORECASE)
NORMALIZED_STATEMENT_FIELDS = ("action", "resource", "notaction", "notresource")
def test_all_2279_queries_registered():
missing = [qid for qid in PATHFINDING_2279_QUERY_IDS if qid not in _BY_ID]
assert not missing, f"queries not registered in AWS_QUERIES: {missing}"
class TestServicePrivescQuerySchema:
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_is_query_definition(self, query):
assert isinstance(query, AttackPathsQueryDefinition)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_id_kebab_and_aws_prefixed(self, query):
assert query.id.startswith("aws-")
assert re.match(r"^[a-z0-9]+(-[a-z0-9]+)*$", query.id)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_provider_is_aws(self, query):
assert query.provider == "aws"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_has_metadata(self, query):
assert query.name and len(query.name) > 5
assert query.short_description and len(query.short_description) > 10
assert query.description and len(query.description) > 20
assert isinstance(query.parameters, list)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_attribution_links_pathfinding(self, query):
assert query.attribution is not None
assert "pathfinding.cloud" in query.attribution.text
assert query.attribution.link.startswith("https://pathfinding.cloud/paths/")
class TestServicePrivescQueryCypher:
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_anchored_and_provider_scoped(self, query):
assert "(aws:AWSAccount {id: $provider_uid})" in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_finding_label_interpolated(self, query):
assert "PROWLER_FINDING_LABEL" not in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_typed_status_scoped_finding_probe(self, query):
assert re.search(
r"-\[pfr:HAS_FINDING\]-\(pf:ProwlerFinding \{status: 'FAIL'\}\)",
query.cypher,
), f"{query.id} lacks the typed, status-scoped finding probe"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_return_contract(self, query):
assert re.search(
r"RETURN paths, collect\(DISTINCT pf\) as dpf, "
r"collect\(DISTINCT pfr\) as dpfr",
query.cypher,
)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_no_neptune_unsupported_predicates(self, query):
m = NEPTUNE_UNSUPPORTED_PREDICATES.search(query.cypher)
assert m is None, f"{query.id} uses '{m.group().strip()}' (not Neptune-safe)"
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_no_regex_operator(self, query):
assert "=~" not in query.cypher
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_does_not_read_normalized_list_fields(self, query):
for field in NORMALIZED_STATEMENT_FIELDS:
assert not re.search(rf"\.{field}\b", query.cypher), (
f"{query.id} reads normalized list field '.{field}' as a property; "
f"traverse the HAS_{field.upper()} edge instead"
)
@pytest.mark.parametrize("query", NEW_QUERIES, ids=lambda q: q.id)
def test_read_only(self, query):
no_comments = "\n".join(
line
for line in query.cypher.split("\n")
if not line.strip().startswith("//")
)
assert not re.search(
r"\b(CREATE|MERGE|SET|DELETE|REMOVE|DETACH)\b", no_comments, re.IGNORECASE
)
+24 -137
View File
@@ -5,18 +5,16 @@ from uuid import uuid4
import pytest
from api.authentication import (
API_KEY_LAST_USED_AT_THROTTLE_SECONDS,
OrphanedAPIKeyError,
SSEAuthentication,
TenantAPIKeyAuthentication,
)
from api.db_router import MainRouter
from api.models import TenantAPIKey, User
from api.models import TenantAPIKey
from django.db import connections
from django.db.models.query import QuerySet
from django.test import RequestFactory
from django.test.utils import CaptureQueriesContext
from freezegun import freeze_time
from rest_framework.exceptions import AuthenticationFailed
@@ -288,15 +286,14 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_reads_the_api_key_once_without_a_row_lock(
def test_authenticate_reads_the_api_key_once_under_a_row_lock(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the API key is read a single time and no row is locked.
"""Test the API key is read a single time and the row is locked.
Validation, the `last_used_at` update and the claims must all come from the
same authoritative row: a second lookup would reopen the window where a key
revoked in between still authenticates. `SELECT ... FOR UPDATE` serialized
every request for a hot key onto one locked row and is not used any more.
same authoritative row: a second, unlocked lookup would reopen the window
where a key revoked in between still authenticates.
"""
api_key = api_keys_fixture[0]
@@ -313,40 +310,33 @@ class TestTenantAPIKeyAuthentication:
]
assert len(api_key_selects) == 1
assert "FOR UPDATE" not in api_key_selects[0]
assert "FOR UPDATE" in api_key_selects[0]
def test_authenticate_ignores_revocation_after_the_single_read(
def test_authenticate_ignores_revocation_after_the_locked_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test the claims describe the row that was validated, not a later state.
Regression test: the key used to be looked up again to build the auth dict,
without rechecking `revoked` or `entity`. A key revoked or orphaned between
both reads still authenticated, and the claims came from that stale row.
There is now only a single read, so this race is closed by construction and
the revocation only takes effect on the next request.
both reads still authenticated, and the claims came from that stale row. With
a single locked read the write below cannot land mid-authentication, and the
revocation only takes effect on the next request.
"""
api_key = api_keys_fixture[0]
entity_at_validation = api_key.entity
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
original_save = TenantAPIKey.save
def revoke_and_orphan_after_reading(self, request, key):
# Runs right after the single read `authenticate` will use to build the
# claims: the exact window a concurrent revocation used to slip into
result = original_authenticate_credentials(self, request, key)
def revoke_and_orphan_before_saving(instance, *args, **kwargs):
# Runs after validation, right before the claims are built: the exact
# window a concurrent revocation or user deletion used to slip into
TenantAPIKey.objects.filter(id=api_key.id).update(revoked=True, entity=None)
return result
return original_save(instance, *args, **kwargs)
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
revoke_and_orphan_after_reading,
):
with patch.object(TenantAPIKey, "save", revoke_and_orphan_before_saving):
entity, auth_dict = auth_backend.authenticate(request)
assert entity == entity_at_validation
@@ -360,43 +350,6 @@ class TestTenantAPIKeyAuthentication:
assert str(exc_info.value.detail) == "This API Key has been revoked."
def test_authenticate_survives_owner_deleted_after_the_single_read(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test a user deleted right after the read does not turn into a 500.
Without the row lock a concurrent user deletion can land between the read
and building the claims. `entity` is loaded by the same query, so no later
lookup can raise `DoesNotExist`.
"""
api_key = api_keys_fixture[0]
owner_id = api_key.entity_id
original_authenticate_credentials = (
TenantAPIKeyAuthentication._authenticate_credentials
)
def delete_owner_after_reading(self, request, key):
result = original_authenticate_credentials(self, request, key)
User.objects.using(MainRouter.admin_db).filter(id=owner_id).delete()
return result
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {api_key._raw_key}"
with patch.object(
TenantAPIKeyAuthentication,
"_authenticate_credentials",
delete_owner_after_reading,
):
entity, auth_dict = auth_backend.authenticate(request)
assert auth_dict["sub"] == str(owner_id)
assert entity.id == owner_id
# From the next request on, the orphaned key is rejected with a 401
with pytest.raises(AuthenticationFailed):
auth_backend.authenticate(request)
def test_authenticate_expired_api_key(
self, auth_backend, create_test_user, tenants_fixture, request_factory
):
@@ -468,90 +421,24 @@ class TestTenantAPIKeyAuthentication:
if original_last_used:
assert api_key.last_used_at > original_last_used
def test_authenticate_updates_last_used_at_on_admin_database(
def test_authenticate_saves_to_admin_database(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that the `last_used_at` update runs against the admin database."""
"""Test that the API key save operation uses admin database."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
# Mock the save method to verify it's called with using='admin'
with patch.object(TenantAPIKey, "save") as mock_save:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
assert "last_used_at" in api_key_updates[0]
def test_authenticate_does_not_rewrite_last_used_at_within_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that a second authentication within the throttle interval is a no-op write."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
# First call sets last_used_at
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
# Second call, still within the throttle interval, must issue no UPDATE
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert api_key_updates == []
api_key.refresh_from_db()
assert api_key.last_used_at == first_used_at
def test_authenticate_rewrites_last_used_at_after_throttle_interval(
self, auth_backend, api_keys_fixture, request_factory
):
"""Test that `last_used_at` is refreshed once it is older than the throttle interval."""
api_key = api_keys_fixture[0]
raw_key = api_key._raw_key
request = request_factory.get("/")
request.META["HTTP_AUTHORIZATION"] = f"Api-Key {raw_key}"
start = datetime.now(UTC)
with freeze_time(start):
auth_backend.authenticate(request)
api_key.refresh_from_db()
first_used_at = api_key.last_used_at
assert first_used_at is not None
later = start + timedelta(seconds=API_KEY_LAST_USED_AT_THROTTLE_SECONDS + 1)
with freeze_time(later):
with CaptureQueriesContext(connections[MainRouter.admin_db]) as captured:
auth_backend.authenticate(request)
api_key_updates = [
query["sql"]
for query in captured.captured_queries
if query["sql"].startswith("UPDATE") and '"api_keys"' in query["sql"]
]
assert len(api_key_updates) == 1
api_key.refresh_from_db()
assert api_key.last_used_at > first_used_at
# Verify save was called with using=admin_db
mock_save.assert_called_once_with(
update_fields=["last_used_at"], using=MainRouter.admin_db
)
def test_authenticate_returns_correct_auth_dict(
self, auth_backend, api_keys_fixture, request_factory
@@ -222,12 +222,6 @@ class TestProtection:
result = _inject(cypher)
assert result.count(LABEL) == 1
def test_block_comment_with_node_pattern(self):
cypher = "MATCH (n:AWSRole) /* (m:Fake)\n(k) */ RETURN n"
result = _inject(cypher)
assert "/* (m:Fake)\n(k) */" in result
assert result.count(LABEL) == 1
# ---------------------------------------------------------------------------
# Clause splitting
@@ -445,35 +439,6 @@ class TestValidation:
assert "blocked operation" in str(exc.value.detail)
@pytest.mark.parametrize(
"cypher",
[
"LOAD /* c */ CSV FROM 'http://169.254.169.254/' AS x RETURN x",
"LOAD/**/CSV FROM 'http://evil.com' AS x RETURN x",
"LOAD /* a */ /* b */ CSV FROM 'http://evil.com' AS x RETURN x",
"LOAD /* multi\nline */ CSV FROM 'http://evil.com' AS x RETURN x",
"CALL apoc./**/load.json('http://evil.com/') YIELD value RETURN value",
"CALL apoc . load.json('http://evil.com/') YIELD value RETURN value",
"CALL apoc.load . json('http://evil.com/') YIELD value RETURN value",
"CALL apoc\n.cypher.run('CREATE (n)', {}) YIELD value RETURN value",
],
ids=[
"LOAD_CSV_block_comment",
"LOAD_CSV_empty_block_comment",
"LOAD_CSV_two_block_comments",
"LOAD_CSV_multiline_block_comment",
"apoc.load_block_comment",
"apoc.load_spaces_around_dot",
"apoc.load_spaces_before_procedure",
"apoc.cypher_newline_before_dot",
],
)
def test_rejects_blocked_patterns_split_by_comments_or_whitespace(self, cypher):
with pytest.raises(ValidationError) as exc:
validate_custom_query(cypher)
assert "blocked operation" in str(exc.value.detail)
@pytest.mark.parametrize(
"cypher",
[
@@ -483,8 +448,6 @@ class TestValidation:
"CALL apoc.create.vNode(['Label'], {}) YIELD node RETURN node",
"MATCH (n) WHERE n.name = 'apoc.load.json' RETURN n",
'MATCH (n) WHERE n.description = "LOAD CSV is cool" RETURN n',
"MATCH (n) /* LOAD CSV is documented here */ RETURN n",
"MATCH (n) WHERE n.note = '/* LOAD CSV */' RETURN n",
],
ids=[
"simple_match",
@@ -493,8 +456,6 @@ class TestValidation:
"apoc_virtual_node",
"apoc_load_inside_single_quotes",
"load_csv_inside_double_quotes",
"load_csv_inside_block_comment",
"block_comment_inside_string",
],
)
def test_allows_clean_queries(self, cypher):
+1 -226
View File
@@ -1,16 +1,14 @@
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
import pytest
from allauth.socialaccount.models import SocialApp
from api.db_router import MainRouter
from api.models import (
Provider,
ProviderComplianceScore,
Resource,
ResourceTag,
SAMLConfiguration,
SAMLDomainIndex,
Scan,
StateChoices,
StatusChoices,
TenantComplianceSummary,
@@ -526,226 +524,3 @@ class TestTenantComplianceSummaryModel:
assert summary1.id != summary2.id
assert summary1.requirements_passed != summary2.requirements_passed
def _latest_scan_fixture(tenant, provider, *, completed_at, inserted_at=None, **kwargs):
scan = Scan.objects.create(
tenant_id=tenant.id,
provider=provider,
trigger=kwargs.pop("trigger", Scan.TriggerChoices.MANUAL),
state=kwargs.pop("state", StateChoices.COMPLETED),
completed_at=completed_at,
**kwargs,
)
if inserted_at is not None:
# `inserted_at` is auto_now_add, so it has to be forced after the fact.
Scan.all_objects.filter(pk=scan.pk).update(inserted_at=inserted_at)
scan.refresh_from_db()
return scan
@pytest.mark.django_db
class TestScanQuerySetOrdering:
def test_completed_later_wins_over_inserted_later(
self, tenants_fixture, aws_provider
):
"""The scan that FINISHED last is current, not the one that started last."""
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
finished_last = _latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now - timedelta(hours=3),
completed_at=now,
)
_latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now - timedelta(hours=1),
completed_at=now - timedelta(hours=1),
)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [finished_last.id]
def test_null_completed_at_provider_is_still_returned(
self, tenants_fixture, aws_provider
):
"""NULLS LAST, not `completed_at__isnull=False`.
Excluding NULL `completed_at` would drop the provider from every
"latest" endpoint instead of falling back to `inserted_at`.
"""
tenant, *_ = tenants_fixture
only_scan = _latest_scan_fixture(tenant, aws_provider, completed_at=None)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [only_scan.id]
def test_null_completed_at_never_outranks_a_finished_scan(
self, tenants_fixture, aws_provider
):
"""Postgres sorts NULLs first under DESC; NULLS LAST is what fixes it."""
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
finished = _latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now - timedelta(hours=2),
completed_at=now - timedelta(hours=2),
)
_latest_scan_fixture(
tenant,
aws_provider,
inserted_at=now,
completed_at=None,
)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [finished.id]
def test_id_breaks_an_exact_timestamp_tie_deterministically(
self, tenants_fixture, aws_provider
):
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
scans = [
_latest_scan_fixture(
tenant, aws_provider, inserted_at=now, completed_at=now
)
for _ in range(3)
]
expected = max(scan.id for scan in scans)
picks = {
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()[0]
for _ in range(5)
}
assert picks == {expected}
@pytest.mark.django_db
class TestScanQuerySetEligibility:
def test_unfinished_scans_are_excluded(self, tenants_fixture, aws_provider):
tenant, *_ = tenants_fixture
_latest_scan_fixture(
tenant,
aws_provider,
completed_at=None,
state=StateChoices.EXECUTING,
)
assert (
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
)
@pytest.mark.django_db
class TestScanQuerySetManagerChoice:
def test_active_manager_hides_soft_deleted_providers(
self, tenants_fixture, aws_provider
):
"""`Scan.objects` drops soft-deleted providers, `all_objects` keeps them.
The queryset must not decide this for the caller.
"""
tenant, *_ = tenants_fixture
scan = _latest_scan_fixture(
tenant, aws_provider, completed_at=datetime.now(UTC)
)
Provider.all_objects.filter(pk=aws_provider.pk).update(is_deleted=True)
assert Scan.all_objects.filter(
tenant_id=tenant.id
).latest_ids_per_provider() == [scan.id]
assert Scan.objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
@pytest.mark.django_db
class TestScanQuerySetPerProviderScoping:
def test_one_scan_per_provider(self, tenants_fixture, aws_provider_pair):
tenant, *_ = tenants_fixture
provider_one, provider_two = aws_provider_pair
now = datetime.now(UTC)
newest_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
_latest_scan_fixture(tenant, provider_one, completed_at=now - timedelta(days=1))
newest_two = _latest_scan_fixture(tenant, provider_two, completed_at=now)
assert set(
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider()
) == {newest_one.id, newest_two.id}
def test_caller_filters_are_preserved(self, tenants_fixture, aws_provider_pair):
tenant, *_ = tenants_fixture
provider_one, provider_two = aws_provider_pair
now = datetime.now(UTC)
scan_one = _latest_scan_fixture(tenant, provider_one, completed_at=now)
_latest_scan_fixture(tenant, provider_two, completed_at=now)
assert Scan.all_objects.filter(
tenant_id=tenant.id, provider__in=[provider_one]
).latest_ids_per_provider() == [scan_one.id]
def test_latest_first_is_ordered_not_deduplicated(
self, tenants_fixture, aws_provider
):
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
older = _latest_scan_fixture(
tenant, aws_provider, completed_at=now - timedelta(days=1)
)
ordered = list(
Scan.all_objects.filter(
tenant_id=tenant.id, provider_id=aws_provider.id
).latest_first()
)
assert [scan.id for scan in ordered] == [newest.id, older.id]
def test_tenant_isolation(self, tenants_fixture, aws_provider):
tenant, other_tenant, *_ = tenants_fixture
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
assert (
Scan.all_objects.filter(tenant_id=other_tenant.id).latest_ids_per_provider()
== []
)
def test_empty_queryset_returns_empty_list(self, tenants_fixture):
tenant, *_ = tenants_fixture
assert (
Scan.all_objects.filter(tenant_id=tenant.id).latest_ids_per_provider() == []
)
@pytest.mark.django_db
class TestScanQuerySetPerProviderQuerysetShape:
def test_returns_a_queryset_not_a_list(self, tenants_fixture, aws_provider):
tenant, *_ = tenants_fixture
_latest_scan_fixture(tenant, aws_provider, completed_at=datetime.now(UTC))
qs = Scan.all_objects.filter(tenant_id=tenant.id).latest_per_provider()
# Callers chain .values(...) / .values_list(...) onto this.
assert qs.values_list("provider_id", flat=True).count() == 1
@pytest.mark.django_db
class TestScanQuerySetRelatedManager:
def test_reverse_relation_exposes_the_methods(self, tenants_fixture, aws_provider):
tenant, *_ = tenants_fixture
now = datetime.now(UTC)
newest = _latest_scan_fixture(tenant, aws_provider, completed_at=now)
_latest_scan_fixture(tenant, aws_provider, completed_at=now - timedelta(days=1))
assert aws_provider.scans.latest_first().first().id == newest.id
@@ -1,63 +0,0 @@
from datetime import UTC, datetime
from itertools import islice
import pytest
from api.partitions import (
PostgresUUIDv7PartitioningStrategy,
relative_months_or_none,
)
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ImproperlyConfigured
from psqlextra.partitioning import PostgresTimePartitionSize
def build_strategy(max_age):
return PostgresUUIDv7PartitioningStrategy(
size=PostgresTimePartitionSize(months=1),
count=1,
start_date=datetime.now(UTC),
max_age=max_age,
name_format="%Y_%b",
)
class TestRelativeMonthsOrNone:
@pytest.mark.parametrize("value", [None, 0])
def test_unset_or_zero_keeps_partitions_indefinitely(self, value):
assert relative_months_or_none(value) is None
@pytest.mark.parametrize("months", [1, 3, 12])
def test_value_is_interpreted_as_months(self, months):
assert relative_months_or_none(months) == relativedelta(months=months)
def test_value_is_not_interpreted_as_days(self):
assert relative_months_or_none(12) != relativedelta(days=12)
def test_negative_is_rejected(self):
with pytest.raises(ImproperlyConfigured):
relative_months_or_none(-12)
class TestToDelete:
@pytest.mark.parametrize("max_age", [None, relative_months_or_none(0)])
def test_nothing_is_deleted_without_max_age(self, max_age):
strategy = build_strategy(max_age)
assert list(islice(strategy.to_delete(), 5)) == []
def test_first_deleted_partition_is_max_age_old(self):
months = 3
strategy = build_strategy(relative_months_or_none(months))
first = next(strategy.to_delete())
expected = strategy.get_start_datetime() - relativedelta(months=months)
assert first.name() == expected.strftime("%Y_%b").lower()
def test_deleted_partitions_go_further_back_in_time(self):
strategy = build_strategy(relative_months_or_none(3))
names = [p.name() for p in islice(strategy.to_delete(), 3)]
starts = [datetime.strptime(n, "%Y_%b") for n in names]
assert starts == sorted(starts, reverse=True)
-85
View File
@@ -12,7 +12,6 @@ from api.models import (
ProviderSecret,
Role,
RoleProviderGroupRelationship,
SAMLConfiguration,
Scan,
User,
UserRoleRelationship,
@@ -1897,90 +1896,6 @@ class TestUserRoleLinkPermissions:
assert response.status_code == status.HTTP_403_FORBIDDEN
SAML_METADATA_XML = """<?xml version='1.0' encoding='UTF-8'?>
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
<md:KeyDescriptor use='signing'>
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
<ds:X509Data>
<ds:X509Certificate>TEST</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
"""
@pytest.mark.django_db
class TestSAMLConfigurationPermissions:
def _create(self, client, email_domain):
return client.post(
reverse("saml-config-list"),
data={"email_domain": email_domain, "metadata_xml": SAML_METADATA_XML},
format="json",
)
def test_manage_integrations_only_cannot_create(
self, authenticated_client_rbac_manage_integrations_only
):
response = self._create(
authenticated_client_rbac_manage_integrations_only,
"integrations-only-create.com",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not SAMLConfiguration.objects.filter(
email_domain="integrations-only-create.com"
).exists()
def test_manage_integrations_only_cannot_read_or_replace(
self, authenticated_client_rbac_manage_integrations_only
):
client = authenticated_client_rbac_manage_integrations_only
tenant = Membership.objects.filter(user=client.user).first().tenant
config = SAMLConfiguration.objects.create(
tenant_id=str(tenant.id),
email_domain="integrations-only-replace.com",
metadata_xml=SAML_METADATA_XML,
)
list_response = client.get(reverse("saml-config-list"))
patch_response = client.patch(
reverse("saml-config-detail", kwargs={"pk": config.id}),
data={"metadata_xml": SAML_METADATA_XML},
format="json",
)
delete_response = client.delete(
reverse("saml-config-detail", kwargs={"pk": config.id})
)
assert list_response.status_code == status.HTTP_403_FORBIDDEN
assert patch_response.status_code == status.HTTP_403_FORBIDDEN
assert delete_response.status_code == status.HTTP_403_FORBIDDEN
assert SAMLConfiguration.objects.filter(id=config.id).exists()
def test_manage_account_only_can_create_and_delete(
self, authenticated_client_rbac_manage_account
):
client = authenticated_client_rbac_manage_account
create_response = self._create(client, "account-only.com")
assert create_response.status_code == status.HTTP_201_CREATED
config = SAMLConfiguration.objects.get(email_domain="account-only.com")
delete_response = client.delete(
reverse("saml-config-detail", kwargs={"pk": config.id})
)
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
@pytest.mark.django_db
class TestCrossTenantRoleLeak:
"""Regression tests for get_role() cross-tenant privilege leak.
+1 -179
View File
@@ -1,10 +1,9 @@
import errno
import logging
from unittest.mock import MagicMock, patch
import pytest
from config.settings import sentry as sentry_settings
from config.settings.sentry import before_send, errno_fingerprint
from config.settings.sentry import before_send
def test_initialize_sentry_skips_without_dsn():
@@ -30,21 +29,6 @@ def test_initialize_sentry_uses_configured_dsn():
assert mock_init.call_args.kwargs["before_send"] is sentry_settings.before_send
def test_initialize_sentry_sends_no_personal_data():
with (
patch.object(
sentry_settings.env,
"str",
return_value="https://fake-public-key@sentry.example.invalid/1",
),
patch.object(sentry_settings.sentry_sdk, "init") as mock_init,
):
sentry_settings.initialize_sentry()
assert mock_init.call_args.kwargs["send_default_pii"] is False
assert mock_init.call_args.kwargs["max_request_body_size"] == "never"
def _make_log_record(msg, level=logging.ERROR, name="test", args=None):
"""Build a real LogRecord so getMessage() works like in production."""
record = logging.LogRecord(
@@ -204,165 +188,3 @@ def test_before_send_passes_non_defunct_neo4j_log():
event = MagicMock()
assert before_send(event, hint) == event
def _filesystem_hint(exception, msg="Error generating output directory"):
"""Build the hint the logging integration sends for a filesystem failure."""
exc_info = (type(exception), exception, exception.__traceback__)
log_record = _make_log_record(msg)
log_record.exc_info = exc_info
setattr(
log_record,
sentry_settings.ERROR_CATEGORY_ATTRIBUTE,
sentry_settings.FILESYSTEM_ERROR_CATEGORY,
)
return {"log_record": log_record, "exc_info": exc_info}
@pytest.mark.parametrize(
("error_number", "message", "expected_suffix"),
[
(errno.ENOSPC, "No space left on device", "errno:ENOSPC"),
(errno.ENOENT, "No such file or directory", "errno:ENOENT"),
(errno.EACCES, "Permission denied", "errno:EACCES"),
],
)
def test_before_send_fingerprints_oserror_by_errno(
error_number, message, expected_suffix
):
"""Filesystem failures raised from the same call site must not be merged."""
event = {}
result = before_send(event, _filesystem_hint(OSError(error_number, message)))
assert result is event
assert event["fingerprint"] == ["{{ default }}", expected_suffix]
def test_before_send_fingerprints_differ_per_errno():
"""ENOSPC and ENOENT from the same call site produce different issues."""
enospc_event = {}
enoent_event = {}
before_send(
enospc_event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
before_send(
enoent_event,
_filesystem_hint(OSError(errno.ENOENT, "No such file or directory")),
)
assert enospc_event["fingerprint"] != enoent_event["fingerprint"]
def test_before_send_fingerprints_wrapped_oserror():
"""The errno is found even when the OSError is wrapped by another error."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError as os_error:
raise RuntimeError("Error generating output directory") from os_error
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert event["fingerprint"] == ["{{ default }}", "errno:ENOSPC"]
def test_before_send_does_not_fingerprint_non_oserror():
"""Non-filesystem exceptions keep Sentry's default grouping."""
event = {}
result = before_send(event, _filesystem_hint(ValueError("boom")))
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_unrelated_oserror_log():
"""Only records declaring the filesystem category opt into the errno grouping."""
exception = OSError(errno.ENOSPC, "No space left on device")
log_record = _make_log_record("Unrelated failure")
exc_info = (OSError, exception, None)
log_record.exc_info = exc_info
event = {}
result = before_send(event, {"log_record": log_record, "exc_info": exc_info})
assert result is event
assert "fingerprint" not in event
def test_before_send_does_not_fingerprint_exception_events():
"""Exception events without a log record keep Sentry's default grouping."""
event = {}
result = before_send(
event,
{"exc_info": (OSError, OSError(errno.ENOSPC, "No space left on device"), None)},
)
assert result is event
assert "fingerprint" not in event
@pytest.mark.parametrize("fingerprint", [["scope-fingerprint"], []])
def test_before_send_keeps_existing_fingerprint(fingerprint):
"""A fingerprint set by a scope or an integration is never overwritten."""
expected_fingerprint = fingerprint.copy()
event = {"fingerprint": fingerprint}
before_send(
event, _filesystem_hint(OSError(errno.ENOSPC, "No space left on device"))
)
assert event["fingerprint"] == expected_fingerprint
def test_before_send_ignores_suppressed_context():
"""`raise ... from None` hides the context, so it must not group the event."""
try:
try:
raise OSError(errno.ENOSPC, "No space left on device")
except OSError:
raise RuntimeError("Error generating output directory") from None
except RuntimeError as wrapper:
event = {}
before_send(event, _filesystem_hint(wrapper))
assert "fingerprint" not in event
def test_errno_fingerprint_follows_implicit_context():
"""An implicit `raise` during handling still exposes the original errno."""
try:
try:
raise OSError(errno.EACCES, "Permission denied")
except OSError:
raise RuntimeError("Error generating output directory")
except RuntimeError as wrapper:
assert errno_fingerprint(wrapper) == "errno:EACCES"
def test_before_send_does_not_fingerprint_oserror_without_errno():
"""An OSError without errno has nothing to split the issue by."""
event = {}
before_send(event, _filesystem_hint(OSError("no errno here")))
assert "fingerprint" not in event
def test_errno_fingerprint_uses_raw_number_for_unknown_errno():
"""Unmapped errno values still split the issue instead of being dropped."""
assert errno_fingerprint(OSError(9999, "unknown")) == "errno:9999"
def test_errno_fingerprint_stops_on_self_referencing_chain():
"""A cyclic exception chain must not hang the fingerprint lookup."""
first = ValueError("first")
second = ValueError("second")
first.__cause__ = second
second.__cause__ = first
assert errno_fingerprint(first) is None
+24 -296
View File
@@ -5,8 +5,6 @@ from api.v1.serializer_utils.integrations import (
)
from api.v1.serializer_utils.providers import ProviderSecretField
from api.v1.serializers import (
KUBERNETES_KUBECONFIG_INVALID_ERROR,
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
ImageProviderSecret,
IntegrationSerializer,
IntegrationUpdateSerializer,
@@ -217,34 +215,36 @@ class TestOracleCloudProviderSecret:
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
def test_keeps_region_as_home_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region=" me-abudhabi-1 ")
)
def test_accepts_and_ignores_region_field(self):
secret = self.valid_secret(region="us-phoenix-1")
serializer = OracleCloudProviderSecret(data=secret)
assert serializer.is_valid(), serializer.errors
assert serializer.validated_data["region"] == "me-abudhabi-1"
def test_rejects_unknown_region(self):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region="mars-north-1")
)
assert not serializer.is_valid()
assert "region" in serializer.errors
@pytest.mark.parametrize("legacy_value", [None, "", {"name": "us-ashburn-1"}])
def test_drops_blank_or_non_string_region(self, legacy_value):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(region=legacy_value)
)
assert serializer.is_valid(), serializer.errors
assert "region" not in serializer.validated_data
@pytest.mark.parametrize(
"legacy_field, legacy_value",
[
("region", None),
("region", ""),
("region", {"name": "us-ashburn-1"}),
],
)
def test_accepts_and_ignores_any_legacy_region_value(
self, legacy_field, legacy_value
):
serializer = OracleCloudProviderSecret(
data=self.valid_secret(**{legacy_field: legacy_value})
)
assert serializer.is_valid(), serializer.errors
assert legacy_field not in serializer.validated_data
class TestProviderSecretFieldSchema:
def test_oraclecloud_schema_region_is_not_deprecated(self):
def test_oraclecloud_schema_includes_legacy_region_field(self):
schema = ProviderSecretField._spectacular_annotation["field"]
oraclecloud_schema = next(
credential_schema
@@ -253,7 +253,7 @@ class TestProviderSecretFieldSchema:
== "Oracle Cloud Infrastructure (OCI) API Key Credentials"
)
assert "deprecated" not in oraclecloud_schema["properties"]["region"]
assert oraclecloud_schema["properties"]["region"]["deprecated"] is True
class TestKubernetesProviderSecret:
@@ -342,278 +342,6 @@ current-context: test-context
assert not serializer.is_valid()
assert "kubeconfig_content" in serializer.errors
@staticmethod
def _kubeconfig(user_extra="", cluster_extra=""):
return f"""
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
{cluster_extra}
users:
- name: test-user
user:
token: test-token
{user_extra}
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
@staticmethod
def _assert_rejected_without_echo(
kubeconfig_content, leaked_value, expected_message
):
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert serializer.is_valid() is False
assert serializer.errors["kubeconfig_content"] == [expected_message]
assert leaked_value not in str(serializer.errors)
def test_inline_token_with_certificate_authority_data_is_accepted(self):
kubeconfig_content = self._kubeconfig(
cluster_extra=" certificate-authority-data: dGVzdA=="
)
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert serializer.is_valid()
def test_kubeconfig_with_token_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" tokenFile: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_client_certificate_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" client-certificate: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_client_key_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" client-key: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_certificate_authority_file_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(cluster_extra=" certificate-authority: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_cluster_proxy_url_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(cluster_extra=" proxy-url: http://proxy.evil.test"),
"proxy.evil.test",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
@pytest.mark.parametrize("provider_name", ["gcp", "oidc", "azure"])
def test_kubeconfig_with_any_auth_provider_is_rejected(self, provider_name):
self._assert_rejected_without_echo(
self._kubeconfig(
user_extra=(
" auth-provider:\n"
f" name: {provider_name}\n"
" config:\n"
" idp-issuer-url: https://idp.evil.test"
)
),
"idp.evil.test",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_unknown_user_key_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(user_extra=" bogus: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_unknown_cluster_key_is_rejected(self):
self._assert_rejected_without_echo(
self._kubeconfig(cluster_extra=" bogus: /etc/passwd"),
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_allowlisted_cluster_keys_is_accepted(self):
serializer = KubernetesProviderSecret(
data={
"kubeconfig_content": self._kubeconfig(
cluster_extra=(
" certificate-authority-data: dGVzdA==\n"
" insecure-skip-tls-verify: false\n"
" tls-server-name: kubernetes.example.test\n"
" disable-compression: true\n"
" extensions: []"
)
)
}
)
assert serializer.is_valid(), serializer.errors
def test_kubeconfig_with_non_list_clusters_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
name: /etc/passwd
"""
self._assert_rejected_without_echo(
kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR
)
def test_kubeconfig_with_non_dict_cluster_entry_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
- /etc/passwd
"""
self._assert_rejected_without_echo(
kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR
)
def test_kubeconfig_with_non_dict_cluster_value_is_rejected(self):
kubeconfig_content = """
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster: /etc/passwd
"""
self._assert_rejected_without_echo(
kubeconfig_content, "/etc/passwd", KUBERNETES_KUBECONFIG_INVALID_ERROR
)
@pytest.mark.parametrize(
"user_extra",
[
" token: test-token",
" username: test-user\n password: test-password",
" client-certificate-data: dGVzdA==\n client-key-data: dGVzdA==",
" token: test-token\n as: other-user",
" token: test-token\n as-uid: 1234",
" token: test-token\n as-groups:\n - group-a",
" token: test-token\n as-user-extra:\n scopes:\n - read",
],
ids=[
"token",
"username-password",
"client-cert-data",
"as",
"as-uid",
"as-groups",
"as-user-extra",
],
)
def test_kubeconfig_with_allowlisted_user_keys_is_accepted(self, user_extra):
kubeconfig_content = f"""
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
certificate-authority-data: dGVzdA==
users:
- name: test-user
user:
{user_extra}
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": kubeconfig_content}
)
assert serializer.is_valid()
@pytest.mark.parametrize(
"kubeconfig_content",
[
"""
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://kubernetes.example.test
users:
- name: good-user
user:
token: test-token
- name: bad-user
user:
tokenFile: /etc/passwd
""",
"""
apiVersion: v1
kind: Config
clusters:
- name: good-cluster
cluster:
server: https://kubernetes.example.test
- name: bad-cluster
cluster:
server: https://kubernetes.example.test
certificate-authority: /etc/passwd
users:
- name: test-user
user:
token: test-token
""",
],
ids=["second-user", "second-cluster"],
)
def test_kubeconfig_with_bad_second_entry_is_rejected(self, kubeconfig_content):
self._assert_rejected_without_echo(
kubeconfig_content,
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_kubeconfig_with_indented_document_is_rejected(self):
kubeconfig_content = (
" wrapper:\n"
" users:\n"
" - name: u\n"
" user:\n"
" tokenFile: /etc/passwd\n"
" clusters:\n"
" - name: c\n"
" cluster:\n"
" server: https://example.test\n"
)
self._assert_rejected_without_echo(
kubeconfig_content,
"/etc/passwd",
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR,
)
def test_malformed_kubeconfig_is_rejected(self):
serializer = KubernetesProviderSecret(
data={"kubeconfig_content": "apiVersion: ["}
+12 -130
View File
@@ -14,7 +14,6 @@ from api.utils import (
return_prowler_provider,
validate_invitation,
)
from api.v1.serializers import KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
from prowler.providers.alibabacloud.alibabacloud_provider import AlibabacloudProvider
from prowler.providers.aws.aws_provider import AwsProvider
from prowler.providers.aws.lib.security_hub.security_hub import SecurityHubConnection
@@ -36,34 +35,6 @@ from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvid
from prowler.providers.vercel.vercel_provider import VercelProvider
from rest_framework.exceptions import NotFound, ValidationError
VALID_KUBECONFIG = """\
apiVersion: v1
kind: Config
clusters:
- name: test-cluster
cluster:
server: https://example.invalid:6443
certificate-authority-data: Y2E=
users:
- name: test-user
user:
token: test-token
contexts:
- name: test-context
context:
cluster: test-cluster
user: test-user
current-context: test-context
"""
TOKEN_FILE_KUBECONFIG = VALID_KUBECONFIG.replace(
"token: test-token", "tokenFile: /etc/passwd"
)
EXEC_KUBECONFIG = VALID_KUBECONFIG.replace(
"token: test-token", "exec:\n command: test-command"
)
class TestMergeDicts:
def test_simple_merge(self):
@@ -201,7 +172,7 @@ class TestInitializeProwlerProvider:
)
@patch("api.utils.return_prowler_provider")
def test_initialize_oraclecloud_provider_passes_region_as_home_region(
def test_initialize_oraclecloud_provider_removes_region_string(
self, mock_return_prowler_provider
):
provider = MagicMock()
@@ -211,7 +182,7 @@ class TestInitializeProwlerProvider:
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..fake",
"region": "me-abudhabi-1",
"region": "us-ashburn-1",
}
mock_return_prowler_provider.return_value = MagicMock()
@@ -222,7 +193,6 @@ class TestInitializeProwlerProvider:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..fake",
home_region="me-abudhabi-1",
)
@patch("api.utils.return_prowler_provider")
@@ -284,73 +254,15 @@ class TestProwlerProviderConnectionTest:
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region=OraclecloudProvider._bootstrap_region,
region=getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
),
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_oraclecloud_connection_test_uses_stored_region_for_identity(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.uid = "ocid1.tenancy.oc1..aaaaaaaexample"
provider.provider = Provider.ProviderChoices.ORACLECLOUD.value
provider.secret.secret = {
"user": "ocid1.user.oc1..aaaaaaaexample",
"fingerprint": "00:11:22:33:44:55:66:77",
"key_content": "fake-base64-key-content",
"tenancy": "ocid1.tenancy.oc1..aaaaaaaexample",
"region": "me-abudhabi-1",
}
mock_return_prowler_provider.return_value = MagicMock()
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
user="ocid1.user.oc1..aaaaaaaexample",
fingerprint="00:11:22:33:44:55:66:77",
key_content="fake-base64-key-content",
tenancy="ocid1.tenancy.oc1..aaaaaaaexample",
region="me-abudhabi-1",
provider_id="ocid1.tenancy.oc1..aaaaaaaexample",
raise_on_exception=False,
)
@patch("api.utils.return_prowler_provider")
def test_kubernetes_connection_test_rejects_invalid_kubeconfig(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = {"kubeconfig_content": TOKEN_FILE_KUBECONFIG}
connection = prowler_provider_connection_test(provider)
assert connection.is_connected is False
assert (
str(connection.error) == KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
)
mock_return_prowler_provider.return_value.test_connection.assert_not_called()
@patch("api.utils.return_prowler_provider")
def test_kubernetes_connection_test_with_valid_kubeconfig(
self, mock_return_prowler_provider
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = {"kubeconfig_content": VALID_KUBECONFIG}
prowler_provider_connection_test(provider)
mock_return_prowler_provider.return_value.test_connection.assert_called_once_with(
kubeconfig_content=VALID_KUBECONFIG,
provider_id="provider_uid",
raise_on_exception=False,
)
@pytest.mark.django_db
@patch("api.utils.return_prowler_provider")
def test_prowler_provider_connection_test_without_secret(
@@ -468,6 +380,10 @@ class TestGetProwlerProviderKwargs:
Provider.ProviderChoices.GOOGLEWORKSPACE.value,
{},
),
(
Provider.ProviderChoices.KUBERNETES.value,
{"context": "provider_uid"},
),
(
Provider.ProviderChoices.M365.value,
{},
@@ -518,40 +434,7 @@ class TestGetProwlerProviderKwargs:
expected_result = {**secret_dict, **expected_extra_kwargs}
assert result == expected_result
def test_get_prowler_provider_kwargs_kubernetes_valid_kubeconfig(self):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = {"kubeconfig_content": VALID_KUBECONFIG}
assert get_prowler_provider_kwargs(provider) == {
"kubeconfig_content": VALID_KUBECONFIG,
"context": "provider_uid",
}
@pytest.mark.parametrize(
"secret",
[
{"kubeconfig_content": TOKEN_FILE_KUBECONFIG},
{"kubeconfig_content": EXEC_KUBECONFIG},
{"kubeconfig_content": "[]"},
{"key": "value"},
],
)
def test_get_prowler_provider_kwargs_kubernetes_rejects_invalid_kubeconfig(
self, secret
):
provider = MagicMock()
provider.provider = Provider.ProviderChoices.KUBERNETES.value
provider.uid = "provider_uid"
provider.secret.secret = secret
with pytest.raises(ValidationError) as exc_info:
get_prowler_provider_kwargs(provider)
assert "/etc/passwd" not in str(exc_info.value)
def test_get_prowler_provider_kwargs_oraclecloud_maps_region_to_home_region(
def test_get_prowler_provider_kwargs_oraclecloud_removes_region(
self,
):
secret_dict = {
@@ -578,7 +461,6 @@ class TestGetProwlerProviderKwargs:
"key_content": "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----",
"tenancy": "ocid1.tenancy.oc1..fake",
"pass_phrase": "fake-passphrase",
"home_region": "us-ashburn-1",
}
def test_get_prowler_provider_kwargs_with_mutelist(self):
File diff suppressed because it is too large. Load diff
+8 -33
View File
@@ -17,7 +17,7 @@ from api.models import (
Role,
UserRoleRelationship,
)
from api.v1.serializers import FindingMetadataSerializer, KubernetesProviderSecret
from api.v1.serializers import FindingMetadataSerializer
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import transaction
from django.db.models import Subquery
@@ -201,13 +201,6 @@ def return_prowler_provider(
return prowler_provider
def _validate_kubernetes_secret(secret: dict) -> None:
"""Re-validate a stored kubeconfig at use time; errors never echo its content."""
KubernetesProviderSecret(
data={"kubeconfig_content": secret.get("kubeconfig_content")}
).is_valid(raise_exception=True)
def get_prowler_provider_kwargs(
provider: Provider, mutelist_processor: Processor | None = None
) -> dict:
@@ -232,7 +225,6 @@ def get_prowler_provider_kwargs(
"project_ids": [provider.uid],
}
elif provider.provider == Provider.ProviderChoices.KUBERNETES.value:
_validate_kubernetes_secret(prowler_provider_kwargs)
prowler_provider_kwargs = {**prowler_provider_kwargs, "context": provider.uid}
elif provider.provider == Provider.ProviderChoices.GITHUB.value:
if provider.uid:
@@ -310,26 +302,17 @@ def get_prowler_provider_kwargs(
def _normalize_oraclecloud_provider_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into SDK provider kwargs."""
prowler_provider_kwargs = secret.copy()
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
if home_region:
prowler_provider_kwargs["home_region"] = home_region
prowler_provider_kwargs.pop("region", None)
return prowler_provider_kwargs
def _oraclecloud_home_region(region) -> str | None:
"""Return the stored OCI region as a home region, ignoring blank or non-string legacy values."""
if isinstance(region, str) and region.strip():
return region.strip()
return None
def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
"""Normalize external OCI secret fields into test_connection kwargs."""
from prowler.providers.oraclecloud.oraclecloud_provider import OraclecloudProvider
prowler_provider_kwargs = secret.copy()
home_region = _oraclecloud_home_region(prowler_provider_kwargs.pop("region", None))
prowler_provider_kwargs.pop("region", None)
if (
prowler_provider_kwargs.get("user")
@@ -340,9 +323,11 @@ def _normalize_oraclecloud_connection_test_kwargs(secret: dict) -> dict:
or prowler_provider_kwargs.get("key_file")
)
):
# Identity calls only succeed in a region the tenancy is subscribed to.
prowler_provider_kwargs["region"] = (
home_region or OraclecloudProvider._bootstrap_region
# Connection validation needs one OCI endpoint, but scans remain unfiltered.
prowler_provider_kwargs["region"] = getattr(
OraclecloudProvider,
"_bootstrap_region",
OraclecloudProvider._home_region,
)
return prowler_provider_kwargs
@@ -400,16 +385,6 @@ def prowler_provider_connection_test(provider: Provider) -> Connection:
except Provider.secret.RelatedObjectDoesNotExist as secret_error:
return Connection(is_connected=False, error=secret_error)
if provider.provider == Provider.ProviderChoices.KUBERNETES.value:
# A rejected stored kubeconfig is a failed connection, not a task error.
try:
_validate_kubernetes_secret(prowler_provider_kwargs)
except ValidationError as validation_error:
return Connection(
is_connected=False,
error=Exception(validation_error.detail["kubeconfig_content"][0]),
)
# For IaC provider, construct the kwargs properly for test_connection
if provider.provider == Provider.ProviderChoices.IAC.value:
# Don't pass repository_url from secret, use scan_repository_url with the UID
@@ -214,8 +214,7 @@ from rest_framework_json_api import serializers
"kubeconfig_content": {
"type": "string",
"description": "The content of the Kubernetes kubeconfig file, encoded as a string. "
"Only inline credentials are supported: token, username/password, or client-certificate-data with client-key-data for users, and certificate-authority-data for clusters. "
"File-path and auth-provider kubeconfig fields are not supported.",
"Kubeconfig command-based authentication is not supported in Prowler Cloud for security reasons.",
}
},
"required": ["kubeconfig_content"],
@@ -302,7 +301,8 @@ from rest_framework_json_api import serializers
},
"region": {
"type": "string",
"description": "Optional OCI home region (or any region the tenancy is subscribed to) used to validate the credentials. It does not filter the scan, which covers all subscribed regions. Defaults to us-ashburn-1.",
"deprecated": True,
"description": "Legacy OCI region field accepted for backwards compatibility but ignored; OCI scans all regions.",
},
},
"required": ["user", "fingerprint", "tenancy"],
+41 -173
View File
@@ -71,7 +71,6 @@ from django.db import IntegrityError, transaction
from drf_spectacular.utils import extend_schema_field
from jwt.exceptions import InvalidKeyError
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.providers.oraclecloud.config import OCI_REGIONS
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from rest_framework_json_api import serializers
@@ -330,15 +329,6 @@ class TokenSwitchTenantSerializer(BaseSerializerV1):
# Users
class ActiveMembershipRelatedField(SerializerMethodResourceRelatedField):
def to_representation(self, value):
representation = super().to_representation(value)
representation["meta"] = {
"active": str(value.tenant_id) == str(self.context["request"].tenant_id),
}
return representation
class UserSerializer(BaseModelSerializerV1):
"""
Serializer for the User model.
@@ -377,54 +367,27 @@ class UserSerializer(BaseModelSerializerV1):
def _can_view_relationships(self, instance) -> bool:
"""Allow self to view own relationships. Require manage_account to view others."""
role = self.context.get("role")
return self._is_self(instance) or bool(role and role.manage_account)
def _is_self(self, instance) -> bool:
request = self.context.get("request")
return bool(
is_self = bool(
request
and getattr(request, "user", None)
and getattr(instance, "id", None) == request.user.id
)
def _active_tenant_id(self):
return getattr(self.context.get("request"), "tenant_id", None)
@staticmethod
def _in_tenant(related_manager, tenant_id):
"""Filter a prefetched related manager in memory.
``.filter()`` clones the queryset without its result cache, so it would
discard the viewset's prefetch and re-query once per serialized user.
"""
return [
item
for item in related_manager.all()
if str(item.tenant_id) == str(tenant_id)
]
return is_self or (role and role.manage_account)
def get_roles(self, instance):
tenant_id = self._active_tenant_id()
if not tenant_id or not self._can_view_relationships(instance):
return Role.objects.none()
return self._in_tenant(instance.roles, tenant_id)
return (
instance.roles.all()
if self._can_view_relationships(instance)
else Role.objects.none()
)
def get_memberships(self, instance):
if not self._can_view_relationships(instance):
return Membership.objects.none()
# Own memberships span tenants on purpose: the tenant switcher reads them.
if self._is_self(instance):
return instance.memberships.all()
tenant_id = self._active_tenant_id()
if not tenant_id:
return Membership.objects.none()
return self._in_tenant(instance.memberships, tenant_id)
class UserMeSerializer(UserSerializer):
memberships = ActiveMembershipRelatedField(
many=True, read_only=True, source="memberships", method_name="get_memberships"
)
return (
instance.memberships.all()
if self._can_view_relationships(instance)
else Membership.objects.none()
)
class UserIncludeSerializer(UserSerializer):
@@ -752,32 +715,11 @@ class MembershipIncludeSerializer(serializers.ModelSerializer):
included_serializers = {"tenant": "api.v1.serializers.TenantIncludeSerializer"}
_REQUESTER_TENANTS_ATTR = "_prowler_requester_tenants"
def _requester_tenants(self):
"""Tenants the requester belongs to, resolved once per request.
Memoised on the request because DRF builds a serializer instance per
included object, so a per-instance cache would still query per membership.
"""
request = self.context.get("request")
user = getattr(request, "user", None)
if user is None:
return {}
tenants = getattr(request, self._REQUESTER_TENANTS_ATTR, None)
if tenants is None:
tenants = {
tenant.id: tenant
for tenant in Tenant.objects.using(MainRouter.admin_db).filter(
membership__user_id=user.id
)
}
setattr(request, self._REQUESTER_TENANTS_ATTR, tenants)
return tenants
def get_tenant(self, instance):
"""Resolve the tenant only when the requester is a member of it."""
return self._requester_tenants().get(instance.tenant_id)
try:
return Tenant.objects.using(MainRouter.admin_db).get(id=instance.tenant_id)
except Tenant.DoesNotExist:
return None
# Provider Groups
@@ -1373,28 +1315,6 @@ class AttackPathsQuerySerializer(BaseSerializerV1):
attribution = AttackPathsQueryAttributionSerializer(allow_null=True, required=False)
provider = serializers.CharField()
parameters = AttackPathsQueryParameterSerializer(many=True)
# The terminal impact the query leads to (e.g. {"kind": "code_execution",
# "label": "Code execution"}), or null if the query has none. The UI renders
# this as the graph's terminal outcome node.
outcome = serializers.SerializerMethodField()
@extend_schema_field(
{
"type": "object",
"nullable": True,
"properties": {
"kind": {"type": "string"},
"label": {"type": "string"},
"partial": {"type": "boolean"},
},
}
)
def get_outcome(self, definition):
outcome = getattr(definition, "outcome", None)
if outcome is None:
return None
meta = outcome.value
return {"kind": meta.kind, "label": meta.label, "partial": meta.partial}
class JSONAPIMeta:
resource_name = "attack-paths-queries"
@@ -1701,43 +1621,14 @@ class FindingMetadataSerializer(BaseSerializerV1):
# Provider secrets
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR = (
"Only inline Kubernetes credentials are supported. "
"For user authentication use token, username/password, or client-certificate-data with client-key-data; "
"clusters may use certificate-authority-data. "
"File-path fields (tokenFile, client-certificate, client-key, certificate-authority), "
"command-based authentication (exec, cmd-path), auth-provider directives, and proxy URLs are not supported."
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR = (
"Kubernetes kubeconfig command-based authentication is not supported in "
"Prowler Cloud for security reasons."
)
KUBERNETES_KUBECONFIG_INVALID_ERROR = "Invalid Kubernetes kubeconfig content."
KUBECONFIG_ALLOWED_USER_KEYS = frozenset(
{
"token",
"username",
"password",
"client-certificate-data",
"client-key-data",
"as",
"as-uid",
"as-groups",
"as-user-extra",
}
)
KUBECONFIG_ALLOWED_CLUSTER_KEYS = frozenset(
{
"server",
"certificate-authority-data",
"insecure-skip-tls-verify",
"tls-server-name",
"disable-compression",
"extensions",
}
)
def kubeconfig_is_inline_only_credentials(kubeconfig: dict) -> bool:
"""Accept only inline credentials; reject file-reference and auth-provider fields."""
def kubeconfig_contains_unsupported_command_auth(kubeconfig: dict) -> bool:
users = kubeconfig.get("users", [])
if not isinstance(users, list):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
@@ -1750,25 +1641,21 @@ def kubeconfig_is_inline_only_credentials(kubeconfig: dict) -> bool:
if not isinstance(user, dict):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if any(key not in KUBECONFIG_ALLOWED_USER_KEYS for key in user):
return False
if "exec" in user:
return True
clusters = kubeconfig.get("clusters", [])
if not isinstance(clusters, list):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
auth_provider = user.get("auth-provider", {})
if not isinstance(auth_provider, dict):
continue
for cluster_entry in clusters:
if not isinstance(cluster_entry, dict):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
auth_provider_config = auth_provider.get("config", {})
if not isinstance(auth_provider_config, dict):
continue
cluster = cluster_entry.get("cluster", {})
if not isinstance(cluster, dict):
raise ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if "cmd-path" in auth_provider_config:
return True
if any(key not in KUBECONFIG_ALLOWED_CLUSTER_KEYS for key in cluster):
return False
return True
return False
class BaseWriteProviderSecretSerializer(BaseWriteSerializer):
@@ -1951,7 +1838,7 @@ class MongoDBAtlasProviderSecret(serializers.Serializer):
class KubernetesProviderSecret(serializers.Serializer):
kubeconfig_content = serializers.CharField(trim_whitespace=False)
kubeconfig_content = serializers.CharField()
def validate_kubeconfig_content(self, kubeconfig_content):
try:
@@ -1964,9 +1851,9 @@ class KubernetesProviderSecret(serializers.Serializer):
if not isinstance(kubeconfig, dict):
raise serializers.ValidationError(KUBERNETES_KUBECONFIG_INVALID_ERROR)
if not kubeconfig_is_inline_only_credentials(kubeconfig):
if kubeconfig_contains_unsupported_command_auth(kubeconfig):
raise serializers.ValidationError(
KUBERNETES_KUBECONFIG_NON_INLINE_CREDENTIALS_ERROR
KUBERNETES_KUBECONFIG_UNSUPPORTED_COMMAND_AUTH_ERROR
)
return kubeconfig_content
@@ -1993,16 +1880,9 @@ class IacProviderSecret(serializers.Serializer):
resource_name = "provider-secrets"
class OCIHomeRegionField(serializers.Field):
"""Optional OCI home region; blank or non-string legacy values are dropped."""
class LegacyOCIRegionField(serializers.Field):
def to_internal_value(self, data):
if not isinstance(data, str) or not data.strip():
return None
region = data.strip()
if region not in OCI_REGIONS:
raise serializers.ValidationError(f"Invalid OCI region: {region}")
return region
return data
def to_representation(self, value):
return value
@@ -2015,11 +1895,10 @@ class OracleCloudProviderSecret(serializers.Serializer):
key_content = serializers.CharField(required=False)
tenancy = serializers.CharField()
pass_phrase = serializers.CharField(required=False)
region = OCIHomeRegionField(required=False, allow_null=True)
region = LegacyOCIRegionField(required=False, allow_null=True)
def validate(self, attrs):
if not attrs.get("region"):
attrs.pop("region", None)
attrs.pop("region", None)
if "key_file" not in attrs and "key_content" not in attrs:
raise serializers.ValidationError(
@@ -2233,12 +2112,6 @@ class InvitationSerializer(RLSSerializer):
if tenant_id is not None:
self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id)
def to_representation(self, instance):
data = super().to_representation(instance)
if instance.is_lapsed:
data["state"] = Invitation.State.EXPIRED.value
return data
class Meta:
model = Invitation
fields = [
@@ -2265,7 +2138,6 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
self.fields["roles"].queryset = Role.objects.filter(tenant_id=tenant_id)
def validate_email(self, value):
value = value.strip().lower()
user = User.objects.filter(email=value).first()
tenant_id = self.context["tenant_id"]
if user and Membership.objects.filter(user=user, tenant=tenant_id).exists():
@@ -2273,13 +2145,9 @@ class InvitationBaseWriteSerializer(BaseWriteSerializer):
"The user may already be a member of the tenant or there was an issue with the "
"email provided."
)
pending_invitations = Invitation.objects.filter(
tenant_id=tenant_id, email=value, state=Invitation.State.PENDING
)
pending_invitations.filter(Invitation.lapsed_q()).update(
state=Invitation.State.EXPIRED
)
if pending_invitations.filter(expires_at__gt=datetime.now(UTC)).exists():
if Invitation.objects.filter(
email=value, state=Invitation.State.PENDING
).exists():
raise ValidationError(
"Unable to process your request. Please check the information provided and "
"try again."
Loaded 100 of 2520 files, more files were not shown because too many files have changed in this diff. Show more