mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
Merge branch 'master' into PROWLER-2085-kubernetes-kubeconfig-exec-rce
This commit is contained in:
@@ -215,7 +215,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17@sha256:2cd82735a36356842d5eb1ef80db3ae8f1154172f0f653db48fde079b2a0b7f7
|
||||
image: postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e
|
||||
env:
|
||||
POSTGRES_HOST: ${{ env.POSTGRES_HOST }}
|
||||
POSTGRES_PORT: ${{ env.POSTGRES_PORT }}
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
valkey:
|
||||
image: valkey/valkey:7-alpine3.19
|
||||
image: valkey/valkey:7-alpine3.19@sha256:4054fe7fc607b9326ac7c4691ed26e9670d2ff17a9fb28c2577adecf928acbcc
|
||||
env:
|
||||
VALKEY_HOST: ${{ env.VALKEY_HOST }}
|
||||
VALKEY_PORT: ${{ env.VALKEY_PORT }}
|
||||
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -49,6 +49,8 @@ jobs:
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6
|
||||
with:
|
||||
files: '**'
|
||||
safe_output: false # Raw paths (list read via env var, injection-safe); default escaping backslash-quotes chars like () and breaks the -f test
|
||||
separator: "\n" # Newline-delimited so the reader tolerates spaces and glob chars in paths
|
||||
|
||||
- name: Check for conflict markers
|
||||
id: conflict-check
|
||||
@@ -58,19 +60,18 @@ jobs:
|
||||
CONFLICT_FILES=""
|
||||
HAS_CONFLICTS=false
|
||||
|
||||
# Check each changed file for conflict markers
|
||||
for file in ${STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES}; do
|
||||
if [ -f "$file" ]; then
|
||||
echo "Checking file: $file"
|
||||
# Read newline-delimited paths so spaces/globs neither word-split nor glob-expand
|
||||
while IFS= read -r file; do
|
||||
[ -n "$file" ] || continue
|
||||
[ -f "$file" ] || continue
|
||||
echo "Checking file: $file"
|
||||
|
||||
# Look for conflict markers (more precise regex)
|
||||
if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$file" 2>/dev/null; then
|
||||
echo "Conflict markers found in: $file"
|
||||
CONFLICT_FILES="${CONFLICT_FILES}- \`${file}\`"$'\n'
|
||||
HAS_CONFLICTS=true
|
||||
fi
|
||||
if grep -qE '^(<<<<<<<|=======|>>>>>>>)' "$file" 2>/dev/null; then
|
||||
echo "Conflict markers found in: $file"
|
||||
CONFLICT_FILES="${CONFLICT_FILES}- \`${file}\`"$'\n'
|
||||
HAS_CONFLICTS=true
|
||||
fi
|
||||
done
|
||||
done <<< "$STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES"
|
||||
|
||||
if [ "$HAS_CONFLICTS" = true ]; then
|
||||
echo "has_conflicts=true" >> $GITHUB_OUTPUT
|
||||
@@ -87,18 +88,49 @@ jobs:
|
||||
env:
|
||||
STEPS_CHANGED_FILES_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }}
|
||||
|
||||
- name: Check base-branch mergeability
|
||||
id: merge-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
MERGEABLE=null
|
||||
|
||||
# GitHub computes mergeability async, so .mergeable is null until ready; poll until resolved
|
||||
for attempt in 1 2 3 4 5; do
|
||||
MERGEABLE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.mergeable')
|
||||
if [ "$MERGEABLE" != "null" ]; then
|
||||
break
|
||||
fi
|
||||
echo "Attempt ${attempt}: mergeability not computed yet, retrying..."
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Keep 'unknown' distinct from 'clean' so we never assert a clean merge we could not confirm
|
||||
case "$MERGEABLE" in
|
||||
false) STATUS=conflict; echo "PR branch cannot be merged cleanly into its base branch" ;;
|
||||
true) STATUS=clean; echo "PR branch merges cleanly into its base branch" ;;
|
||||
*) STATUS=unknown; echo "::warning::Mergeability did not resolve after retries; leaving it undetermined" ;;
|
||||
esac
|
||||
|
||||
echo "merge_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Manage conflict label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }}
|
||||
MERGE_STATUS: ${{ steps.merge-check.outputs.merge_status }}
|
||||
run: |
|
||||
LABEL_NAME="has-conflicts"
|
||||
|
||||
# Add or remove label based on conflict status
|
||||
if [ "$HAS_CONFLICTS" = "true" ]; then
|
||||
if [ "$HAS_CONFLICTS" = "true" ] || [ "$MERGE_STATUS" = "conflict" ]; then
|
||||
echo "Adding conflict label to PR #${PR_NUMBER}..."
|
||||
gh pr edit "$PR_NUMBER" --add-label "$LABEL_NAME" --repo ${{ github.repository }} || true
|
||||
elif [ "$MERGE_STATUS" = "unknown" ]; then
|
||||
# Don't drop the label on an undetermined merge state; a later run will settle it
|
||||
echo "Mergeability undetermined; leaving label unchanged"
|
||||
else
|
||||
echo "Removing conflict label from PR #${PR_NUMBER}..."
|
||||
gh pr edit "$PR_NUMBER" --remove-label "$LABEL_NAME" --repo ${{ github.repository }} || true
|
||||
@@ -120,20 +152,25 @@ jobs:
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!-- conflict-checker-comment -->
|
||||
${{ steps.conflict-check.outputs.has_conflicts == 'true' && '⚠️ **Conflict Markers Detected**' || '✅ **Conflict Markers Resolved**' }}
|
||||
|
||||
${{ steps.conflict-check.outputs.has_conflicts == 'true' && format('This pull request contains unresolved conflict markers in the following files:
|
||||
${{ (steps.conflict-check.outputs.has_conflicts == 'true' || steps.merge-check.outputs.merge_status == 'conflict') && '⚠️ **Conflicts Detected**' || (steps.merge-check.outputs.merge_status == 'unknown' && 'ℹ️ **Conflict Check Incomplete**' || '✅ **No Conflicts**') }}
|
||||
${{ steps.conflict-check.outputs.has_conflicts == 'true' && format('
|
||||
**Conflict markers** are present in the following files:
|
||||
|
||||
{0}
|
||||
|
||||
Please resolve these conflicts by:
|
||||
1. Locating the conflict markers: `<<<<<<<`, `=======`, and `>>>>>>>`
|
||||
2. Manually editing the files to resolve the conflicts
|
||||
3. Removing all conflict markers
|
||||
4. Committing and pushing the changes', steps.conflict-check.outputs.conflict_files) || 'All conflict markers have been successfully resolved in this pull request.' }}
|
||||
Resolve them by removing every `<<<<<<<`, `=======`, and `>>>>>>>` marker, then commit and push.', steps.conflict-check.outputs.conflict_files) || '' }}
|
||||
${{ steps.merge-check.outputs.merge_status == 'conflict' && '
|
||||
**Merge conflict with the base branch.** This PR cannot be merged cleanly. Update your branch with the latest base (rebase or merge) and resolve the conflicts.' || '' }}
|
||||
${{ steps.merge-check.outputs.merge_status == 'unknown' && '
|
||||
GitHub had not finished computing mergeability, so base-branch conflict status could not be verified on this run.' || '' }}
|
||||
${{ (steps.conflict-check.outputs.has_conflicts != 'true' && steps.merge-check.outputs.merge_status == 'clean') && '
|
||||
No conflict markers, and the branch merges cleanly into its base.' || '' }}
|
||||
|
||||
- name: Fail workflow if conflicts detected
|
||||
if: steps.conflict-check.outputs.has_conflicts == 'true'
|
||||
if: steps.conflict-check.outputs.has_conflicts == 'true' || steps.merge-check.outputs.merge_status == 'conflict'
|
||||
env:
|
||||
HAS_CONFLICTS: ${{ steps.conflict-check.outputs.has_conflicts }}
|
||||
MERGE_STATUS: ${{ steps.merge-check.outputs.merge_status }}
|
||||
run: |
|
||||
echo "::error::Workflow failed due to conflict markers detected in the PR"
|
||||
[ "$HAS_CONFLICTS" = "true" ] && echo "::error::Conflict markers detected in changed files"
|
||||
[ "$MERGE_STATUS" = "conflict" ] && echo "::error::PR branch has merge conflicts with the base branch"
|
||||
exit 1
|
||||
|
||||
@@ -309,7 +309,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
python-version: '3.12.13'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install pyyaml
|
||||
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
|
||||
- name: Install regctl
|
||||
if: always()
|
||||
uses: regclient/actions/regctl-installer@da9319db8e44e8b062b3a147e1dfb2f574d41a03 # main
|
||||
uses: regclient/actions/regctl-installer@9a2d4216180dbb3e2dccfa60d2dd4afd98e42ec5 # main
|
||||
|
||||
- name: Cleanup intermediate architecture tags
|
||||
if: always()
|
||||
|
||||
@@ -114,6 +114,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Review PR requirements: template, title conventions, changelog gate | `prowler-pr` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing JSON:API compliance | `jsonapi` |
|
||||
| Reviewing Prowler UI components | `prowler-ui` |
|
||||
| Reviewing compliance framework PRs | `prowler-compliance-review` |
|
||||
| Running makemigrations or pgmakemigrations | `django-migration-psql` |
|
||||
| Syncing compliance framework with upstream catalog | `prowler-compliance` |
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf 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"
|
||||
|
||||
@@ -10,6 +10,10 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Attack Paths: Scan task now checks the ingest Neo4j database and configured graph sink before starting graph ingestion [(#11743)](https://github.com/prowler-cloud/prowler/pull/11743)
|
||||
- Disable PowerShell telemetry in the API container image [(#11746)](https://github.com/prowler-cloud/prowler/pull/11746)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Attack Paths: Provider graph cleanup now deletes Neo4j and Neptune relationships in directed batches before deleting nodes [(#11755)](https://github.com/prowler-cloud/prowler/pull/11755)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Kubernetes provider credentials now reject kubeconfigs using `exec` authentication in Prowler Cloud, preventing user-supplied commands from running on Cloud workers [(#11753)](https://github.com/prowler-cloud/prowler/pull/11753)
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM python:3.12.13-slim-bookworm@sha256:76d4b7b6305788c6b4c6a19d6a22a3921bf802e9af4d5e1e5bd771208dba74bf AS build
|
||||
FROM python:3.12.13-slim-bookworm@sha256:8a7e7cc04fd3e2bd787f7f24e22d5d119aa590d429b50c95dfe12b3abe52f48b AS build
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud/api"
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Shared batched deletion helpers for sink backends."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
RELATIONSHIP_DELETE_QUERY_TEMPLATES = {
|
||||
"outgoing relationship": """
|
||||
MATCH (n:`{provider_label}`)-[r]->()
|
||||
WITH r LIMIT $batch_size
|
||||
DELETE r
|
||||
RETURN COUNT(r) AS deleted_rels_count
|
||||
""",
|
||||
"incoming relationship": """
|
||||
MATCH (n:`{provider_label}`)<-[r]-()
|
||||
WITH r LIMIT $batch_size
|
||||
DELETE r
|
||||
RETURN COUNT(r) AS deleted_rels_count
|
||||
""",
|
||||
}
|
||||
|
||||
NODE_DELETE_QUERY_TEMPLATE = """
|
||||
MATCH (n:{provider_resource_label}:`{provider_label}`)
|
||||
WITH n LIMIT $batch_size
|
||||
DELETE n
|
||||
RETURN COUNT(n) AS deleted_nodes_count
|
||||
"""
|
||||
|
||||
|
||||
def delete_batches(
|
||||
*,
|
||||
session: Any,
|
||||
logger: logging.Logger,
|
||||
log_target: str,
|
||||
provider_id: str,
|
||||
query: str,
|
||||
phase: str,
|
||||
count_key: str,
|
||||
total_key: str,
|
||||
deleted_key: str,
|
||||
initial_total: int,
|
||||
batch_size: int,
|
||||
drop_t0: float,
|
||||
) -> tuple[int, int]:
|
||||
deleted_total = initial_total
|
||||
batches = 0
|
||||
while True:
|
||||
logger.info(
|
||||
"Deleting %s batch from %s "
|
||||
"(provider=%s, batch=%s, total_%s=%s, elapsed=%.3fs)",
|
||||
phase,
|
||||
log_target,
|
||||
provider_id,
|
||||
batches + 1,
|
||||
total_key,
|
||||
deleted_total,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
record = session.run(query, {"batch_size": batch_size}).single()
|
||||
deleted = (record[count_key] if record else 0) or 0
|
||||
if deleted == 0:
|
||||
return deleted_total, batches
|
||||
|
||||
batches += 1
|
||||
deleted_total += deleted
|
||||
logger.info(
|
||||
"Deleted %s batch from %s "
|
||||
"(provider=%s, batch=%s, %s=%s, total_%s=%s, elapsed=%.3fs)",
|
||||
phase,
|
||||
log_target,
|
||||
provider_id,
|
||||
batches,
|
||||
deleted_key,
|
||||
deleted,
|
||||
total_key,
|
||||
deleted_total,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
@@ -17,6 +17,11 @@ import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.attack_paths.sink.drop import (
|
||||
NODE_DELETE_QUERY_TEMPLATE,
|
||||
RELATIONSHIP_DELETE_QUERY_TEMPLATES,
|
||||
delete_batches,
|
||||
)
|
||||
from config.env import env
|
||||
from django.conf import settings
|
||||
|
||||
@@ -204,10 +209,8 @@ class Neo4jSink(SinkDatabase):
|
||||
)
|
||||
|
||||
provider_label = get_provider_label(provider_id)
|
||||
deleted_nodes = 0
|
||||
deleted_relationships = 0
|
||||
relationship_batches = 0
|
||||
node_batches = 0
|
||||
deleted_nodes = deleted_relationships = 0
|
||||
relationship_batches = node_batches = 0
|
||||
drop_t0 = time.perf_counter()
|
||||
|
||||
logger.info(
|
||||
@@ -232,84 +235,44 @@ class Neo4jSink(SinkDatabase):
|
||||
database,
|
||||
provider_id,
|
||||
)
|
||||
# Phase 1: delete relationships incident to provider nodes in
|
||||
# batches. The undirected pattern matches an edge between two
|
||||
# provider nodes from both ends, so `DISTINCT r` dedupes it to
|
||||
# delete a full batch of unique relationships each round.
|
||||
deleted_count = 1
|
||||
while deleted_count > 0:
|
||||
next_batch = relationship_batches + 1
|
||||
logger.info(
|
||||
"Deleting relationship batch from Neo4j sink database %s "
|
||||
"(provider=%s, batch=%s, total_rels=%s, elapsed=%.3fs)",
|
||||
database,
|
||||
provider_id,
|
||||
next_batch,
|
||||
deleted_relationships,
|
||||
time.perf_counter() - drop_t0,
|
||||
log_target = f"Neo4j sink database {database}"
|
||||
for (
|
||||
phase,
|
||||
query_template,
|
||||
) in RELATIONSHIP_DELETE_QUERY_TEMPLATES.items():
|
||||
deleted_relationships, phase_batches = delete_batches(
|
||||
session=session,
|
||||
logger=logger,
|
||||
log_target=log_target,
|
||||
provider_id=provider_id,
|
||||
query=query_template.format(provider_label=provider_label),
|
||||
phase=phase,
|
||||
count_key="deleted_rels_count",
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH (:`{provider_label}`)-[r]-()
|
||||
WITH DISTINCT r LIMIT $batch_size
|
||||
DELETE r
|
||||
RETURN COUNT(r) AS deleted_rels_count
|
||||
""",
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
deleted_count = result.single().get("deleted_rels_count", 0)
|
||||
if deleted_count > 0:
|
||||
relationship_batches += 1
|
||||
deleted_relationships += deleted_count
|
||||
logger.info(
|
||||
"Deleted relationship batch from Neo4j sink database %s "
|
||||
"(provider=%s, batch=%s, deleted_rels=%s, "
|
||||
"total_rels=%s, elapsed=%.3fs)",
|
||||
database,
|
||||
provider_id,
|
||||
relationship_batches,
|
||||
deleted_count,
|
||||
deleted_relationships,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
|
||||
# Phase 2: delete the now relationship-free nodes in batches.
|
||||
deleted_count = 1
|
||||
while deleted_count > 0:
|
||||
next_batch = node_batches + 1
|
||||
logger.info(
|
||||
"Deleting node batch from Neo4j sink database %s "
|
||||
"(provider=%s, batch=%s, total_nodes=%s, elapsed=%.3fs)",
|
||||
database,
|
||||
provider_id,
|
||||
next_batch,
|
||||
deleted_nodes,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH (n:{PROVIDER_RESOURCE_LABEL}:`{provider_label}`)
|
||||
WITH n LIMIT $batch_size
|
||||
DELETE n
|
||||
RETURN COUNT(n) AS deleted_nodes_count
|
||||
""",
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
deleted_count = result.single().get("deleted_nodes_count", 0)
|
||||
if deleted_count > 0:
|
||||
node_batches += 1
|
||||
deleted_nodes += deleted_count
|
||||
logger.info(
|
||||
"Deleted node batch from Neo4j sink database %s "
|
||||
"(provider=%s, batch=%s, deleted_nodes=%s, "
|
||||
"total_nodes=%s, elapsed=%.3fs)",
|
||||
database,
|
||||
provider_id,
|
||||
node_batches,
|
||||
deleted_count,
|
||||
deleted_nodes,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
deleted_nodes, node_batches = delete_batches(
|
||||
session=session,
|
||||
logger=logger,
|
||||
log_target=log_target,
|
||||
provider_id=provider_id,
|
||||
query=NODE_DELETE_QUERY_TEMPLATE.format(
|
||||
provider_label=provider_label,
|
||||
provider_resource_label=PROVIDER_RESOURCE_LABEL,
|
||||
),
|
||||
phase="node",
|
||||
count_key="deleted_nodes_count",
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
except GraphDatabaseQueryException as exc:
|
||||
if exc.code == DATABASE_NOT_FOUND_CODE:
|
||||
|
||||
@@ -27,6 +27,11 @@ import neo4j
|
||||
import neo4j.exceptions
|
||||
from api.attack_paths.retryable_session import RetryableSession
|
||||
from api.attack_paths.sink.base import SinkDatabase
|
||||
from api.attack_paths.sink.drop import (
|
||||
NODE_DELETE_QUERY_TEMPLATE,
|
||||
RELATIONSHIP_DELETE_QUERY_TEMPLATES,
|
||||
delete_batches,
|
||||
)
|
||||
from botocore.auth import SigV4Auth
|
||||
from botocore.awsrequest import AWSRequest
|
||||
from botocore.session import Session as BotoSession
|
||||
@@ -296,78 +301,40 @@ class NeptuneSink(SinkDatabase):
|
||||
"Opened Neptune writer session for provider graph drop (provider=%s)",
|
||||
provider_id,
|
||||
)
|
||||
while True:
|
||||
next_batch = relationship_batches + 1
|
||||
logger.info(
|
||||
"Deleting relationship batch from Neptune sink "
|
||||
"(provider=%s, batch=%s, total_rels=%s, elapsed=%.3fs)",
|
||||
provider_id,
|
||||
next_batch,
|
||||
deleted_relationships,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH (:`{provider_label}`)-[r]-()
|
||||
WITH DISTINCT r LIMIT $batch_size
|
||||
DELETE r
|
||||
RETURN COUNT(r) AS deleted_rels_count
|
||||
""",
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
record = result.single()
|
||||
deleted_rels = (record["deleted_rels_count"] if record else 0) or 0
|
||||
if deleted_rels == 0:
|
||||
break
|
||||
relationship_batches += 1
|
||||
deleted_relationships += deleted_rels
|
||||
logger.info(
|
||||
"Deleted relationship batch from Neptune sink "
|
||||
"(provider=%s, batch=%s, deleted_rels=%s, total_rels=%s, "
|
||||
"elapsed=%.3fs)",
|
||||
provider_id,
|
||||
relationship_batches,
|
||||
deleted_rels,
|
||||
deleted_relationships,
|
||||
time.perf_counter() - drop_t0,
|
||||
for phase, query_template in RELATIONSHIP_DELETE_QUERY_TEMPLATES.items():
|
||||
deleted_relationships, phase_batches = delete_batches(
|
||||
session=session,
|
||||
logger=logger,
|
||||
log_target="Neptune sink",
|
||||
provider_id=provider_id,
|
||||
query=query_template.format(provider_label=provider_label),
|
||||
phase=phase,
|
||||
count_key="deleted_rels_count",
|
||||
total_key="rels",
|
||||
deleted_key="deleted_rels",
|
||||
initial_total=deleted_relationships,
|
||||
batch_size=BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
relationship_batches += phase_batches
|
||||
|
||||
deleted_nodes = 0
|
||||
while True:
|
||||
next_batch = node_batches + 1
|
||||
logger.info(
|
||||
"Deleting node batch from Neptune sink "
|
||||
"(provider=%s, batch=%s, total_nodes=%s, elapsed=%.3fs)",
|
||||
provider_id,
|
||||
next_batch,
|
||||
deleted_nodes,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
result = session.run(
|
||||
f"""
|
||||
MATCH (n:`{PROVIDER_RESOURCE_LABEL}`:`{provider_label}`)
|
||||
WITH n LIMIT $batch_size
|
||||
DELETE n
|
||||
RETURN COUNT(n) AS deleted_nodes_count
|
||||
""",
|
||||
{"batch_size": BATCH_SIZE},
|
||||
)
|
||||
record = result.single()
|
||||
deleted = (record["deleted_nodes_count"] if record else 0) or 0
|
||||
if deleted == 0:
|
||||
break
|
||||
node_batches += 1
|
||||
deleted_nodes += deleted
|
||||
logger.info(
|
||||
"Deleted node batch from Neptune sink "
|
||||
"(provider=%s, batch=%s, deleted_nodes=%s, total_nodes=%s, "
|
||||
"elapsed=%.3fs)",
|
||||
provider_id,
|
||||
node_batches,
|
||||
deleted,
|
||||
deleted_nodes,
|
||||
time.perf_counter() - drop_t0,
|
||||
)
|
||||
deleted_nodes, node_batches = delete_batches(
|
||||
session=session,
|
||||
logger=logger,
|
||||
log_target="Neptune sink",
|
||||
provider_id=provider_id,
|
||||
query=NODE_DELETE_QUERY_TEMPLATE.format(
|
||||
provider_label=provider_label,
|
||||
provider_resource_label=PROVIDER_RESOURCE_LABEL,
|
||||
),
|
||||
phase="node",
|
||||
count_key="deleted_nodes_count",
|
||||
total_key="nodes",
|
||||
deleted_key="deleted_nodes",
|
||||
initial_total=0,
|
||||
batch_size=BATCH_SIZE,
|
||||
drop_t0=drop_t0,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Finished dropping provider graph from Neptune sink "
|
||||
|
||||
@@ -186,6 +186,25 @@ def _session_ctx(session: MagicMock) -> MagicMock:
|
||||
return ctx
|
||||
|
||||
|
||||
def _count_result(key: str, count: int) -> MagicMock:
|
||||
return MagicMock(single=MagicMock(return_value={key: count}))
|
||||
|
||||
|
||||
def _directed_drop_results(
|
||||
outgoing_rels: int,
|
||||
incoming_rels: int,
|
||||
nodes: int,
|
||||
) -> list[MagicMock]:
|
||||
return [
|
||||
_count_result("deleted_rels_count", outgoing_rels),
|
||||
_count_result("deleted_rels_count", 0),
|
||||
_count_result("deleted_rels_count", incoming_rels),
|
||||
_count_result("deleted_rels_count", 0),
|
||||
_count_result("deleted_nodes_count", nodes),
|
||||
_count_result("deleted_nodes_count", 0),
|
||||
]
|
||||
|
||||
|
||||
class TestNeo4jSinkSyncWrites:
|
||||
def test_ensure_sync_indexes_runs_create_index_idempotent(self):
|
||||
from api.attack_paths.sink.neo4j import Neo4jSink
|
||||
@@ -310,65 +329,48 @@ class TestNeptuneSinkSyncWrites:
|
||||
|
||||
|
||||
class TestNeptuneSinkDropSubgraph:
|
||||
def test_drop_subgraph_deletes_rels_before_nodes_in_bounded_batches(self):
|
||||
def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self):
|
||||
from api.attack_paths.sink.neptune import NeptuneSink
|
||||
|
||||
sink = NeptuneSink()
|
||||
session = MagicMock()
|
||||
|
||||
rel_record_first = MagicMock()
|
||||
rel_record_first.__getitem__ = lambda _self, key: 50
|
||||
rel_record_drain = MagicMock()
|
||||
rel_record_drain.__getitem__ = lambda _self, key: 0
|
||||
node_record_first = MagicMock()
|
||||
node_record_first.__getitem__ = lambda _self, key: 10
|
||||
node_record_drain = MagicMock()
|
||||
node_record_drain.__getitem__ = lambda _self, key: 0
|
||||
|
||||
run_results = [
|
||||
MagicMock(single=MagicMock(return_value=rel_record_first)),
|
||||
MagicMock(single=MagicMock(return_value=rel_record_drain)),
|
||||
MagicMock(single=MagicMock(return_value=node_record_first)),
|
||||
MagicMock(single=MagicMock(return_value=node_record_drain)),
|
||||
]
|
||||
session.run.side_effect = run_results
|
||||
session.run.side_effect = _directed_drop_results(
|
||||
outgoing_rels=50,
|
||||
incoming_rels=30,
|
||||
nodes=10,
|
||||
)
|
||||
|
||||
with patch.object(sink, "get_session", return_value=_session_ctx(session)):
|
||||
deleted = sink.drop_subgraph("ignored", "provider-1")
|
||||
|
||||
assert deleted == 10
|
||||
first_query = session.run.call_args_list[0].args[0]
|
||||
assert "DELETE r" in first_query
|
||||
assert "DETACH DELETE" not in first_query
|
||||
# DISTINCT avoids double-counting relationships matched from both ends.
|
||||
assert "DISTINCT r" in first_query
|
||||
third_query = session.run.call_args_list[2].args[0]
|
||||
assert "DELETE n" in third_query
|
||||
assert session.run.call_count == 6
|
||||
queries = [call.args[0] for call in session.run.call_args_list]
|
||||
|
||||
assert ")-[r]->()" in queries[0]
|
||||
assert ")<-[r]-()" in queries[2]
|
||||
assert "DELETE n" in queries[4]
|
||||
assert all("DETACH DELETE" not in query for query in queries)
|
||||
assert all("DISTINCT r" not in query for query in queries)
|
||||
|
||||
first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q)
|
||||
last_rel = max(i for i, q in enumerate(queries) if "DELETE r" in q)
|
||||
assert last_rel < first_node
|
||||
|
||||
|
||||
class TestNeo4jSinkDropSubgraph:
|
||||
"""Neo4j drop deletes relationships then nodes in batches (no ``DETACH DELETE``)."""
|
||||
|
||||
def test_drop_subgraph_deletes_rels_before_nodes_in_bounded_batches(self):
|
||||
def test_drop_subgraph_deletes_directed_rels_before_nodes_in_bounded_batches(self):
|
||||
from api.attack_paths.sink.neo4j import Neo4jSink
|
||||
|
||||
sink = Neo4jSink()
|
||||
session = MagicMock()
|
||||
|
||||
rel_first = MagicMock()
|
||||
rel_first.get = lambda key, default=0: 50
|
||||
rel_drain = MagicMock()
|
||||
rel_drain.get = lambda key, default=0: 0
|
||||
node_first = MagicMock()
|
||||
node_first.get = lambda key, default=0: 10
|
||||
node_drain = MagicMock()
|
||||
node_drain.get = lambda key, default=0: 0
|
||||
session.run.side_effect = [
|
||||
MagicMock(single=MagicMock(return_value=rel_first)),
|
||||
MagicMock(single=MagicMock(return_value=rel_drain)),
|
||||
MagicMock(single=MagicMock(return_value=node_first)),
|
||||
MagicMock(single=MagicMock(return_value=node_drain)),
|
||||
]
|
||||
session.run.side_effect = _directed_drop_results(
|
||||
outgoing_rels=50,
|
||||
incoming_rels=30,
|
||||
nodes=10,
|
||||
)
|
||||
|
||||
provider_id = "00000000-0000-0000-0000-000000000abc"
|
||||
with patch.object(sink, "get_session", return_value=_session_ctx(session)):
|
||||
@@ -376,19 +378,20 @@ class TestNeo4jSinkDropSubgraph:
|
||||
|
||||
# Only phase-2 node counts contribute to the return value.
|
||||
assert deleted == 10
|
||||
assert session.run.call_count == 4
|
||||
assert session.run.call_count == 6
|
||||
|
||||
queries = [call.args[0] for call in session.run.call_args_list]
|
||||
# Regression guard: the memory blow-up was caused by DETACH DELETE.
|
||||
assert all("DETACH DELETE" not in query for query in queries)
|
||||
assert all("DISTINCT r" not in query for query in queries)
|
||||
|
||||
first_query = queries[0]
|
||||
assert "DELETE r" in first_query
|
||||
# DISTINCT avoids double-counting relationships matched from both ends.
|
||||
assert "DISTINCT r" in first_query
|
||||
assert ")-[r]->()" in first_query
|
||||
assert ":`_Provider_00000000000000000000000000000abc`" in first_query
|
||||
|
||||
assert "DELETE n" in queries[2]
|
||||
assert ")<-[r]-()" in queries[2]
|
||||
assert "DELETE n" in queries[4]
|
||||
|
||||
# Relationships must be fully drained before nodes are deleted.
|
||||
first_node = next(i for i, q in enumerate(queries) if "DELETE n" in q)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Build command
|
||||
# docker build --platform=linux/amd64 --no-cache -t prowler:latest .
|
||||
|
||||
ARG PROWLER_VERSION=latest@sha256:4b796c6df40a3350c7947747b59bdda230d0da6222287500e13b0a8e1574aad4
|
||||
ARG PROWLER_VERSION=latest@sha256:ebb4ab999f10cb7e7c256226c2873de9b3bf2f3d855f385e0164bcf34104bfba
|
||||
|
||||
FROM toniblyx/prowler:${PROWLER_VERSION}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine@sha256:8b1e78743a03dbb2c95171cc58639fef29abc8816598e27fb910ed2e621e589a
|
||||
image: nginx:alpine@sha256:54f2a904c251d5a34adf545a72d32515a15e08418dae0266e23be2e18c66fefa
|
||||
container_name: prowler-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -42,7 +42,7 @@ When adding a new configurable check to Prowler, update the following files:
|
||||
```
|
||||
- **Provider Schema:** Add the typed field to the provider's Pydantic schema in `prowler/config/schema/<provider>.py`. This is required: the loader validates user configs against these schemas and the shipped `config.yaml` must round-trip with zero warnings. See [Adding a Parameter to the Provider Schema](#adding-a-parameter-to-the-provider-schema) below.
|
||||
- **Test Fixtures:** If tests depend on this configuration, add the variable to `tests/config/fixtures/config.yaml`.
|
||||
- **Documentation:** Document the new variable in the list of configurable checks in `docs/tutorials/configuration_file.md`.
|
||||
- **Documentation:** Document the new variable in the list of configurable checks in [Configuration File](/user-guide/cli/tutorials/configuration_file) (`docs/user-guide/cli/tutorials/configuration_file.mdx`).
|
||||
|
||||
For a complete list of checks that already support configuration, see the [Configuration File Tutorial](/user-guide/cli/tutorials/configuration_file).
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const SubscriptionBanner = ({ children }) => {
|
||||
return (
|
||||
<Note>
|
||||
This feature is available exclusively in <b>Prowler Cloud</b> and <b>Prowler Enterprise</b> with a <a href="https://prowler.com/pricing">subscription</a>.
|
||||
{children}
|
||||
</Note>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,7 @@ The following list includes all the AWS checks with configurable variables that
|
||||
|---------------------------------------------------------------|--------------------------------------------------|-----------------|
|
||||
| `acm_certificates_expiration_check` | `days_to_expire_threshold` | Integer |
|
||||
| `acmpca_certificate_authority_pqc_key_algorithm` | `acmpca_pqc_key_algorithms` | List of Strings |
|
||||
| `apigateway_restapi_no_secrets_in_stage_variables` | `secrets_ignore_patterns` | List of Strings |
|
||||
| `appstream_fleet_maximum_session_duration` | `max_session_duration_seconds` | Integer |
|
||||
| `appstream_fleet_session_disconnect_timeout` | `max_disconnect_timeout_in_seconds` | Integer |
|
||||
| `appstream_fleet_session_idle_disconnect_timeout` | `max_idle_disconnect_timeout_in_seconds` | Integer |
|
||||
|
||||
@@ -12,6 +12,7 @@ The checks with this functionality are the following.
|
||||
|
||||
AWS:
|
||||
|
||||
- apigateway\_restapi\_no\_secrets\_in\_stage\_variables
|
||||
- autoscaling\_find\_secrets\_ec2\_launch\_configuration
|
||||
- awslambda\_function\_no\_secrets\_in\_code
|
||||
- awslambda\_function\_no\_secrets\_in\_variables
|
||||
|
||||
@@ -4,14 +4,13 @@ description: 'Create email alerts from Prowler Cloud findings to monitor relevan
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.26.0" />
|
||||
|
||||
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
<SubscriptionBanner />
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -226,8 +226,8 @@ Assign administrative permissions by selecting from the following options:
|
||||
|------------|-------|-------------|
|
||||
| Invite and Manage Users | All | Invite new users and manage existing ones. |
|
||||
| Manage Account | All | Adjust account settings, delete users and read/manage users permissions. |
|
||||
| Manage Scans | All | Run and review scans. |
|
||||
| Manage Providers | All | Add or modify connected providers. |
|
||||
| Manage Scans | All | Run and review scans, and manage [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) settings. |
|
||||
| Manage Providers | All | Add or modify connected providers, and attach or detach providers from a [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) (in addition to Manage Scans). |
|
||||
| Manage Integrations | All | Add or modify the Prowler Integrations. |
|
||||
| Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. |
|
||||
| Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. |
|
||||
|
||||
@@ -3,53 +3,40 @@ title: 'Scan Configuration'
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.32.0" />
|
||||
|
||||
Scan Configuration lets you override Prowler's built-in scan defaults per tenant and per provider, directly from Prowler App — without editing files or redeploying. Each configuration is a small YAML document that changes how specific checks behave (thresholds, allowed values, retention windows, and so on), and you attach it to the cloud providers that should use it on their next scan.
|
||||
Scan Configuration lets you override, per provider, specific values in the default configuration Prowler's checks use during a scan. Each configuration modifies how specific checks behave, e.g.: thresholds, allowed values, retention windows, and you attach it to the providers that you want to use it on their next scan.
|
||||
|
||||
<Note>
|
||||
Scan Configuration is a **Prowler Cloud-only** feature. The open-source API does not expose the `scan-configurations` endpoints, so the menu item and provider actions described here only appear in Prowler Cloud.
|
||||
</Note>
|
||||
<SubscriptionBanner />
|
||||
|
||||
## What Is a Scan Configuration?
|
||||
|
||||
Every Prowler scan reads a set of tunable values documented in [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml) — for example, how many days an access key can stay unused before it's flagged, or the minimum retention period for a storage bucket. A Scan Configuration is a **partial override** of those defaults:
|
||||
A Scan Configuration lets you **override specific values, per provider**, on top of Prowler's defaults. It's merged with those defaults, not a full replacement:
|
||||
|
||||
- You include **only** the keys you want to change. Everything else falls back to Prowler's built-in defaults.
|
||||
- It is stored per tenant and applied to the **providers you attach** to it.
|
||||
- A provider type you don't add a section for keeps using `config.yaml` untouched.
|
||||
- **A provider type you do add a section for has its keys merged over `config.yaml` for that provider.** Only the keys you set are overridden; every key you leave out keeps its default from `config.yaml`, because each check falls back to the default configuration when a value isn't provided. See [How It's Applied](#how-its-applied) for details.
|
||||
- It is stored per organization and applied to the **providers you attach** to it.
|
||||
- **Attaching a provider is optional at creation time.** You can save a Scan Configuration with no providers attached and associate them later, either from the configuration's editor or from the **Providers** page (see [Attaching Providers](#attaching-providers)). It has no effect on any scan until at least one provider is attached.
|
||||
- A provider can be attached to **at most one** Scan Configuration at a time.
|
||||
- Changes take effect on the provider's **next scan** — they do not re-run past scans.
|
||||
- Changes take effect on the provider's **next scan** and do not re-run past scans.
|
||||
|
||||
This is different from the [Mutelist](/user-guide/tutorials/prowler-app-mute-findings), which hides findings. A Scan Configuration changes how the checks themselves evaluate your resources.
|
||||
|
||||
## Where to Find It
|
||||
The full set of configurable values and their defaults lives in [`prowler/config/config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml). For what each key means and which checks read it, see the [Configuration File tutorial](/user-guide/cli/tutorials/configuration_file).
|
||||
|
||||
In Prowler Cloud, open **Configuration → Scan** in the sidebar, or go directly to `/scans/config`. The page lists every Scan Configuration in your tenant, with search by name and a filter by provider.
|
||||
## Required Permissions
|
||||
|
||||
## Creating a Scan Configuration
|
||||
Scan Configuration access is governed by Role-Based Access Control (RBAC). See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details on each permission.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open the editor">
|
||||
On the **Scan** page, click **New Scan Configuration**.
|
||||
</Step>
|
||||
<Step title="Name it">
|
||||
Give the configuration a descriptive **Name** (3–100 characters), e.g. `stricter-iam-aws`. Names must be unique within your tenant.
|
||||
</Step>
|
||||
<Step title="Write the YAML overrides">
|
||||
In the **Configuration (YAML)** field, add only the keys you want to override, grouped by provider. The editor is pre-filled with a representative default placeholder you can use as a starting point.
|
||||
</Step>
|
||||
<Step title="Attach providers (optional)">
|
||||
Under **Attach to providers**, pick the providers that should use this configuration. This is optional — you can save without any provider and attach them later.
|
||||
</Step>
|
||||
<Step title="Save">
|
||||
Click **Save**. The server validates the configuration values and, if everything is valid, stores it and attaches the selected providers.
|
||||
</Step>
|
||||
</Steps>
|
||||
- **Viewing** a Scan Configuration doesn't require any specific permission.
|
||||
- **Creating, editing, and deleting** a Scan Configuration requires the **Manage Scans** permission.
|
||||
- **Attaching or detaching providers** requires the **Manage Providers** permission as well, in addition to Manage Scans. This applies to explicit changes to the attached providers, and to deleting a Scan Configuration that still has providers attached (deleting it detaches them too).
|
||||
- Attaching or detaching a provider also requires that provider to be **visible to your role**. Visibility comes from the [Provider Groups](/user-guide/tutorials/prowler-app-rbac#provider-groups) assigned to your role, or from **Unlimited Visibility**. You can't attach a provider you can't see, and you can't detach one either, whether by removing it from the list or by deleting the Scan Configuration it's attached to.
|
||||
|
||||
### YAML Structure
|
||||
## Config Schema
|
||||
|
||||
The YAML follows the structure of `config.yaml`: a mapping keyed by provider, with each provider section holding the keys you want to override.
|
||||
The YAML follows the structure of `config.yaml`: a mapping keyed by provider, with each provider section holding the keys you want to change. You only list the keys you want to override; they're merged over that provider's `config.yaml` defaults.
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
@@ -64,35 +51,36 @@ gcp:
|
||||
storage_min_retention_days: 30
|
||||
```
|
||||
|
||||
Scan Configuration works for **every provider Prowler scans** — you key your overrides by provider using the same section names as `config.yaml`. Each provider below ships a configuration schema, so its values are checked on save (ranges, enums, and types):
|
||||
## Creating a Scan Configuration
|
||||
|
||||
| Provider | Section key |
|
||||
| --- | --- |
|
||||
| AWS | `aws` |
|
||||
| Azure | `azure` |
|
||||
| Google Cloud | `gcp` |
|
||||
| Kubernetes | `kubernetes` |
|
||||
| Microsoft 365 | `m365` |
|
||||
| GitHub | `github` |
|
||||
| MongoDB Atlas | `mongodbatlas` |
|
||||
| Cloudflare | `cloudflare` |
|
||||
| Vercel | `vercel` |
|
||||
| Okta | `okta` |
|
||||
| Alibaba Cloud | `alibabacloud` |
|
||||
| OpenStack | `openstack` |
|
||||
|
||||
Sections that aren't listed here — those contributed by third-party check plugins, or providers that don't yet ship tunable defaults — are **accepted as-is** and applied without server-side value validation.
|
||||
<Steps>
|
||||
<Step title="Open the editor">
|
||||
On the **Scan** page (under **Configuration**), click **New Scan Configuration**.
|
||||
</Step>
|
||||
<Step title="Name it">
|
||||
Give the configuration a descriptive **Name** (3–100 characters), e.g. `stricter-iam-aws`.
|
||||
</Step>
|
||||
<Step title="Write the configuration file">
|
||||
In the **Configuration (YAML)** field, add the keys you want to change, grouped by provider. Only the keys you set are overridden; every other key keeps its `config.yaml` default. The editor is pre-filled with a representative default placeholder you can use as a starting point.
|
||||
</Step>
|
||||
<Step title="Attach providers (optional)">
|
||||
Under **Attach to providers**, pick the providers that should use this configuration. This is optional, you can save without any provider and attach them later, either by editing this configuration or from the **Providers** page (see [Attaching Providers](#attaching-providers)).
|
||||
</Step>
|
||||
<Step title="Save">
|
||||
Click **Save**. The server validates the configuration values and, if everything is valid, stores it and attaches the selected providers.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Tip>
|
||||
You don't need to fill in every provider — include only the sections and keys you actually want to change. The placeholder shown in the editor is just an example; if you leave the field with only the placeholder (greyed-out) text, nothing is saved.
|
||||
You don't need to fill in every provider. A section you don't include leaves that provider's `config.yaml` untouched. Within a section you do include, only add the keys you want to change; the rest keep their `config.yaml` defaults. The placeholder shown in the editor is just an example; if you leave the field with only the placeholder (greyed-out) text, nothing is saved.
|
||||
</Tip>
|
||||
|
||||
## How Validation Works
|
||||
|
||||
Validation happens in two layers, mirroring the Advanced Mutelist editor:
|
||||
Prowler checks your configuration in two stages, the same way the Advanced Mutelist editor does:
|
||||
|
||||
1. **Client-side (live): YAML syntax only.** As you type, the editor checks that the text parses to a valid YAML mapping. If it doesn't, you'll see an `Invalid YAML format` message and the **Save** button is disabled. When the syntax is valid, it shows **Valid YAML format**.
|
||||
2. **Server-side (on save): configuration values.** When you click Save (or Update), the API validates the actual values — ranges, enums, and types — against Prowler's schema. Any problems are returned and shown **inline beneath the field**, for both create and edit.
|
||||
1. **As you type: is it well-formed?** The editor checks that what you've written is valid YAML. If something is off, you'll see an `Invalid YAML format` message and **Save** stays disabled until you fix it. Once it's clean, it shows **Valid YAML format**.
|
||||
2. **When you save: are the values allowed?** Saving checks that each value is one Prowler accepts, the right type, within range, and one of the allowed options where a key only takes a fixed set of choices. If a value isn't allowed, the editor points to the exact key and explains why, right beneath the field, so you can correct it and save again.
|
||||
|
||||
For example, `azure.defender_attack_path_minimal_risk_level` only accepts `Low`, `Medium`, `High`, or `Critical`. Saving any other value returns an inline error like:
|
||||
|
||||
@@ -101,9 +89,9 @@ azure.defender_attack_path_minimal_risk_level: Input should be 'Low', 'Medium',
|
||||
```
|
||||
|
||||
<Warning>
|
||||
"Valid YAML format" confirms only that the document is **syntactically** correct — it does **not** mean the values are valid. Value validation (ranges and enums) is performed by the server when you save.
|
||||
**Valid YAML format** only means the text is well-formed; it doesn't mean your values are accepted. Those are checked when you save.
|
||||
|
||||
Be careful with indentation. A line like `azure: defender_attack_path_minimal_risk_level: Critical` (no newline/indent after `azure:`) is *valid YAML*, but it parses to a single top-level key named `azure:defender_attack_path_minimal_risk_level` instead of the nested `azure` section — so the value is never applied. Always nest provider keys:
|
||||
Be careful with indentation. A line like `azure: defender_attack_path_minimal_risk_level: Critical` (no line break and indent after `azure:`) is still valid YAML, but Prowler reads it as one long key name instead of a setting inside the `azure` section, so the value is silently ignored. Always nest provider keys:
|
||||
|
||||
```yaml
|
||||
azure:
|
||||
@@ -112,7 +100,7 @@ azure:
|
||||
</Warning>
|
||||
|
||||
<Info>
|
||||
Unknown top-level sections and unknown keys inside a known provider section are **tolerated** (accepted without error) for backward compatibility with third-party check plugins. This means typos in section or key names won't be rejected on save — double-check your structure against `config.yaml`.
|
||||
Prowler won't flag a section or key it doesn't recognize. It accepts them without error, so custom checks and plugins can add their own settings. The trade-off: a typo in a key or section name isn't rejected either, and that misspelled setting simply won't apply. Double-check your spelling against `config.yaml`.
|
||||
</Info>
|
||||
|
||||
## Attaching Providers
|
||||
@@ -131,11 +119,10 @@ You can also manage a provider's configuration from **Providers**:
|
||||
<Step title="Open the provider menu">
|
||||
On the **Providers** page, open the **⋮** menu on a provider row.
|
||||
</Step>
|
||||
<Step title="Choose the scan-config action">
|
||||
Click **Edit Scan Configuration**.
|
||||
<Step title="Click **Edit Scan Configuration**.">
|
||||
</Step>
|
||||
<Step title="Pick a configuration">
|
||||
In the dialog, choose an existing configuration from the dropdown to associate it, pick a different one to move the provider, or select **Default** to detach it. **Default** means the provider uses Prowler's built-in scan defaults from the SDK (no custom configuration), and it's always available — even if no custom configurations exist yet. Then click **Save**.
|
||||
In the dialog, pick a configuration from the dropdown to apply it (picking a different one moves the provider), or pick **Default** to detach it and go back to the built-in `config.yaml` defaults. Then click **Save**.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -144,7 +131,7 @@ This dialog only **associates or disassociates** an existing configuration. To c
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
Because a provider can belong to only one configuration, associating a provider that is already attached elsewhere **moves** it to the new configuration automatically — it is removed from the previous one.
|
||||
Because a provider can belong to only one configuration, associating a provider that is already attached elsewhere **moves** it to the new configuration automatically; it is removed from the previous one.
|
||||
</Info>
|
||||
|
||||
## Editing and Deleting
|
||||
@@ -152,20 +139,43 @@ Because a provider can belong to only one configuration, associating a provider
|
||||
On the **Scan Config** page, open the **⋮** menu on a configuration row:
|
||||
|
||||
- **Edit:** Choose **Edit** to open the editor, change its name, YAML, or attached providers, and click **Update**. Editing the YAML always happens here, never from the provider row.
|
||||
- **Delete:** Choose **Delete** (in the danger zone) and confirm. Providers that were attached fall back to Prowler's built-in scan defaults on their next scan.
|
||||
- **Delete:** Choose **Delete** (in the danger zone) and confirm. Providers that were attached fall back to the built-in defaults from `config.yaml` on their next scan.
|
||||
|
||||
## How It's Applied
|
||||
|
||||
When a scan runs for a provider:
|
||||
|
||||
1. If the provider is attached to a Scan Configuration, Prowler applies that configuration's overrides on top of the built-in defaults.
|
||||
2. If it isn't attached to any, the built-in defaults from `config.yaml` are used.
|
||||
1. If the provider is attached to a Scan Configuration **and that configuration has a section for the provider's type** (e.g. `aws` for an AWS provider), Prowler merges that section over `config.yaml` for that provider's scan: the keys you set win, and every key you didn't set keeps its `config.yaml` default.
|
||||
2. If the provider is attached to a Scan Configuration, but that configuration **has no section for the provider's type** (for example, a configuration that only defines an `aws` section, attached to a GCP provider), the scan uses the built-in defaults from `config.yaml` for that provider, exactly as if no Scan Configuration were attached at all.
|
||||
3. If the provider isn't attached to any Scan Configuration, the built-in defaults from `config.yaml` are used.
|
||||
|
||||
Overrides are merged key by key: any value you don't set keeps its default.
|
||||
<Note>
|
||||
The merge is per key. A key you don't set keeps its `config.yaml` default, because each check falls back to the default configuration when a value isn't provided. You only need to list the keys you want to change.
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
A single Scan Configuration can hold sections for several provider types at once (see [Config Schema](#config-schema)) and be attached to providers of different types. Each provider only ever picks up the section matching its own type; the rest of the YAML is ignored for that provider.
|
||||
</Tip>
|
||||
|
||||
## Effect on Compliance Results
|
||||
|
||||
Some compliance requirements only hold if the checks they map to ran with a strict-enough configuration. For example, a requirement expecting unused access keys to be disabled within 45 days loses its meaning if a Scan Configuration raises `max_unused_access_keys_days` to 120: the check would still PASS, but the requirement wouldn't really be met.
|
||||
|
||||
When a scan's applied configuration doesn't meet a requirement's expectations, Prowler marks that requirement as **FAIL** on the Compliance page, even if every individual finding passed. The requirement shows an info icon (and, when expanded, an inline alert) with:
|
||||
|
||||
> Marked as FAIL because the applied scan configuration does not meet this requirement, even though all findings passed.
|
||||
|
||||
This only affects requirements built around a configurable check that declares this kind of expectation; requirements without one are never affected by an attached Scan Configuration.
|
||||
|
||||
## Common Examples
|
||||
|
||||
**Stricter IAM hygiene for AWS:**
|
||||
Each example below shows only the keys being changed for that provider.
|
||||
|
||||
<Note>
|
||||
Only the keys shown are overridden. Every other key for that provider keeps its `config.yaml` default (see [How It's Applied](#how-its-applied)).
|
||||
</Note>
|
||||
|
||||
**Stricter IAM (Identity and Access Management) hygiene for AWS:**
|
||||
|
||||
```yaml
|
||||
aws:
|
||||
@@ -191,18 +201,10 @@ gcp:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Note>
|
||||
**Save is disabled.** The YAML has a syntax error (or the field is empty). Fix the `Invalid YAML format` message shown beneath the editor.
|
||||
</Note>
|
||||
### A Provider Doesn't Appear in the Selector
|
||||
|
||||
<Note>
|
||||
**An inline error appears after saving.** The server rejected a value (out of range or not an allowed enum). The message names the exact path, e.g. `aws.max_unused_access_keys_days: ...`. Correct the value and save again.
|
||||
</Note>
|
||||
The provider is already attached to another Scan Configuration. Detach it there first, or use the provider row menu to move it.
|
||||
|
||||
<Note>
|
||||
**A provider doesn't appear in the selector.** It's already attached to another Scan Configuration. Detach it there first, or use the provider row menu to move it.
|
||||
</Note>
|
||||
### You Can't Attach or Detach Providers
|
||||
|
||||
<Note>
|
||||
**My override doesn't seem to apply.** Check indentation (provider keys must be nested under their section) and key spelling — unknown keys are silently accepted. Compare against [`config.yaml`](https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml).
|
||||
</Note>
|
||||
If you can edit the configuration but not change its providers, or an error mentions a provider ID that "wasn't found", you're missing the **Manage Providers** permission or the provider isn't visible to your role. A provider outside your visibility is reported the same way as one that doesn't exist, so it isn't revealed to roles that shouldn't see it. See [Required Permissions](#required-permissions).
|
||||
|
||||
@@ -98,6 +98,12 @@ Choose a Method:
|
||||
|
||||
</Info>
|
||||
<Warning>
|
||||
**Single-Value `userType` Required**
|
||||
|
||||
Map `userType` to an IdP attribute that always contains a single value. If the IdP sends multiple values, Prowler App uses only the first value and does not assign multiple roles or select the highest-privilege role.
|
||||
|
||||
</Warning>
|
||||
<Warning>
|
||||
**Dynamic Updates**
|
||||
|
||||
Prowler App updates these attributes each time a user logs in. Any changes made in the Identity Provider (IdP) will be reflected when the user logs in again.
|
||||
@@ -154,6 +160,7 @@ Choose a Method:
|
||||
* If a role with the specified name already exists in Prowler App, the user automatically receives that role.
|
||||
* If the role does not exist, Prowler App creates a new role with that exact name with read-only access: the user can see all providers and their findings but cannot manage anything. A Prowler administrator (a user whose role includes the "Manage Account" permission) can adjust its permissions afterward through the [RBAC Management tab](/user-guide/tutorials/prowler-app-rbac).
|
||||
* If `userType` is not defined in the user's Okta profile, the user's existing roles in Prowler App are left unchanged.
|
||||
* `userType` must contain a single value. If the IdP sends multiple values, Prowler App uses only the first value and does not assign multiple roles.
|
||||
|
||||
**Example:** To assign the `IT` role to a user, set the `userType` value to `IT` in Okta. If a role named `IT` already exists in Prowler App, the user receives it automatically upon login. If it does not exist, Prowler App creates a new role called `IT` with read-only access, and a Prowler administrator can adjust its permissions as needed.
|
||||
|
||||
|
||||
@@ -18,17 +18,14 @@ After [installing](/getting-started/installation/prowler-app) **Prowler App**, a
|
||||
To view the auto-generated **Prowler API** documentation, navigate to [http://localhost:8080/api/v1/docs](http://localhost:8080/api/v1/docs). This documentation provides details on available endpoints, parameters, and responses.
|
||||
</Note>
|
||||
|
||||
## **Step 1: Sign Up**
|
||||
|
||||
### **Sign Up with Email**
|
||||
|
||||
## Step 1: Sign Up
|
||||
### Sign Up with Email
|
||||
To get started, sign up using your email and password:
|
||||
|
||||
<img src="/images/sign-up-button.png" alt="Sign Up Button" width="320" />
|
||||
<img src="/images/sign-up.png" alt="Sign Up" width="285" />
|
||||
|
||||
### **Sign Up with Social Login**
|
||||
|
||||
### Sign Up with Social Login
|
||||
If Social Login is enabled, you can sign up using your preferred provider (e.g., Google, GitHub).
|
||||
|
||||
<Note>
|
||||
@@ -44,16 +41,14 @@ If your email is not registered, a new account will be created using your social
|
||||
See [how to configure Social Login for Prowler](/user-guide/tutorials/prowler-app-social-login) to enable this feature in your own deployments.
|
||||
|
||||
</Note>
|
||||
## **Step 2: Log In**
|
||||
|
||||
## Step 2: Log In
|
||||
Once registered, log in with your email and password to access Prowler App.
|
||||
|
||||
<img src="/images/log-in.png" alt="Log In" width="350" />
|
||||
|
||||
Upon logging in, the Overview page will display. At this stage, no data is present: add a provider to begin scanning your cloud environment.
|
||||
|
||||
## **Step 3: Add a Provider**
|
||||
|
||||
## Step 3: Add a Provider
|
||||
To perform security scans, link a cloud provider account. Prowler supports the following providers and more:
|
||||
|
||||
- **AWS**
|
||||
@@ -77,8 +72,7 @@ Steps to add a provider:
|
||||
|
||||
<img src="/images/add-provider.png" alt="Add Provider" width="700" />
|
||||
|
||||
## **Step 4: Configure the Provider**
|
||||
|
||||
## Step 4: Configure the Provider
|
||||
Select the cloud provider to scan and configure authentication credentials. Each provider has specific requirements and authentication methods.
|
||||
|
||||
<img src="/images/select-provider.png" alt="Select a Provider" width="700" />
|
||||
@@ -111,14 +105,12 @@ For detailed instructions on configuring credentials for each provider, refer to
|
||||
Scan IaC public or private repositories for security issues.
|
||||
</Card>
|
||||
</Columns>
|
||||
## **Step 5: Test Connection**
|
||||
|
||||
## Step 5: Test Connection
|
||||
After adding your credentials of your cloud account, click the `Launch` button to verify that Prowler App can successfully connect to your provider:
|
||||
|
||||
<img src="/images/test-connection-button.png" alt="Test Connection" width="700" />
|
||||
|
||||
## **Step 6: Scan started**
|
||||
|
||||
## Step 6: Scan Started
|
||||
After successfully adding and testing your credentials, Prowler will start scanning your cloud environment, click the `Go to Scans` button to see the progress:
|
||||
|
||||
<img src="/images/provider-added.png" alt="Start Now" width="700" />
|
||||
@@ -127,8 +119,7 @@ After successfully adding and testing your credentials, Prowler will start scann
|
||||
Prowler will automatically scan all configured providers every **24 hours**, ensuring your cloud environment stays continuously monitored.
|
||||
|
||||
</Note>
|
||||
## **Step 7: Monitor Scan Progress**
|
||||
|
||||
## Step 7: Monitor Scan Progress
|
||||
Track the progress of your scan in the `Scans` section:
|
||||
|
||||
<img src="/images/scan-progress.png" alt="Scan Progress" width="700" />
|
||||
@@ -146,8 +137,7 @@ Each dashboard handles scan data differently:
|
||||
When a new scan completes or a new data ingestion is processed, the dashboards automatically reflect the updated results.
|
||||
</Note>
|
||||
|
||||
## **Step 8: Analyze the Findings**
|
||||
|
||||
## Step 8: Analyze the Findings
|
||||
While the scan is running, start exploring the findings in these sections:
|
||||
|
||||
- **Overview**: High-level summary of the scans.
|
||||
@@ -168,8 +158,7 @@ While the scan is running, start exploring the findings in these sections:
|
||||
|
||||
To view all `new` findings that have not been seen prior to this scan, click the `Delta` filter and select `new`. To view all `changed` findings that have had a status change (from `PASS` to `FAIL` for example), click the `Delta` filter and select `changed`.
|
||||
|
||||
## **Step 9: Download the Outputs**
|
||||
|
||||
## Step 9: Download the Outputs
|
||||
Once a scan is complete, navigate to the Scan Jobs section to download the output files generated by Prowler:
|
||||
|
||||
<img src="/images/scan_jobs_section.png" alt="Scan Jobs section" width="700" />
|
||||
@@ -190,8 +179,7 @@ The `zip` file unpacks into a folder named like `prowler-output-<provider_id>-<t
|
||||
For more information about the API endpoint used by the UI to download the ZIP archive, refer to: [Prowler API Reference - Download Scan Output](https://api.prowler.com/api/v1/docs#tag/Scan/operation/scans_report_retrieve)
|
||||
|
||||
</Note>
|
||||
## **Step 10: Download specified compliance report**
|
||||
|
||||
## Step 10: Download Specified Compliance Report
|
||||
Once your scan has finished, you don’t need to grab the entire ZIP—just pull down the specific compliance report you want:
|
||||
|
||||
- Navigate to the **Compliance** section of the UI.
|
||||
|
||||
@@ -4,14 +4,15 @@ description: 'Onboard all AWS accounts in your Organization through a single gui
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Prowler Cloud enables you to onboard all AWS accounts in your Organization through a single guided wizard. Instead of connecting accounts one by one, you can discover every account in your AWS Organization, select the ones you want to monitor, test connectivity, and launch scans — all from the Prowler Cloud UI.
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing). For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
|
||||
</Note>
|
||||
<SubscriptionBanner>
|
||||
For CLI-based multi-account scanning, see [AWS Organizations in Prowler CLI](/user-guide/providers/aws/organizations).
|
||||
</SubscriptionBanner>
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ description: 'Upload OCSF scan results to Prowler Cloud from external sources or
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.19.0" />
|
||||
|
||||
Findings Ingestion enables uploading OCSF (Open Cybersecurity Schema Framework) scan results to Prowler Cloud. This feature supports importing findings from Prowler CLI output files that use the [Detection Finding](https://schema.ocsf.io/classes/detection_finding) class.
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
<SubscriptionBanner />
|
||||
|
||||
## OCSF Detection Finding format
|
||||
## OCSF Detection Finding Format
|
||||
|
||||
The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Detection Finding records. Each finding represents a security check result from Prowler.
|
||||
|
||||
@@ -130,7 +129,7 @@ The ingestion API accepts `.ocsf.json` files containing a JSON array of OCSF Det
|
||||
Only **Detection Finding** (`class_uid: 2004`) records are accepted. Other OCSF classes are not supported for ingestion.
|
||||
</Note>
|
||||
|
||||
## Required permissions
|
||||
## Required Permissions
|
||||
|
||||
The **Manage Ingestions** RBAC permission controls access to the ingestion endpoints. Without this permission, findings cannot be submitted via the API or `--push-to-cloud`.
|
||||
|
||||
@@ -145,7 +144,7 @@ The `--push-to-cloud` flag uploads scan results directly to Prowler Cloud after
|
||||
- A valid Prowler Cloud API key (see [API Keys](/user-guide/tutorials/prowler-app-api-keys))
|
||||
- The `PROWLER_CLOUD_API_KEY` environment variable configured
|
||||
|
||||
### Basic usage
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
|
||||
@@ -153,7 +152,7 @@ export PROWLER_CLOUD_API_KEY="pk_your_api_key_here"
|
||||
prowler aws --push-to-cloud
|
||||
```
|
||||
|
||||
### Combining with output formats
|
||||
### Combining with Output Formats
|
||||
|
||||
When using `--push-to-cloud` with custom output formats that exclude OCSF, Prowler generates a temporary OCSF file for upload:
|
||||
|
||||
@@ -169,7 +168,7 @@ When default output formats include OCSF, Prowler reuses the existing file. Defa
|
||||
prowler aws --services accessanalyzer --push-to-cloud -o /tmp/scan-output
|
||||
```
|
||||
|
||||
### CLI output examples
|
||||
### CLI Output Examples
|
||||
|
||||
**Successful upload:**
|
||||
```
|
||||
@@ -228,7 +227,7 @@ curl -X POST \
|
||||
https://api.prowler.com/api/v1/ingestions
|
||||
```
|
||||
|
||||
### Submit an ingestion batch
|
||||
### Submit an Ingestion Batch
|
||||
|
||||
Upload a `.ocsf.json` file containing a JSON array of OCSF Detection Finding records. See [OCSF Detection Finding format](#ocsf-detection-finding-format) for the expected structure.
|
||||
|
||||
@@ -266,7 +265,7 @@ curl -X POST \
|
||||
}
|
||||
```
|
||||
|
||||
### Get ingestion status
|
||||
### Get Ingestion Status
|
||||
|
||||
Monitor the progress of an ingestion job.
|
||||
|
||||
@@ -304,7 +303,7 @@ curl -X GET \
|
||||
}
|
||||
```
|
||||
|
||||
### List ingestion jobs
|
||||
### List Ingestion Jobs
|
||||
|
||||
Retrieve a list of ingestion jobs for the tenant.
|
||||
|
||||
@@ -332,7 +331,7 @@ curl -X GET \
|
||||
"https://api.prowler.com/api/v1/ingestions?filter[status]=completed&page[size]=10"
|
||||
```
|
||||
|
||||
### Get ingestion errors
|
||||
### Get Ingestion Errors
|
||||
|
||||
Retrieve error details for a specific ingestion job.
|
||||
|
||||
@@ -346,7 +345,7 @@ curl -X GET \
|
||||
https://api.prowler.com/api/v1/ingestions/3650fef9-8e5f-4808-a95f-74f0afae8499/errors
|
||||
```
|
||||
|
||||
## Ingestion status values
|
||||
## Ingestion Status Values
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
@@ -355,7 +354,7 @@ curl -X GET \
|
||||
| `completed` | All records processed successfully |
|
||||
| `failed` | Job encountered errors during processing |
|
||||
|
||||
## CI/CD integration
|
||||
## CI/CD Integration
|
||||
|
||||
Automate findings ingestion in CI/CD pipelines by setting the API key as a secret.
|
||||
|
||||
@@ -391,7 +390,7 @@ prowler_scan:
|
||||
PROWLER_CLOUD_API_KEY: $PROWLER_CLOUD_API_KEY
|
||||
```
|
||||
|
||||
## Billing impact
|
||||
## Billing Impact
|
||||
|
||||
Each unique cloud account discovered in ingested OCSF findings counts as one **provider** in the Prowler Cloud subscription.
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ description: 'Create, edit, and monitor recurring scans in Prowler Cloud and Ent
|
||||
---
|
||||
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
|
||||
|
||||
<VersionBadge version="5.31.0" />
|
||||
|
||||
Scan Scheduling lets Prowler run recurring scans for connected providers. Use it to keep findings, compliance results, and resource inventory up to date without launching every scan manually.
|
||||
|
||||
<Note>
|
||||
This feature is available exclusively in **Prowler Cloud** and **Prowler Enterprise** with a [subscription](https://prowler.com/pricing).
|
||||
</Note>
|
||||
<SubscriptionBanner />
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# =============================================================================
|
||||
# Final stage - Minimal runtime environment
|
||||
# =============================================================================
|
||||
FROM python:3.13.14-alpine3.23@sha256:b0513989fa9be54569cac73f48a60320b74bb0f9ffa886568eea7e48a2432c04
|
||||
FROM python:3.13.14-alpine3.23@sha256:9fdbf2e3e82628351513560b121e2ee6ce31cac212be9e070c5a5e2769fb5e76
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- CIS Google Cloud Platform Foundation Benchmark v5.0.0 compliance framework for the GCP provider [(#11714)](https://github.com/prowler-cloud/prowler/pull/11714)
|
||||
- CIS Kubernetes Benchmark v2.0.1 compliance framework for the Kubernetes provider [(#11722)](https://github.com/prowler-cloud/prowler/pull/11722)
|
||||
- CIS GitHub Benchmark v1.2.0 compliance framework for the GitHub provider [(#11719)](https://github.com/prowler-cloud/prowler/pull/11719)
|
||||
- AWS Bedrock AgentCore privilege escalation paths in the IAM privilege escalation checks, covering Runtime, Harness, Code Interpreter and Custom Browser [(#11726)](https://github.com/prowler-cloud/prowler/pull/11726)
|
||||
- `--scan-secrets-validate` flag and `aws.secrets_validate` configuration option to optionally validate the secrets discovered by the secret-scanning checks against the provider APIs; secrets confirmed to be live are reported as critical [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
|
||||
- `apigateway_restapi_no_secrets_in_stage_variables` check for AWS provider, scanning API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens [(#11188)](https://github.com/prowler-cloud/prowler/pull/11188)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -32,6 +34,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- GitHub `repository_has_codeowners_file` check no longer flags archived repositories, since they are read-only and cannot be updated without first being unarchived, making the finding not actionable [(#11735)](https://github.com/prowler-cloud/prowler/pull/11735)
|
||||
- Report secret-scanning checks as `MANUAL` instead of `PASS` when the scanner fails (non-zero exit, timeout, unparseable output or missing binary), so a scanner failure is no longer indistinguishable from "no secrets found" [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
|
||||
- Avoid a false `FAIL` in `cloudwatch_log_group_no_secrets_in_logs` when a multiline event's secrets are all removed by `secrets_ignore_patterns` during the rescan [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
|
||||
- Key the `cloudwatch_log_group_no_secrets_in_logs` secret scan by log group ARN instead of name, so same-named log groups and streams in different regions no longer collide and reuse each other's findings [(#11694)](https://github.com/prowler-cloud/prowler/pull/11694)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "apigateway_restapi_no_secrets_in_stage_variables",
|
||||
"CheckTitle": "API Gateway REST API stage variables should not contain secrets",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "apigateway",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:aws:apigateway:region::/restapis/api-id/stages/stage-name",
|
||||
"Severity": "high",
|
||||
"ResourceType": "AwsApiGatewayStage",
|
||||
"ResourceGroup": "security",
|
||||
"Description": "Checks API Gateway REST API stage variables for hardcoded secrets such as passwords, API keys, and tokens. Stage variables should reference AWS Secrets Manager or Parameter Store rather than containing plaintext credentials.",
|
||||
"Risk": "Hardcoded secrets in stage variables are stored in plaintext in the AWS control plane and are visible to anyone with read access to the API Gateway configuration. This can lead to unauthorized access, credential theft, and lateral movement across systems.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_how-services-use-secrets_api-gateway.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws apigateway update-stage --rest-api-id <api-id> --stage-name <stage-name> --patch-operations op=remove,path=/variables/<variable-name>",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Open AWS Console > API Gateway\n2. Select the REST API and stage\n3. Go to Stage Variables tab\n4. Remove any variables containing plaintext secrets\n5. Reference secrets using AWS Secrets Manager integration instead",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Remove hardcoded secrets from API Gateway stage variables. Use AWS Secrets Manager or Parameter Store to manage credentials and retrieve them at runtime using Lambda authorizers or integration request mapping templates.",
|
||||
"Url": "https://hub.prowler.com/check/apigateway_restapi_no_secrets_in_stage_variables"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Infrastructure Protection"
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import json
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.lib.utils.utils import (
|
||||
SecretsScanError,
|
||||
annotate_verified_secrets,
|
||||
detect_secrets_scan_batch,
|
||||
)
|
||||
from prowler.providers.aws.services.apigateway.apigateway_client import (
|
||||
apigateway_client,
|
||||
)
|
||||
|
||||
|
||||
class apigateway_restapi_no_secrets_in_stage_variables(Check):
|
||||
"""Check that API Gateway REST API stage variables contain no hardcoded secrets."""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
findings = []
|
||||
secrets_ignore_patterns = apigateway_client.audit_config.get(
|
||||
"secrets_ignore_patterns", []
|
||||
)
|
||||
validate = apigateway_client.audit_config.get("secrets_validate", False)
|
||||
|
||||
# Collect one payload per stage (its variables) and scan them all in
|
||||
# batched Kingfisher invocations instead of one subprocess per stage.
|
||||
# Findings are keyed by (rest_api index, stage index).
|
||||
def payloads():
|
||||
for api_index, rest_api in enumerate(apigateway_client.rest_apis):
|
||||
for stage_index, stage in enumerate(rest_api.stages):
|
||||
if stage.variables:
|
||||
yield (api_index, stage_index), json.dumps(
|
||||
stage.variables, indent=2
|
||||
)
|
||||
|
||||
scan_error = None
|
||||
try:
|
||||
batch_results = detect_secrets_scan_batch(
|
||||
payloads(),
|
||||
excluded_secrets=secrets_ignore_patterns,
|
||||
validate=validate,
|
||||
)
|
||||
except SecretsScanError as error:
|
||||
batch_results = {}
|
||||
scan_error = error
|
||||
|
||||
for api_index, rest_api in enumerate(apigateway_client.rest_apis):
|
||||
for stage_index, stage in enumerate(rest_api.stages):
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=rest_api)
|
||||
report.resource_arn = stage.arn
|
||||
report.resource_id = f"{rest_api.name}/{stage.name}"
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"No secrets found in stage variables of API Gateway "
|
||||
f"REST API {rest_api.name} stage {stage.name}."
|
||||
)
|
||||
|
||||
if stage.variables:
|
||||
if scan_error:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Could not scan stage variables of API Gateway REST API "
|
||||
f"{rest_api.name} stage {stage.name} for secrets: "
|
||||
f"{scan_error}; manual review is required."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
detect_secrets_output = batch_results.get((api_index, stage_index))
|
||||
if detect_secrets_output:
|
||||
variable_names = list(stage.variables.keys())
|
||||
secrets_string = ", ".join(
|
||||
[
|
||||
f"{secret['type']} in variable "
|
||||
f"{variable_names[secret['line_number'] - 2]}"
|
||||
for secret in detect_secrets_output
|
||||
]
|
||||
)
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Potential "
|
||||
f"{'secrets' if len(detect_secrets_output) > 1 else 'secret'} "
|
||||
f"found in stage variables of API Gateway REST API "
|
||||
f"{rest_api.name} stage {stage.name} -> {secrets_string}."
|
||||
)
|
||||
annotate_verified_secrets(report, detect_secrets_output)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -179,6 +179,7 @@ class APIGateway(AWSService):
|
||||
tracing_enabled=tracing_enabled,
|
||||
cache_enabled=cache_enabled,
|
||||
cache_data_encrypted=cache_data_encrypted,
|
||||
variables=stage.get("variables", {}),
|
||||
)
|
||||
)
|
||||
except ClientError as error:
|
||||
@@ -265,6 +266,7 @@ class Stage(BaseModel):
|
||||
tracing_enabled: Optional[bool] = None
|
||||
cache_enabled: Optional[bool] = None
|
||||
cache_data_encrypted: Optional[bool] = None
|
||||
variables: Optional[dict] = {}
|
||||
|
||||
|
||||
class PathResourceMethods(BaseModel):
|
||||
|
||||
@@ -19,6 +19,7 @@ from prowler.providers.aws.services.iam.lib.policy import get_effective_actions
|
||||
# - https://github.com/RhinoSecurityLabs/Security-Research/blob/master/tools/aws-pentest-tools/aws_escalate.py
|
||||
# - https://rhinosecuritylabs.com/aws/aws-privilege-escalation-methods-mitigation/
|
||||
# - https://github.com/DataDog/pathfinding.cloud (AWS IAM Privilege Escalation Path Library)
|
||||
# - https://www.beyondtrust.com/blog/entry/aws-agentcore-privilege-escalation (AWS Bedrock AgentCore)
|
||||
|
||||
privilege_escalation_policies_combination = {
|
||||
# IAM self-escalation and policy manipulation
|
||||
@@ -299,6 +300,7 @@ privilege_escalation_policies_combination = {
|
||||
"PassRole+AgentCoreCreateInterpreter+InvokeInterpreter": {
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateCodeInterpreter",
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
"bedrock-agentcore:InvokeCodeInterpreter",
|
||||
},
|
||||
# Prerequisite: Existing Bedrock code interpreter with admin role
|
||||
@@ -306,6 +308,40 @@ privilege_escalation_policies_combination = {
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
"bedrock-agentcore:InvokeCodeInterpreter",
|
||||
},
|
||||
# Prerequisite: Existing AgentCore Runtime or Harness with admin execution role.
|
||||
# InvokeAgentRuntimeCommand runs shell commands as root inside the microVM and
|
||||
# reads the execution role credentials from MMDS, bypassing the agent and guardrails.
|
||||
"AgentCoreInvokeRuntimeCommand": {
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
},
|
||||
"PassRole+AgentCoreCreateRuntime+InvokeRuntimeCommand": {
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateAgentRuntime",
|
||||
"bedrock-agentcore:CreateAgentRuntimeEndpoint",
|
||||
"bedrock-agentcore:CreateWorkloadIdentity",
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
},
|
||||
"PassRole+AgentCoreCreateHarness+InvokeRuntimeCommand": {
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateHarness",
|
||||
"bedrock-agentcore:CreateAgentRuntime",
|
||||
"bedrock-agentcore:CreateAgentRuntimeEndpoint",
|
||||
"bedrock-agentcore:CreateWorkloadIdentity",
|
||||
"bedrock-agentcore:GetAgentRuntime",
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
},
|
||||
# Prerequisite: Existing AgentCore Custom Browser with admin execution role.
|
||||
# A remote CDP driver on the browser session reads the role credentials from MMDS.
|
||||
"AgentCoreBrowserSessionConnect": {
|
||||
"bedrock-agentcore:StartBrowserSession",
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
},
|
||||
"PassRole+AgentCoreCreateBrowser+ConnectBrowser": {
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateBrowser",
|
||||
"bedrock-agentcore:StartBrowserSession",
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
},
|
||||
# TO-DO: We have to handle AssumeRole just if the resource is * and without conditions
|
||||
# "sts:AssumeRole": {"sts:AssumeRole"},
|
||||
}
|
||||
|
||||
+3
@@ -22,6 +22,9 @@ class repository_has_codeowners_file(Check):
|
||||
"""
|
||||
findings = []
|
||||
for repo in repository_client.repositories.values():
|
||||
if repo.archived:
|
||||
continue
|
||||
|
||||
if repo.codeowners_exists is not None:
|
||||
report = CheckReportGithub(metadata=self.metadata(), resource=repo)
|
||||
if repo.codeowners_exists:
|
||||
|
||||
@@ -10,6 +10,7 @@ metadata:
|
||||
scope: [root, ui]
|
||||
auto_invoke:
|
||||
- "Creating/modifying Prowler UI components"
|
||||
- "Reviewing Prowler UI components"
|
||||
- "Working on Prowler UI structure (actions/adapters/types/hooks)"
|
||||
allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task
|
||||
---
|
||||
@@ -39,6 +40,18 @@ HeroUI 2.8.4 (LEGACY - do not add new components)
|
||||
- **ALWAYS**: Use `shadcn/ui` + Tailwind (`components/shadcn/`)
|
||||
- **NEVER**: Add new HeroUI components (`components/ui/` is legacy only)
|
||||
|
||||
## Design System Discipline (REQUIRED)
|
||||
|
||||
Applies to ALL UI work. The design system is the single source of truth — reuse it exactly, extend it deliberately.
|
||||
|
||||
- **Reuse first, never reinvent.** Before building anything, search `components/shadcn/` and existing usages in the codebase for an equivalent. Do NOT create a custom component, modal wrapper, or primitive when one already exists.
|
||||
- **Use exactly the defined variants/styles — no more, no less.** At the call site, drive appearance through the component's `variant`/`size`/`tone` props. Never add ad-hoc visual `className` (color, opacity, hover/focus/disabled, spacing-for-looks) to shared controls (`Button`, `SelectTrigger`, `SelectItem`, `Modal`, badges…), and never skip the correct semantic variant.
|
||||
- **Modals**: only `@/components/shadcn/modal`. **Selects**: `components/shadcn/select`.
|
||||
- **Colors**: reuse existing semantic tokens from `ui/styles/globals.css`. No raw Tailwind color utilities (e.g. `bg-blue-950/40`), no hex. If no token fits, STOP and ask the design owner — do not invent or near-duplicate tokens.
|
||||
- **Need a genuinely new variant/token?** That is a design-system change: add it to the shared component API (with design sign-off), then consume it. It is never a call-site decision.
|
||||
|
||||
When reviewing UI PRs, flag: custom modals/primitives that duplicate shadcn, call-site visual `className` on shared controls, raw color utilities, and new variants/tokens introduced without going through the shared component API.
|
||||
|
||||
## DECISION TREES
|
||||
|
||||
### Component Placement
|
||||
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
from unittest import mock
|
||||
|
||||
from boto3 import client
|
||||
from moto import mock_aws
|
||||
|
||||
from tests.providers.aws.utils import (
|
||||
AWS_REGION_EU_WEST_1,
|
||||
AWS_REGION_US_EAST_1,
|
||||
set_mocked_aws_provider,
|
||||
)
|
||||
|
||||
|
||||
class Test_apigateway_restapi_no_secrets_in_stage_variables:
|
||||
@mock_aws
|
||||
def test_no_rest_apis(self):
|
||||
from prowler.providers.aws.services.apigateway.apigateway_service import (
|
||||
APIGateway,
|
||||
)
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
|
||||
new=APIGateway(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
|
||||
apigateway_restapi_no_secrets_in_stage_variables,
|
||||
)
|
||||
|
||||
check = apigateway_restapi_no_secrets_in_stage_variables()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
@mock_aws
|
||||
def test_stage_with_no_variables(self):
|
||||
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
|
||||
rest_api = apigw.create_rest_api(name="test-api")
|
||||
api_id = rest_api["id"]
|
||||
|
||||
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
|
||||
resource = apigw.create_resource(
|
||||
restApiId=api_id, parentId=root_id, pathPart="test"
|
||||
)
|
||||
apigw.put_method(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
authorizationType="NONE",
|
||||
)
|
||||
apigw.put_integration(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
type="HTTP",
|
||||
integrationHttpMethod="POST",
|
||||
uri="http://test.com",
|
||||
)
|
||||
apigw.create_deployment(restApiId=api_id, stageName="prod")
|
||||
|
||||
from prowler.providers.aws.services.apigateway.apigateway_service import (
|
||||
APIGateway,
|
||||
)
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
|
||||
new=APIGateway(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
|
||||
apigateway_restapi_no_secrets_in_stage_variables,
|
||||
)
|
||||
|
||||
check = apigateway_restapi_no_secrets_in_stage_variables()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
"No secrets found in stage variables of API Gateway "
|
||||
"REST API test-api stage prod."
|
||||
)
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "test-api/prod"
|
||||
|
||||
@mock_aws
|
||||
def test_stage_with_safe_variables(self):
|
||||
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
|
||||
rest_api = apigw.create_rest_api(name="test-api")
|
||||
api_id = rest_api["id"]
|
||||
|
||||
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
|
||||
resource = apigw.create_resource(
|
||||
restApiId=api_id, parentId=root_id, pathPart="test"
|
||||
)
|
||||
apigw.put_method(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
authorizationType="NONE",
|
||||
)
|
||||
apigw.put_integration(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
type="HTTP",
|
||||
integrationHttpMethod="POST",
|
||||
uri="http://test.com",
|
||||
)
|
||||
apigw.create_deployment(restApiId=api_id, stageName="prod")
|
||||
apigw.update_stage(
|
||||
restApiId=api_id,
|
||||
stageName="prod",
|
||||
patchOperations=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/variables/environment",
|
||||
"value": "production",
|
||||
},
|
||||
{"op": "replace", "path": "/variables/region", "value": "us-east-1"},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.apigateway.apigateway_service import (
|
||||
APIGateway,
|
||||
)
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
|
||||
new=APIGateway(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
|
||||
apigateway_restapi_no_secrets_in_stage_variables,
|
||||
)
|
||||
|
||||
check = apigateway_restapi_no_secrets_in_stage_variables()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].status_extended == (
|
||||
"No secrets found in stage variables of API Gateway "
|
||||
"REST API test-api stage prod."
|
||||
)
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "test-api/prod"
|
||||
|
||||
@mock_aws
|
||||
def test_stage_with_secrets_in_variables(self):
|
||||
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
|
||||
rest_api = apigw.create_rest_api(name="test-api")
|
||||
api_id = rest_api["id"]
|
||||
|
||||
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
|
||||
resource = apigw.create_resource(
|
||||
restApiId=api_id, parentId=root_id, pathPart="test"
|
||||
)
|
||||
apigw.put_method(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
authorizationType="NONE",
|
||||
)
|
||||
apigw.put_integration(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
type="HTTP",
|
||||
integrationHttpMethod="POST",
|
||||
uri="http://test.com",
|
||||
)
|
||||
apigw.create_deployment(restApiId=api_id, stageName="prod")
|
||||
# A safe variable is added alongside the secret so the secret is not the
|
||||
# only variable present. This guards the line-number -> variable-name
|
||||
# mapping against an off-by-one that would otherwise still point at the
|
||||
# single variable and pass unnoticed.
|
||||
# A syntactically valid JSON Web Token that Kingfisher flags as a secret.
|
||||
apigw.update_stage(
|
||||
restApiId=api_id,
|
||||
stageName="prod",
|
||||
patchOperations=[
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/variables/environment",
|
||||
"value": "production",
|
||||
},
|
||||
{
|
||||
"op": "replace",
|
||||
"path": "/variables/api_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.providers.aws.services.apigateway.apigateway_service import (
|
||||
APIGateway,
|
||||
)
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
|
||||
new=APIGateway(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
|
||||
apigateway_restapi_no_secrets_in_stage_variables,
|
||||
)
|
||||
|
||||
check = apigateway_restapi_no_secrets_in_stage_variables()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "test-api" in result[0].status_extended
|
||||
assert "prod" in result[0].status_extended
|
||||
assert "in variable api_token" in result[0].status_extended
|
||||
# The secret must be attributed to the correct variable, not the
|
||||
# safe one that precedes it.
|
||||
assert "in variable environment" not in result[0].status_extended
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "test-api/prod"
|
||||
|
||||
@mock_aws
|
||||
def test_stage_with_variables_scan_error(self):
|
||||
apigw = client("apigateway", region_name=AWS_REGION_US_EAST_1)
|
||||
rest_api = apigw.create_rest_api(name="test-api")
|
||||
api_id = rest_api["id"]
|
||||
|
||||
root_id = apigw.get_resources(restApiId=api_id)["items"][0]["id"]
|
||||
resource = apigw.create_resource(
|
||||
restApiId=api_id, parentId=root_id, pathPart="test"
|
||||
)
|
||||
apigw.put_method(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
authorizationType="NONE",
|
||||
)
|
||||
apigw.put_integration(
|
||||
restApiId=api_id,
|
||||
resourceId=resource["id"],
|
||||
httpMethod="GET",
|
||||
type="HTTP",
|
||||
integrationHttpMethod="POST",
|
||||
uri="http://test.com",
|
||||
)
|
||||
apigw.create_deployment(restApiId=api_id, stageName="prod")
|
||||
apigw.update_stage(
|
||||
restApiId=api_id,
|
||||
stageName="prod",
|
||||
patchOperations=[
|
||||
{"op": "replace", "path": "/variables/api_token", "value": "value"},
|
||||
],
|
||||
)
|
||||
|
||||
from prowler.lib.utils.utils import SecretsScanError
|
||||
from prowler.providers.aws.services.apigateway.apigateway_service import (
|
||||
APIGateway,
|
||||
)
|
||||
|
||||
aws_provider = set_mocked_aws_provider(
|
||||
[AWS_REGION_EU_WEST_1, AWS_REGION_US_EAST_1]
|
||||
)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.apigateway_client",
|
||||
new=APIGateway(aws_provider),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables.detect_secrets_scan_batch",
|
||||
side_effect=SecretsScanError("Kingfisher failed"),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.apigateway.apigateway_restapi_no_secrets_in_stage_variables.apigateway_restapi_no_secrets_in_stage_variables import (
|
||||
apigateway_restapi_no_secrets_in_stage_variables,
|
||||
)
|
||||
|
||||
check = apigateway_restapi_no_secrets_in_stage_variables()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "MANUAL"
|
||||
assert "manual review is required" in result[0].status_extended
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_id == "test-api/prod"
|
||||
+5
@@ -1239,6 +1239,7 @@ class Test_iam_inline_policy_allows_privilege_escalation:
|
||||
"Action": [
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateCodeInterpreter",
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
"bedrock-agentcore:InvokeCodeInterpreter",
|
||||
],
|
||||
"Resource": "*",
|
||||
@@ -1286,6 +1287,10 @@ class Test_iam_inline_policy_allows_privilege_escalation:
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended
|
||||
)
|
||||
|
||||
+382
@@ -928,6 +928,7 @@ class Test_iam_policy_allows_privilege_escalation:
|
||||
"Action": [
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateCodeInterpreter",
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
"bedrock-agentcore:InvokeCodeInterpreter",
|
||||
],
|
||||
"Resource": "*",
|
||||
@@ -973,10 +974,391 @@ class Test_iam_policy_allows_privilege_escalation:
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateCodeInterpreter", result[0].status_extended
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:InvokeCodeInterpreter", result[0].status_extended
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_agentcore_invoke_runtime_command(
|
||||
self,
|
||||
):
|
||||
"""Test detection of AgentCore Runtime/Harness privilege escalation via InvokeAgentRuntimeCommand on an existing resource."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
|
||||
policy_name = "agentcore_invoke_runtime_command_policy"
|
||||
policy_document = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
],
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
policy_arn = iam_client.create_policy(
|
||||
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
|
||||
)["Policy"]["Arn"]
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
|
||||
iam_policy_allows_privilege_escalation,
|
||||
)
|
||||
|
||||
check = iam_policy_allows_privilege_escalation()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == policy_name
|
||||
assert result[0].resource_arn == policy_arn
|
||||
assert result[0].region == AWS_REGION_US_EAST_1
|
||||
assert result[0].resource_tags == []
|
||||
assert search(
|
||||
f"Custom Policy {policy_arn} allows privilege escalation using the following actions: ",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
result[0].status_extended,
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_runtime(
|
||||
self,
|
||||
):
|
||||
"""Test detection of AgentCore Runtime privilege escalation by creating a new runtime with a passed role."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
|
||||
policy_name = "agentcore_create_runtime_policy"
|
||||
policy_document = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateAgentRuntime",
|
||||
"bedrock-agentcore:CreateAgentRuntimeEndpoint",
|
||||
"bedrock-agentcore:CreateWorkloadIdentity",
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
],
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
policy_arn = iam_client.create_policy(
|
||||
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
|
||||
)["Policy"]["Arn"]
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
|
||||
iam_policy_allows_privilege_escalation,
|
||||
)
|
||||
|
||||
check = iam_policy_allows_privilege_escalation()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == policy_name
|
||||
assert result[0].resource_arn == policy_arn
|
||||
assert search("iam:PassRole", result[0].status_extended)
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateAgentRuntime",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateAgentRuntimeEndpoint",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateWorkloadIdentity",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
result[0].status_extended,
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_harness(
|
||||
self,
|
||||
):
|
||||
"""Test detection of AgentCore Harness privilege escalation by creating a new harness with a passed role."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
|
||||
policy_name = "agentcore_create_harness_policy"
|
||||
policy_document = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateHarness",
|
||||
"bedrock-agentcore:CreateAgentRuntime",
|
||||
"bedrock-agentcore:CreateAgentRuntimeEndpoint",
|
||||
"bedrock-agentcore:CreateWorkloadIdentity",
|
||||
"bedrock-agentcore:GetAgentRuntime",
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
],
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
policy_arn = iam_client.create_policy(
|
||||
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
|
||||
)["Policy"]["Arn"]
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
|
||||
iam_policy_allows_privilege_escalation,
|
||||
)
|
||||
|
||||
check = iam_policy_allows_privilege_escalation()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == policy_name
|
||||
assert result[0].resource_arn == policy_arn
|
||||
assert search("iam:PassRole", result[0].status_extended)
|
||||
assert search("bedrock-agentcore:CreateHarness", result[0].status_extended)
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateAgentRuntime",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateAgentRuntimeEndpoint",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:CreateWorkloadIdentity",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:GetAgentRuntime", result[0].status_extended
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
result[0].status_extended,
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_agentcore_browser_session_connect(
|
||||
self,
|
||||
):
|
||||
"""Test detection of AgentCore Custom Browser privilege escalation via an existing browser session."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
|
||||
policy_name = "agentcore_browser_session_connect_policy"
|
||||
policy_document = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock-agentcore:StartBrowserSession",
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
],
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
policy_arn = iam_client.create_policy(
|
||||
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
|
||||
)["Policy"]["Arn"]
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
|
||||
iam_policy_allows_privilege_escalation,
|
||||
)
|
||||
|
||||
check = iam_policy_allows_privilege_escalation()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == policy_name
|
||||
assert result[0].resource_arn == policy_arn
|
||||
assert search(
|
||||
"bedrock-agentcore:StartBrowserSession", result[0].status_extended
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
result[0].status_extended,
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_agentcore_passrole_create_browser(
|
||||
self,
|
||||
):
|
||||
"""Test detection of AgentCore Custom Browser privilege escalation by creating a new browser with a passed role."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
|
||||
policy_name = "agentcore_create_browser_policy"
|
||||
policy_document = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"iam:PassRole",
|
||||
"bedrock-agentcore:CreateBrowser",
|
||||
"bedrock-agentcore:StartBrowserSession",
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
],
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
policy_arn = iam_client.create_policy(
|
||||
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
|
||||
)["Policy"]["Arn"]
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
|
||||
iam_policy_allows_privilege_escalation,
|
||||
)
|
||||
|
||||
check = iam_policy_allows_privilege_escalation()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == policy_name
|
||||
assert result[0].resource_arn == policy_arn
|
||||
assert search("iam:PassRole", result[0].status_extended)
|
||||
assert search("bedrock-agentcore:CreateBrowser", result[0].status_extended)
|
||||
assert search(
|
||||
"bedrock-agentcore:StartBrowserSession", result[0].status_extended
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:ConnectBrowserAutomationStream",
|
||||
result[0].status_extended,
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_agentcore_wildcard(
|
||||
self,
|
||||
):
|
||||
"""Test detection of AgentCore privilege escalation when the policy grants the bedrock-agentcore:* namespace wildcard."""
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
iam_client = client("iam", region_name=AWS_REGION_US_EAST_1)
|
||||
policy_name = "agentcore_wildcard_policy"
|
||||
policy_document = {
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"bedrock-agentcore:*",
|
||||
],
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
policy_arn = iam_client.create_policy(
|
||||
PolicyName=policy_name, PolicyDocument=dumps(policy_document)
|
||||
)["Policy"]["Arn"]
|
||||
|
||||
from prowler.providers.aws.services.iam.iam_service import IAM
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation.iam_client",
|
||||
new=IAM(aws_provider),
|
||||
),
|
||||
):
|
||||
from prowler.providers.aws.services.iam.iam_policy_allows_privilege_escalation.iam_policy_allows_privilege_escalation import (
|
||||
iam_policy_allows_privilege_escalation,
|
||||
)
|
||||
|
||||
check = iam_policy_allows_privilege_escalation()
|
||||
result = check.execute()
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == policy_name
|
||||
assert result[0].resource_arn == policy_arn
|
||||
assert search(
|
||||
"bedrock-agentcore:InvokeAgentRuntimeCommand",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:StartCodeInterpreterSession",
|
||||
result[0].status_extended,
|
||||
)
|
||||
assert search(
|
||||
"bedrock-agentcore:StartBrowserSession", result[0].status_extended
|
||||
)
|
||||
|
||||
@mock_aws
|
||||
def test_iam_policy_allows_privilege_escalation_iam_put(
|
||||
self,
|
||||
|
||||
+52
@@ -145,3 +145,55 @@ class Test_repository_has_codeowners_file:
|
||||
result[0].status_extended
|
||||
== f"Repository {repo_name} does have a CODEOWNERS file."
|
||||
)
|
||||
|
||||
def test_archived_repository_no_codeowners_is_skipped(self):
|
||||
repository_client = mock.MagicMock
|
||||
repo_name = "archived-repo"
|
||||
repository_client.repositories = {
|
||||
3: Repo(
|
||||
id=3,
|
||||
name=repo_name,
|
||||
owner="account-name",
|
||||
full_name="account-name/archived-repo",
|
||||
default_branch=Branch(
|
||||
name="main",
|
||||
protected=False,
|
||||
default_branch=True,
|
||||
require_pull_request=False,
|
||||
approval_count=0,
|
||||
required_linear_history=False,
|
||||
allow_force_pushes=True,
|
||||
branch_deletion=True,
|
||||
status_checks=False,
|
||||
enforce_admins=False,
|
||||
require_code_owner_reviews=False,
|
||||
require_signed_commits=False,
|
||||
conversation_resolution=False,
|
||||
),
|
||||
private=False,
|
||||
securitymd=True,
|
||||
codeowners_exists=False,
|
||||
secret_scanning_enabled=False,
|
||||
archived=True,
|
||||
pushed_at=datetime.now(timezone.utc),
|
||||
delete_branch_on_merge=False,
|
||||
),
|
||||
}
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_github_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file.repository_client",
|
||||
new=repository_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.github.services.repository.repository_has_codeowners_file.repository_has_codeowners_file import (
|
||||
repository_has_codeowners_file,
|
||||
)
|
||||
|
||||
check = repository_has_codeowners_file()
|
||||
result = check.execute()
|
||||
assert len(result) == 0
|
||||
|
||||
@@ -40,6 +40,7 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
| Renaming or removing a data-tour-id attribute value | `prowler-tour` |
|
||||
| Restructuring routes or layouts covered by a tour | `prowler-tour` |
|
||||
| Review changelog format and conventions | `prowler-changelog` |
|
||||
| Reviewing Prowler UI components | `prowler-ui` |
|
||||
| Testing hooks or utilities | `vitest` |
|
||||
| Update CHANGELOG.md in any component | `prowler-changelog` |
|
||||
| Using Zustand stores | `zustand-5` |
|
||||
|
||||
@@ -6,9 +6,6 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Add `Scan Configuration` menu item under the Configuration menu (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
|
||||
- Scan configuration management page (`/scan-configurations`) to create, edit, and manage scan configurations with live YAML validation against the server JSON Schema (only available in Prowler Cloud) [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
|
||||
- Surface an "invalid scan configuration" note on compliance requirements that fail solely because the applied scan config does not meet them [(#11695)](https://github.com/prowler-cloud/prowler/pull/11695)
|
||||
- Filter the Overview, Findings, Resources, Scans, and Providers views by provider group [(#11659)](https://github.com/prowler-cloud/prowler/pull/11659)
|
||||
- CIS Controls v8.1 compliance support, including its detail view and report mapping [(#11700)](https://github.com/prowler-cloud/prowler/pull/11700)
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
adaptFindingGroupResourcesResponse,
|
||||
adaptFindingGroupsResponse,
|
||||
} from "./finding-groups.adapter";
|
||||
|
||||
const expectNoRawTriageTransportKeys = (value: Record<string, unknown>) => {
|
||||
expect(value).not.toHaveProperty("triage_status");
|
||||
expect(value).not.toHaveProperty("triage_has_note");
|
||||
expect(value).not.toHaveProperty("attributes");
|
||||
expect(value).not.toHaveProperty("relationships");
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingGroupsResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -21,17 +37,6 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given
|
||||
const input = { meta: { total: 0 } };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given
|
||||
const input = { data: "not-an-array" };
|
||||
@@ -43,28 +48,6 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is null", () => {
|
||||
// Given
|
||||
const input = { data: null };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given
|
||||
const input = undefined;
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped rows for valid data", () => {
|
||||
// Given
|
||||
const input = {
|
||||
@@ -106,6 +89,10 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
failing_since: null,
|
||||
finding_id: "group-finding-id-1",
|
||||
finding_uid: "group-finding-uid-1",
|
||||
triage_status: "risk_accepted",
|
||||
triage_has_note: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -121,6 +108,10 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
expect(result[0].muted).toBe(true);
|
||||
expect(result[0].manualCount).toBe(1);
|
||||
expect(result[0].newFailMutedCount).toBe(1);
|
||||
expect(result[0]).not.toHaveProperty("triage");
|
||||
expectNoRawTriageTransportKeys(
|
||||
result[0] as unknown as Record<string, unknown>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +128,6 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse({ meta: {} }, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse({ data: {} }, "check-1");
|
||||
@@ -153,12 +136,206 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse(undefined, "check-1");
|
||||
it("should keep resource rows with valid attributes even when JSON:API type differs", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "findings",
|
||||
attributes: {
|
||||
finding_id: "real-finding-uuid",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
triage_status: "remediating",
|
||||
triage_notes_count: 1,
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
type: "Bucket",
|
||||
resource_group: "default",
|
||||
},
|
||||
provider: {
|
||||
type: "aws",
|
||||
uid: "123456789",
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupResourcesResponse(input, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "real-finding-uuid",
|
||||
resourceName: "my-bucket",
|
||||
triage: expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
hasVisibleNote: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should skip malformed resource entries inside a data array", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
null,
|
||||
"bad-entry",
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "real-finding-uuid",
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
type: "Bucket",
|
||||
resource_group: "default",
|
||||
},
|
||||
provider: {
|
||||
type: "aws",
|
||||
uid: "123456789",
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
severity: "high",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupResourcesResponse(input, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "real-finding-uuid",
|
||||
resourceName: "my-bucket",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should attach adapter-produced triage DTOs to finding-level resource rows", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "real-finding-uuid",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
triage_status: "under_review",
|
||||
triage_has_note: true,
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
type: "Bucket",
|
||||
resource_group: "default",
|
||||
},
|
||||
provider: {
|
||||
type: "aws",
|
||||
uid: "123456789",
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
muted: false,
|
||||
delta: "new",
|
||||
severity: "critical",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const [row] = adaptFindingGroupResourcesResponse(input, "s3_check");
|
||||
|
||||
// Then
|
||||
expect(row.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "real-finding-uuid",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
canEdit: true,
|
||||
}),
|
||||
);
|
||||
expectNoRawTriageTransportKeys(
|
||||
row.triage as unknown as Record<string, unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it("should leave triage editing disabled until a real capability is provided", () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "real-finding-uuid",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
triage_status: "open",
|
||||
triage_has_note: false,
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
type: "Bucket",
|
||||
resource_group: "default",
|
||||
},
|
||||
provider: {
|
||||
type: "aws",
|
||||
uid: "123456789",
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
muted: false,
|
||||
delta: "new",
|
||||
severity: "critical",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const [row] = adaptFindingGroupResourcesResponse(input, "s3_check");
|
||||
|
||||
// Then
|
||||
expect(row.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return mapped rows for valid data", () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter";
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import type {
|
||||
FindingGroupRow,
|
||||
FindingResourceRow,
|
||||
@@ -72,6 +74,7 @@ export function adaptFindingGroupsResponse(
|
||||
}
|
||||
|
||||
const data = (apiResponse as { data: FindingGroupApiItem[] }).data;
|
||||
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.GROUP,
|
||||
@@ -146,6 +149,9 @@ interface FindingGroupResourceAttributes {
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
muted_reason?: string | null;
|
||||
finding_uid?: string;
|
||||
triage_status?: string;
|
||||
triage_has_note?: boolean;
|
||||
}
|
||||
|
||||
interface FindingGroupResourceApiItem {
|
||||
@@ -154,6 +160,26 @@ interface FindingGroupResourceApiItem {
|
||||
attributes: FindingGroupResourceAttributes;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object";
|
||||
|
||||
const isFindingGroupResourceApiItem = (
|
||||
value: unknown,
|
||||
): value is FindingGroupResourceApiItem => {
|
||||
if (!isRecord(value) || typeof value.id !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const attributes = value.attributes;
|
||||
|
||||
return (
|
||||
isRecord(attributes) &&
|
||||
typeof attributes.finding_id === "string" &&
|
||||
isRecord(attributes.resource) &&
|
||||
isRecord(attributes.provider)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms the API response for finding group resources (drill-down)
|
||||
* into FindingResourceRow[].
|
||||
@@ -171,8 +197,15 @@ export function adaptFindingGroupResourcesResponse(
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (apiResponse as { data: FindingGroupResourceApiItem[] }).data;
|
||||
return data.map((item) => ({
|
||||
const data = (apiResponse as { data: unknown[] }).data.filter(
|
||||
isFindingGroupResourceApiItem,
|
||||
);
|
||||
const triageSummaries = adaptFindingTriageSummariesResponse(
|
||||
{ ...apiResponse, data },
|
||||
getFindingTriageAdapterOptions(),
|
||||
);
|
||||
|
||||
return data.map((item, index) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.RESOURCE,
|
||||
findingId: item.attributes.finding_id || item.id,
|
||||
@@ -194,5 +227,6 @@ export function adaptFindingGroupResourcesResponse(
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
firstSeenAt: item.attributes.first_seen_at,
|
||||
lastSeenAt: item.attributes.last_seen_at,
|
||||
triage: triageSummaries[index],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ vi.mock("next/navigation", () => ({
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
import { adaptFindingsByResourceResponse } from "./findings-by-resource.adapter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -43,22 +45,6 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse(undefined);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ meta: {} });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: "bad" });
|
||||
@@ -75,14 +61,6 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is a number", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: 42 });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped findings for valid minimal data", () => {
|
||||
// Given — minimal valid JSON:API shape
|
||||
const input = {
|
||||
@@ -194,8 +172,8 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
expect(result[0].resourceMetadata).toBeNull();
|
||||
});
|
||||
|
||||
it("should normalize a single finding response into a one-item drawer array", () => {
|
||||
// Given — getFindingById returns a single JSON:API resource object
|
||||
it("should preserve triage summary fields for a single finding response", () => {
|
||||
// Given - getFindingById returns a single finding with provisional triage fields
|
||||
const input = {
|
||||
data: {
|
||||
id: "finding-1",
|
||||
@@ -204,6 +182,10 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
triage_id: "triage-1",
|
||||
triage_status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
triage_notes_count: 1,
|
||||
triage_has_note: true,
|
||||
check_metadata: {
|
||||
checktitle: "S3 Check",
|
||||
},
|
||||
@@ -220,8 +202,16 @@ describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
const result = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("finding-1");
|
||||
expect(result[0].checkTitle).toBe("S3 Check");
|
||||
expect(result[0].triage).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { adaptFindingTriageSummariesResponse } from "@/actions/findings/findings-triage.adapter";
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { createDict } from "@/lib";
|
||||
import type { ProviderType, Severity } from "@/types";
|
||||
import type { FindingTriageSummary } from "@/types/findings-triage";
|
||||
|
||||
export interface RemediationRecommendation {
|
||||
text: string;
|
||||
@@ -49,6 +52,7 @@ export interface ResourceDrawerFinding {
|
||||
mutedReason: string | null;
|
||||
firstSeenAt: string | null;
|
||||
updatedAt: string | null;
|
||||
triage?: FindingTriageSummary;
|
||||
// Resource
|
||||
resourceId: string;
|
||||
resourceUid: string;
|
||||
@@ -195,8 +199,12 @@ export function adaptFindingsByResourceResponse(
|
||||
const findings = Array.isArray(apiResponse.data)
|
||||
? apiResponse.data
|
||||
: [apiResponse.data];
|
||||
const triageSummaries = adaptFindingTriageSummariesResponse(
|
||||
{ ...apiResponse, data: findings },
|
||||
getFindingTriageAdapterOptions(),
|
||||
);
|
||||
|
||||
return findings.map((item) => {
|
||||
return findings.map((item, index) => {
|
||||
const attrs = item.attributes;
|
||||
const meta = (attrs.check_metadata || {}) as Record<string, unknown>;
|
||||
const remediationRaw = meta.remediation as
|
||||
@@ -254,6 +262,7 @@ export function adaptFindingsByResourceResponse(
|
||||
mutedReason: attrs.muted_reason || null,
|
||||
firstSeenAt: attrs.first_seen_at || null,
|
||||
updatedAt: attrs.updated_at || null,
|
||||
triage: triageSummaries[index],
|
||||
// Resource
|
||||
resourceId: resourceRel?.id || "",
|
||||
resourceUid: (resourceAttrs.uid as string | undefined) || "-",
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
adaptFindingTriageDetailResponse,
|
||||
adaptFindingTriageSummariesResponse,
|
||||
adaptLatestFindingTriageNote,
|
||||
attachFindingTriageSummariesToResponse,
|
||||
} from "./findings-triage.adapter";
|
||||
import {
|
||||
allProvisionalTriageStatusFindings,
|
||||
findingTriageDetailResponse,
|
||||
flatFindingWithAcceptedRiskTriage,
|
||||
flatFindingWithNotePresenceOnly,
|
||||
flatFindingWithUnderReviewTriage,
|
||||
flatPassFindingWithoutPersistedTriage,
|
||||
} from "./findings-triage.fixtures";
|
||||
|
||||
const expectNoRawTransportKeys = (value: Record<string, unknown>) => {
|
||||
expect(value).not.toHaveProperty("attributes");
|
||||
expect(value).not.toHaveProperty("relationships");
|
||||
expect(value).not.toHaveProperty("included");
|
||||
expect(value).not.toHaveProperty("triage_status");
|
||||
expect(value).not.toHaveProperty("triage_has_note");
|
||||
expect(value).not.toHaveProperty("triage_note");
|
||||
expect(value).not.toHaveProperty("current_note");
|
||||
expect(value).not.toHaveProperty("source");
|
||||
expect(value).not.toHaveProperty("updated_by");
|
||||
expect(value).not.toHaveProperty("inserted_at");
|
||||
expect(value).not.toHaveProperty("updated_at");
|
||||
};
|
||||
|
||||
describe("provisional findings triage contract fixtures", () => {
|
||||
it("should document every triage status the provisional contract can return", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: allProvisionalTriageStatusFindings,
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingTriageSummariesResponse(input, {
|
||||
canEdit: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(7);
|
||||
expect(result.map((summary) => summary.status)).toEqual([
|
||||
FINDING_TRIAGE_STATUS.OPEN,
|
||||
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
FINDING_TRIAGE_STATUS.REOPENED,
|
||||
]);
|
||||
expect(result.map((summary) => summary.label)).toEqual([
|
||||
"Open",
|
||||
"Under Review",
|
||||
"Remediating",
|
||||
"Resolved",
|
||||
"Risk Accepted",
|
||||
"False Positive",
|
||||
"Reopened",
|
||||
]);
|
||||
expect(result[0].label).toBe(
|
||||
FINDING_TRIAGE_STATUS_LABELS[FINDING_TRIAGE_STATUS.OPEN],
|
||||
);
|
||||
});
|
||||
|
||||
it("should model table note presence without requiring note previews", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [flatFindingWithNotePresenceOnly],
|
||||
};
|
||||
|
||||
// When
|
||||
const [summary] = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-note-presence-1",
|
||||
findingUid: "prowler-finding-note-presence-uid-1",
|
||||
hasVisibleNote: true,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(summary)).not.toContain("triage_note");
|
||||
expect(JSON.stringify(summary)).not.toContain("current_note");
|
||||
});
|
||||
|
||||
it("should model modal note detail as a separate detail payload", () => {
|
||||
// Given / When
|
||||
const detail = adaptFindingTriageDetailResponse(
|
||||
findingTriageDetailResponse,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(detail.noteBody).toBe("Current note visible only inside the modal.");
|
||||
expect(detail.hasVisibleNote).toBe(true);
|
||||
expect(detail.maxNoteLength).toBe(500);
|
||||
});
|
||||
|
||||
it("should model disabled non-paying state through adapter options only", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [flatFindingWithUnderReviewTriage],
|
||||
};
|
||||
|
||||
// When
|
||||
const [summary] = adaptFindingTriageSummariesResponse(input, {
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(summary.canEdit).toBe(false);
|
||||
expect(summary.disabledReason).toBe(
|
||||
FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
);
|
||||
expect(summary.billingHref).toBe("https://prowler.com/pricing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptLatestFindingTriageNote", () => {
|
||||
it("should adapt the newest note from a JSON:API collection", () => {
|
||||
// Given
|
||||
const response = {
|
||||
data: [
|
||||
{
|
||||
id: "note-latest",
|
||||
type: "finding-triage-notes",
|
||||
attributes: {
|
||||
body: "Latest investigation note",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptLatestFindingTriageNote(response);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
noteId: "note-latest",
|
||||
noteBody: "Latest investigation note",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when the response has no usable note", () => {
|
||||
expect(adaptLatestFindingTriageNote({ data: [] })).toBeNull();
|
||||
expect(
|
||||
adaptLatestFindingTriageNote({ data: [{ id: "note-1" }] }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptFindingTriageSummariesResponse", () => {
|
||||
it("should return [] when the provisional API response is malformed", () => {
|
||||
// Given
|
||||
const input = { meta: { count: 0 } };
|
||||
|
||||
// When
|
||||
const result = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip malformed entries inside a data array", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [null, "bad-entry", flatFindingWithUnderReviewTriage],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not shift attached summaries after malformed entries", () => {
|
||||
// Given
|
||||
const validWithoutTriage = {
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "finding-1",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
triage_status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
triage_notes_count: 1,
|
||||
status: "FAIL",
|
||||
},
|
||||
};
|
||||
const laterValidWithoutTriage = {
|
||||
id: "resource-row-2",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "finding-2",
|
||||
finding_uid: "prowler-finding-uid-2",
|
||||
triage_status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
triage_notes_count: 0,
|
||||
status: "MUTED",
|
||||
},
|
||||
};
|
||||
const input = {
|
||||
data: [validWithoutTriage, null, laterValidWithoutTriage],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = attachFindingTriageSummariesToResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result?.data[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
triage: expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result?.data[1]).toBeNull();
|
||||
expect(result?.data[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
triage: expect.objectContaining({
|
||||
findingId: "finding-2",
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use triage_notes_count from the resource triage API contract", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "finding-1",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
triage_status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
triage_notes_count: 5,
|
||||
status: "FAIL",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "resource-row-2",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "finding-2",
|
||||
finding_uid: "prowler-finding-uid-2",
|
||||
triage_status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
triage_notes_count: 0,
|
||||
status: "FAIL",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
hasVisibleNote: true,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
hasVisibleNote: false,
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should mark triage as muted when resource status is MUTED", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-muted-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "finding-muted-1",
|
||||
finding_uid: "prowler-finding-muted-uid-1",
|
||||
triage_status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
triage_notes_count: 0,
|
||||
status: "MUTED",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const [summary] = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(summary).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-muted-1",
|
||||
isMuted: true,
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should normalize flat provisional finding fields into domain triage summaries", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [flatFindingWithUnderReviewTriage],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingTriageSummariesResponse(input, {
|
||||
canEdit: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should fallback from scan status when no persisted triage status exists", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [flatPassFindingWithoutPersistedTriage],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.RESOLVED,
|
||||
label: "Resolved",
|
||||
hasVisibleNote: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep raw provisional fields out of table component DTOs", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [flatFindingWithAcceptedRiskTriage],
|
||||
};
|
||||
|
||||
// When
|
||||
const [summary] = adaptFindingTriageSummariesResponse(input);
|
||||
|
||||
// Then
|
||||
expectNoRawTransportKeys(summary as unknown as Record<string, unknown>);
|
||||
expect(JSON.stringify(summary)).not.toContain("Accepted risk note body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptFindingTriageDetailResponse", () => {
|
||||
it("should normalize provisional detail payloads into modal DTOs", () => {
|
||||
// Given
|
||||
const input = findingTriageDetailResponse;
|
||||
|
||||
// When
|
||||
const detail = adaptFindingTriageDetailResponse(input, {
|
||||
canEdit: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(detail).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
label: "Risk Accepted",
|
||||
hasVisibleNote: true,
|
||||
canEdit: true,
|
||||
noteBody: "Current note visible only inside the modal.",
|
||||
maxNoteLength: 500,
|
||||
privacyCopy: "This note is only visible to your team.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should keep raw provisional fields out of modal component DTOs", () => {
|
||||
// Given
|
||||
const input = findingTriageDetailResponse;
|
||||
|
||||
// When
|
||||
const detail = adaptFindingTriageDetailResponse(input);
|
||||
|
||||
// Then
|
||||
expectNoRawTransportKeys(detail as unknown as Record<string, unknown>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import {
|
||||
FINDING_TRIAGE_BILLING_HREF,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageDisabledReason,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
// API/backend triage implementation is external to this UI slice. Keep final
|
||||
// contract churn isolated here (and the server action transport) so table/modal
|
||||
// components continue consuming stable domain DTOs.
|
||||
interface FindingTriageAdapterOptions {
|
||||
canEdit?: boolean;
|
||||
disabledReason?: FindingTriageDisabledReason;
|
||||
billingHref?: string;
|
||||
}
|
||||
|
||||
interface FindingTriageAttributes {
|
||||
finding_id?: string;
|
||||
finding_uid?: string;
|
||||
uid?: string;
|
||||
triage_id?: string;
|
||||
triage_notes_count?: number;
|
||||
triage_status?: unknown;
|
||||
triage_has_note?: boolean;
|
||||
status?: unknown;
|
||||
muted?: boolean;
|
||||
body?: unknown;
|
||||
current_note?: string;
|
||||
note?: string;
|
||||
has_note?: boolean;
|
||||
note_id?: string;
|
||||
}
|
||||
|
||||
interface JsonApiResource {
|
||||
id?: string;
|
||||
attributes?: FindingTriageAttributes;
|
||||
}
|
||||
|
||||
interface NormalizedTriageFields {
|
||||
status: FindingTriageStatus;
|
||||
hasVisibleNote: boolean;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object";
|
||||
|
||||
const isJsonApiResource = (value: unknown): value is JsonApiResource =>
|
||||
isRecord(value) &&
|
||||
(!("attributes" in value) ||
|
||||
value.attributes === undefined ||
|
||||
isRecord(value.attributes));
|
||||
|
||||
const isFindingTriageStatus = (value: unknown): value is FindingTriageStatus =>
|
||||
typeof value === "string" &&
|
||||
Object.values(FINDING_TRIAGE_STATUS).includes(value as FindingTriageStatus);
|
||||
|
||||
const fallbackStatusFromFindingStatus = (
|
||||
findingStatus: unknown,
|
||||
): FindingTriageStatus =>
|
||||
findingStatus === "PASS"
|
||||
? FINDING_TRIAGE_STATUS.RESOLVED
|
||||
: FINDING_TRIAGE_STATUS.OPEN;
|
||||
|
||||
const normalizeTriageFields = (
|
||||
finding: JsonApiResource,
|
||||
): NormalizedTriageFields => {
|
||||
const attributes = finding.attributes ?? {};
|
||||
|
||||
if (isFindingTriageStatus(attributes.triage_status)) {
|
||||
return {
|
||||
status: attributes.triage_status,
|
||||
hasVisibleNote:
|
||||
(typeof attributes.triage_notes_count === "number" &&
|
||||
attributes.triage_notes_count >= 1) ||
|
||||
attributes.triage_has_note === true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: fallbackStatusFromFindingStatus(attributes.status),
|
||||
hasVisibleNote: false,
|
||||
};
|
||||
};
|
||||
|
||||
const createSummary = (
|
||||
finding: JsonApiResource,
|
||||
triageFields: NormalizedTriageFields,
|
||||
options: FindingTriageAdapterOptions,
|
||||
): FindingTriageSummary => {
|
||||
const attributes = finding.attributes ?? {};
|
||||
const summary: FindingTriageSummary = {
|
||||
findingId: attributes.finding_id || finding.id || "",
|
||||
findingUid: attributes.uid || attributes.finding_uid || "",
|
||||
triageId: attributes.triage_id || null,
|
||||
notesCount: attributes.triage_notes_count ?? 0,
|
||||
status: triageFields.status,
|
||||
label: FINDING_TRIAGE_STATUS_LABELS[triageFields.status],
|
||||
hasVisibleNote: triageFields.hasVisibleNote,
|
||||
isMuted:
|
||||
typeof attributes.muted === "boolean"
|
||||
? attributes.muted
|
||||
: attributes.status === "MUTED",
|
||||
canEdit: options.canEdit ?? false,
|
||||
billingHref: options.billingHref ?? FINDING_TRIAGE_BILLING_HREF,
|
||||
};
|
||||
|
||||
if (options.disabledReason) {
|
||||
summary.disabledReason = options.disabledReason;
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
export function adaptFindingTriageSummariesResponse(
|
||||
apiResponse: unknown,
|
||||
options: FindingTriageAdapterOptions = {},
|
||||
): FindingTriageSummary[] {
|
||||
if (!isRecord(apiResponse) || !Array.isArray(apiResponse.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data
|
||||
.filter(isJsonApiResource)
|
||||
.map((finding) =>
|
||||
createSummary(finding, normalizeTriageFields(finding), options),
|
||||
);
|
||||
}
|
||||
|
||||
export function adaptLatestFindingTriageNote(
|
||||
apiResponse: unknown,
|
||||
): FindingTriageLoadedNote | null {
|
||||
const latestNote =
|
||||
isRecord(apiResponse) && Array.isArray(apiResponse.data)
|
||||
? apiResponse.data.find(isJsonApiResource)
|
||||
: undefined;
|
||||
const noteId = latestNote?.id;
|
||||
const noteBody = latestNote?.attributes?.body;
|
||||
|
||||
if (typeof noteId !== "string" || !noteId || typeof noteBody !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
noteId,
|
||||
noteBody,
|
||||
};
|
||||
}
|
||||
|
||||
export function attachFindingTriageSummariesToResponse<
|
||||
T extends { data?: unknown },
|
||||
>(
|
||||
apiResponse: T | undefined,
|
||||
options: FindingTriageAdapterOptions = {},
|
||||
): T | undefined {
|
||||
if (!apiResponse || !Array.isArray(apiResponse.data)) {
|
||||
return apiResponse;
|
||||
}
|
||||
|
||||
return {
|
||||
...apiResponse,
|
||||
data: apiResponse.data.map((item) =>
|
||||
isJsonApiResource(item)
|
||||
? {
|
||||
...item,
|
||||
triage: createSummary(item, normalizeTriageFields(item), options),
|
||||
}
|
||||
: item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function adaptFindingTriageDetailResponse(
|
||||
apiResponse: unknown,
|
||||
options: FindingTriageAdapterOptions = {},
|
||||
): FindingTriageDetail {
|
||||
const data =
|
||||
isRecord(apiResponse) && isJsonApiResource(apiResponse.data)
|
||||
? apiResponse.data
|
||||
: undefined;
|
||||
const attributes = data?.attributes ?? {};
|
||||
const status = isFindingTriageStatus(attributes.status)
|
||||
? attributes.status
|
||||
: FINDING_TRIAGE_STATUS.OPEN;
|
||||
const noteBody =
|
||||
typeof attributes.current_note === "string"
|
||||
? attributes.current_note
|
||||
: typeof attributes.note === "string"
|
||||
? attributes.note
|
||||
: "";
|
||||
const summary = createSummary(
|
||||
{
|
||||
id: attributes.finding_id || data?.id || "",
|
||||
attributes: {
|
||||
finding_uid: attributes.finding_uid || "",
|
||||
triage_id: data?.id,
|
||||
triage_notes_count:
|
||||
attributes.triage_notes_count ?? (noteBody.length > 0 ? 1 : 0),
|
||||
},
|
||||
},
|
||||
{
|
||||
status,
|
||||
hasVisibleNote: attributes.has_note === true || noteBody.length > 0,
|
||||
},
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...summary,
|
||||
noteId: attributes.note_id || null,
|
||||
noteBody,
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Provisional UI/API contract fixtures only. API implementation is external to this
|
||||
// UI slice; when the final API lands, update the adapter/server-action seam instead
|
||||
// of teaching table or modal components about the transport payload shape.
|
||||
const createFlatFindingWithTriageStatus = (status: string, index: number) => ({
|
||||
type: "findings",
|
||||
id: `finding-contract-${index}`,
|
||||
attributes: {
|
||||
uid: `prowler-finding-contract-uid-${index}`,
|
||||
status: "FAIL",
|
||||
triage_status: status,
|
||||
triage_has_note: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const allProvisionalTriageStatusFindings = [
|
||||
createFlatFindingWithTriageStatus("open", 1),
|
||||
createFlatFindingWithTriageStatus("under_review", 2),
|
||||
createFlatFindingWithTriageStatus("remediating", 3),
|
||||
createFlatFindingWithTriageStatus("resolved", 4),
|
||||
createFlatFindingWithTriageStatus("risk_accepted", 5),
|
||||
createFlatFindingWithTriageStatus("false_positive", 6),
|
||||
createFlatFindingWithTriageStatus("reopened", 7),
|
||||
] as const;
|
||||
|
||||
export const flatFindingWithNotePresenceOnly = {
|
||||
type: "findings",
|
||||
id: "finding-note-presence-1",
|
||||
attributes: {
|
||||
uid: "prowler-finding-note-presence-uid-1",
|
||||
status: "FAIL",
|
||||
triage_id: "triage-note-presence-1",
|
||||
triage_status: "under_review",
|
||||
triage_notes_count: 1,
|
||||
triage_has_note: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const flatFindingWithUnderReviewTriage = {
|
||||
type: "findings",
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "prowler-finding-uid-1",
|
||||
status: "FAIL",
|
||||
triage_id: "triage-note-presence-1",
|
||||
triage_status: "under_review",
|
||||
triage_notes_count: 1,
|
||||
triage_has_note: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const flatPassFindingWithoutPersistedTriage = {
|
||||
type: "findings",
|
||||
id: "finding-pass-1",
|
||||
attributes: {
|
||||
uid: "prowler-finding-pass-uid-1",
|
||||
status: "PASS",
|
||||
triage_id: null,
|
||||
triage_status: null,
|
||||
triage_notes_count: 0,
|
||||
triage_has_note: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const flatFindingWithAcceptedRiskTriage = {
|
||||
type: "findings",
|
||||
id: "finding-accepted-risk-1",
|
||||
attributes: {
|
||||
uid: "prowler-finding-accepted-risk-uid-1",
|
||||
status: "FAIL",
|
||||
triage_id: "triage-accepted-risk-1",
|
||||
triage_status: "risk_accepted",
|
||||
triage_notes_count: 1,
|
||||
triage_has_note: true,
|
||||
triage_note:
|
||||
"Accepted risk note body that must never appear in a table DTO.",
|
||||
source: "manual",
|
||||
updated_by: "user-1",
|
||||
inserted_at: "2026-06-01T10:00:00Z",
|
||||
updated_at: "2026-06-01T10:05:00Z",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const findingTriageDetailResponse = {
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
id: "triage-detail-1",
|
||||
attributes: {
|
||||
finding_id: "finding-1",
|
||||
finding_uid: "prowler-finding-uid-1",
|
||||
status: "risk_accepted",
|
||||
triage_notes_count: 1,
|
||||
has_note: true,
|
||||
note_id: "note-detail-1",
|
||||
current_note: "Current note visible only inside the modal.",
|
||||
source: "manual",
|
||||
updated_by: "user-1",
|
||||
inserted_at: "2026-06-03T10:00:00Z",
|
||||
updated_at: "2026-06-03T10:05:00Z",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
type FindingTriageDisabledReason,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
interface FindingTriageAdapterOptions {
|
||||
canEdit: boolean;
|
||||
disabledReason?: FindingTriageDisabledReason;
|
||||
}
|
||||
|
||||
export function getFindingTriageAdapterOptions(): FindingTriageAdapterOptions {
|
||||
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
|
||||
return {
|
||||
canEdit: isCloudEnvironment,
|
||||
...(isCloudEnvironment
|
||||
? {}
|
||||
: { disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
const {
|
||||
createMuteRuleMock,
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
createMuteRuleMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/mute-rules", () => ({
|
||||
createMuteRule: createMuteRuleMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
const importActions = async () => import("./findings-triage");
|
||||
|
||||
describe("findings triage actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
createMuteRuleMock.mockResolvedValue({ success: "muted" });
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 200 }));
|
||||
});
|
||||
|
||||
it("should load notes through the persisted triage route when triageId exists", async () => {
|
||||
// Given
|
||||
const { loadLatestFindingTriageNote } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "note-1",
|
||||
type: "finding-triage-notes",
|
||||
attributes: { body: "Existing note" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await loadLatestFindingTriageNote({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ noteId: "note-1", noteBody: "Existing note" });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1/notes",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should load notes through the finding UID route when triageId is virtual", async () => {
|
||||
// Given
|
||||
const { loadLatestFindingTriageNote } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "note-1",
|
||||
type: "finding-triage-notes",
|
||||
attributes: { body: "Existing note" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When
|
||||
await loadLatestFindingTriageNote({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: null,
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve findingUid from findingId before loading virtual triage notes", async () => {
|
||||
// Given
|
||||
const { loadLatestFindingTriageNote } = await importActions();
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { uid: "finding/stable/uid" } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "note-1",
|
||||
type: "finding-triage-notes",
|
||||
attributes: { body: "Existing note" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// When
|
||||
await loadLatestFindingTriageNote({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "",
|
||||
triageId: null,
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.test/api/v1/findings/finding-snapshot-id",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should send the first note with the status update through the triage route", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "First note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "First note",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update an existing note through its note id", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
note: "Updated note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triage-notes",
|
||||
attributes: {
|
||||
body: "Updated note",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should delete an existing persisted note when it is cleared", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
note: "",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
},
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
|
||||
expect.objectContaining({ method: "PATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update an existing note and send status-only triage patch", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
note: "Updated note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.test/api/v1/finding-triages/triage-1/notes/note-1",
|
||||
expect.objectContaining({ method: "PATCH" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.test/api/v1/finding-triages/triage-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update a virtual existing note through the finding UID note route", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: null,
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
note: "Updated note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1",
|
||||
expect.objectContaining({ method: "PATCH" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not patch virtual triage route for virtual existing-note-only updates", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: null,
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
note: "Updated note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1",
|
||||
expect.objectContaining({ method: "PATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should delete a virtual existing note when it is cleared", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "note-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: null,
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
note: "",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage/notes/note-1",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a mute rule when status is Risk Accepted", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(createMuteRuleMock).toHaveBeenCalledOnce();
|
||||
const formData = createMuteRuleMock.mock.calls[0][1] as FormData;
|
||||
expect(formData.get("finding_ids")).toBe(
|
||||
JSON.stringify(["finding-snapshot-id"]),
|
||||
);
|
||||
expect(formData.get("name")).toBe(
|
||||
"Finding triage: Risk Accepted - finding-snapshot-id",
|
||||
);
|
||||
expect(formData.get("reason")).toBe(
|
||||
"Finding triage status changed to Risk Accepted.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject and skip muting when triage patch returns an action error", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
error: "Triage failed",
|
||||
status: 400,
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
).rejects.toThrow("Triage failed");
|
||||
expect(createMuteRuleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should rollback triage status when automatic muting fails", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
createMuteRuleMock.mockResolvedValue({
|
||||
errors: { general: "Mute failed" },
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
).rejects.toThrow("Mute failed");
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.test/api/v1/finding-triages/triage-1",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should rollback virtual triage status through encoded finding UID when automatic muting fails", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
createMuteRuleMock.mockResolvedValue({
|
||||
errors: { general: "Mute failed" },
|
||||
});
|
||||
|
||||
// When / Then
|
||||
await expect(
|
||||
updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
).rejects.toThrow("Mute failed");
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not create a mute rule when the finding is already muted", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: true,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(createMuteRuleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not create a mute rule when moving between shortcut statuses", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(createMuteRuleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not create a mute rule when shortcut status did not change", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
note: "First note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(createMuteRuleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not create a mute rule for regular triage statuses", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(createMuteRuleMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update virtual triage through the finding UID route, not the snapshot id", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock.mockResolvedValue({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "finding/stable/uid",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
|
||||
expect.objectContaining({ method: "PATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should resolve findingUid from findingId before creating virtual triage", async () => {
|
||||
// Given
|
||||
const { updateFindingTriage } = await importActions();
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce({
|
||||
data: { attributes: { uid: "finding/stable/uid" } },
|
||||
})
|
||||
.mockResolvedValueOnce({ data: { id: "triage-1" } });
|
||||
|
||||
// When
|
||||
await updateFindingTriage({
|
||||
findingId: "finding-snapshot-id",
|
||||
findingUid: "",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "First note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"https://api.test/api/v1/findings/finding-snapshot-id",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"https://api.test/api/v1/findings/finding%2Fstable%2Fuid/triage",
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "First note",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
"use server";
|
||||
|
||||
import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter";
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageSummary,
|
||||
isMutelistShortcutStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
|
||||
|
||||
const buildFindingTriageBody = ({
|
||||
status,
|
||||
note,
|
||||
}: {
|
||||
status?:
|
||||
| UpdateFindingTriageInput["status"]
|
||||
| UpdateFindingTriageInput["previousStatus"];
|
||||
note?: UpdateFindingTriageInput["note"];
|
||||
}) => ({
|
||||
data: {
|
||||
type: "finding-triages",
|
||||
attributes: {
|
||||
...(status ? { status } : {}),
|
||||
...(note ? { note } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildFindingTriageNoteBody = (body: string) => ({
|
||||
data: {
|
||||
type: "finding-triage-notes",
|
||||
attributes: {
|
||||
body,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildApiUrl = (path: `/${string}`) => {
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error("API base URL is not configured.");
|
||||
}
|
||||
|
||||
const url = new URL(apiBaseUrl);
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}${path}`;
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
async function getJsonApi(path: `/${string}`) {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
headers,
|
||||
});
|
||||
|
||||
return handleApiResponse(response);
|
||||
}
|
||||
|
||||
const throwIfApiError = (result: unknown) => {
|
||||
if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
("error" in result ||
|
||||
("status" in result &&
|
||||
typeof result.status === "number" &&
|
||||
result.status >= 400))
|
||||
) {
|
||||
throw new Error(
|
||||
"error" in result && typeof result.error === "string"
|
||||
? result.error
|
||||
: "Finding triage request failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
async function patchJsonApi(path: `/${string}`, body: unknown) {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": JSON_API_CONTENT_TYPE,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const result = await handleApiResponse(response);
|
||||
throwIfApiError(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function deleteJsonApi(path: `/${string}`) {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
const result = await handleApiResponse(response);
|
||||
throwIfApiError(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const shouldCreateTriageMuteRule = (
|
||||
input: UpdateFindingTriageInput,
|
||||
): input is UpdateFindingTriageInput & {
|
||||
status: NonNullable<UpdateFindingTriageInput["status"]>;
|
||||
} =>
|
||||
Boolean(input.status) &&
|
||||
input.previousStatus !== undefined &&
|
||||
input.status !== input.previousStatus &&
|
||||
input.isMuted !== true &&
|
||||
isMutelistShortcutStatus(input.status) &&
|
||||
!isMutelistShortcutStatus(input.previousStatus);
|
||||
|
||||
async function createTriageMuteRule(input: UpdateFindingTriageInput) {
|
||||
if (!shouldCreateTriageMuteRule(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = FINDING_TRIAGE_STATUS_LABELS[input.status];
|
||||
const formData = new FormData();
|
||||
formData.set("name", `Finding triage: ${label} - ${input.findingId}`);
|
||||
formData.set("reason", `Finding triage status changed to ${label}.`);
|
||||
formData.set("finding_ids", JSON.stringify([input.findingId]));
|
||||
|
||||
const result = await createMuteRule(null, formData);
|
||||
|
||||
if (result?.errors) {
|
||||
throw new Error(
|
||||
result.errors.general ||
|
||||
result.errors.finding_ids ||
|
||||
result.errors.name ||
|
||||
result.errors.reason ||
|
||||
"Could not mute finding after triage status change.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const encodePathSegment = (value: string) => encodeURIComponent(value);
|
||||
|
||||
async function rollbackTriageStatus(
|
||||
input: UpdateFindingTriageInput,
|
||||
findingUid?: string,
|
||||
) {
|
||||
if (!input.previousStatus || !shouldCreateTriageMuteRule(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = input.previousStatus;
|
||||
|
||||
if (input.triageId) {
|
||||
await patchJsonApi(
|
||||
`/finding-triages/${input.triageId}`,
|
||||
buildFindingTriageBody({ status: previousStatus }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (findingUid) {
|
||||
await patchJsonApi(
|
||||
`/findings/${encodePathSegment(findingUid)}/triage`,
|
||||
buildFindingTriageBody({ status: previousStatus }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMuteRuleOrRollback(
|
||||
input: UpdateFindingTriageInput,
|
||||
findingUid?: string,
|
||||
) {
|
||||
try {
|
||||
await createTriageMuteRule(input);
|
||||
} catch (error) {
|
||||
try {
|
||||
await rollbackTriageStatus(input, findingUid);
|
||||
} catch (rollbackError) {
|
||||
console.error("Could not rollback finding triage status.", rollbackError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFindingUid({
|
||||
findingId,
|
||||
findingUid,
|
||||
}: Pick<UpdateFindingTriageInput, "findingId" | "findingUid">) {
|
||||
if (findingUid) {
|
||||
return findingUid;
|
||||
}
|
||||
|
||||
const apiResponse = await getJsonApi(
|
||||
`/findings/${encodePathSegment(findingId)}`,
|
||||
);
|
||||
const resolvedFindingUid = apiResponse?.data?.attributes?.uid;
|
||||
|
||||
if (typeof resolvedFindingUid !== "string" || !resolvedFindingUid) {
|
||||
throw new Error("Cannot create finding triage without findingUid.");
|
||||
}
|
||||
|
||||
return resolvedFindingUid;
|
||||
}
|
||||
|
||||
export async function loadLatestFindingTriageNote(
|
||||
triage: FindingTriageSummary,
|
||||
): Promise<FindingTriageLoadedNote> {
|
||||
const findingUid = triage.triageId
|
||||
? triage.findingUid
|
||||
: await resolveFindingUid(triage);
|
||||
const apiResponse = await getJsonApi(
|
||||
triage.triageId
|
||||
? `/finding-triages/${triage.triageId}/notes`
|
||||
: `/findings/${encodePathSegment(findingUid)}/triage/notes`,
|
||||
);
|
||||
const latestNote = adaptLatestFindingTriageNote(apiResponse);
|
||||
|
||||
if (!latestNote) {
|
||||
throw new Error("Could not load the latest finding triage note.");
|
||||
}
|
||||
|
||||
return latestNote;
|
||||
}
|
||||
|
||||
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
|
||||
let findingUid: string | undefined;
|
||||
let triagePath: `/${string}`;
|
||||
|
||||
if (input.triageId) {
|
||||
triagePath = `/finding-triages/${input.triageId}`;
|
||||
} else {
|
||||
findingUid = await resolveFindingUid(input);
|
||||
triagePath = `/findings/${encodePathSegment(findingUid)}/triage`;
|
||||
}
|
||||
|
||||
if (input.note !== undefined && input.notesCount > 0 && input.noteId) {
|
||||
const notePath: `/${string}` = `${triagePath}/notes/${input.noteId}`;
|
||||
const noteResult =
|
||||
input.note === ""
|
||||
? await deleteJsonApi(notePath)
|
||||
: await patchJsonApi(notePath, buildFindingTriageNoteBody(input.note));
|
||||
|
||||
if (!input.status) {
|
||||
return noteResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.status && !(input.note && input.notesCount === 0)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = await patchJsonApi(
|
||||
triagePath,
|
||||
buildFindingTriageBody({
|
||||
status: input.status,
|
||||
note: input.notesCount === 0 && input.note ? input.note : undefined,
|
||||
}),
|
||||
);
|
||||
await createMuteRuleOrRollback(input, findingUid);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
fetchMock,
|
||||
getAuthHeadersMock,
|
||||
handleApiResponseMock,
|
||||
appendSanitizedProviderTypeFiltersMock,
|
||||
redirectMock,
|
||||
} = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
handleApiResponseMock: vi.fn(),
|
||||
appendSanitizedProviderTypeFiltersMock: vi.fn(),
|
||||
redirectMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: redirectMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
import { FINDING_TRIAGE_STATUS } from "@/types/findings-triage";
|
||||
|
||||
import { getFindings, getLatestFindings } from "./findings";
|
||||
|
||||
const findingsResponse = {
|
||||
data: [
|
||||
{
|
||||
type: "findings",
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "prowler-finding-uid-1",
|
||||
status: "FAIL",
|
||||
triage_status: "under_review",
|
||||
triage_has_note: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: { pagination: { page: 1 } },
|
||||
};
|
||||
|
||||
describe("findings actions triage projection", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue(findingsResponse);
|
||||
});
|
||||
|
||||
it("should attach domain triage DTOs to historical findings responses", async () => {
|
||||
// When
|
||||
const result = await getFindings({ page: 1, pageSize: 10 });
|
||||
|
||||
// Then
|
||||
expect(result?.data[0].triage).toEqual(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
canEdit: false,
|
||||
disabledReason: "cloud_only",
|
||||
}),
|
||||
);
|
||||
expect(result?.data[0].triage).not.toHaveProperty("triage_status");
|
||||
expect(result?.data[0].triage).not.toHaveProperty("attributes");
|
||||
});
|
||||
|
||||
it("should attach domain triage DTOs to latest findings responses", async () => {
|
||||
// Given
|
||||
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
|
||||
|
||||
// When
|
||||
const result = await getLatestFindings({ page: 1, pageSize: 10 });
|
||||
|
||||
// Then
|
||||
expect(result?.data[0].triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
canEdit: true,
|
||||
}),
|
||||
);
|
||||
expect(result?.data[0].triage).not.toHaveProperty("disabledReason");
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,21 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findings-triage.adapter";
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const withFindingTriageSummaries = <T extends { data?: unknown }>(
|
||||
response: T | undefined,
|
||||
): T | undefined => {
|
||||
return attachFindingTriageSummariesToResponse(
|
||||
response,
|
||||
getFindingTriageAdapterOptions(),
|
||||
);
|
||||
};
|
||||
|
||||
export const getFindings = async ({
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
@@ -31,8 +43,9 @@ export const getFindings = async ({
|
||||
const findings = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
const response = await handleApiResponse(findings);
|
||||
|
||||
return handleApiResponse(findings);
|
||||
return withFindingTriageSummaries(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching findings:", error);
|
||||
return undefined;
|
||||
@@ -67,8 +80,9 @@ export const getLatestFindings = async ({
|
||||
const findings = await fetch(url.toString(), {
|
||||
headers,
|
||||
});
|
||||
const response = await handleApiResponse(findings);
|
||||
|
||||
return handleApiResponse(findings);
|
||||
return withFindingTriageSummaries(response);
|
||||
} catch (error) {
|
||||
console.error("Error fetching findings:", error);
|
||||
return undefined;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./findings";
|
||||
export * from "./findings-by-resource";
|
||||
export * from "./findings-by-resource.adapter";
|
||||
export * from "./findings-triage";
|
||||
|
||||
@@ -158,17 +158,24 @@ export const createMuteRule = async (
|
||||
try {
|
||||
if (responseContentType?.includes("application/json")) {
|
||||
const errorData = await response.json();
|
||||
const jsonApiError = (
|
||||
errorData as {
|
||||
errors?: Array<{
|
||||
detail?: string;
|
||||
title?: string;
|
||||
source?: { pointer?: string };
|
||||
}>;
|
||||
message?: string;
|
||||
}
|
||||
)?.errors?.[0];
|
||||
errorMessage =
|
||||
(
|
||||
errorData as {
|
||||
errors?: Array<{ detail?: string }>;
|
||||
message?: string;
|
||||
}
|
||||
)?.errors?.[0]?.detail ||
|
||||
jsonApiError?.detail ||
|
||||
jsonApiError?.title ||
|
||||
(errorData as { message?: string })?.message ||
|
||||
errorMessage;
|
||||
} else {
|
||||
await response.text();
|
||||
const responseText = await response.text();
|
||||
errorMessage = responseText || errorMessage;
|
||||
}
|
||||
} catch {
|
||||
// JSON parsing failed, use default error message
|
||||
|
||||
@@ -47,6 +47,14 @@ vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
getLatestFindings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/organizations/organizations", () => ({
|
||||
listOrganizationsSafe: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
appendSanitizedProviderTypeFilters: vi.fn(),
|
||||
}));
|
||||
@@ -102,29 +110,6 @@ describe("getResourceEvents", () => {
|
||||
expect(calledUrl.searchParams.get("page[size]")).toBe("25");
|
||||
});
|
||||
|
||||
it("returns parsed response on success", async () => {
|
||||
// Given
|
||||
const mockData = {
|
||||
data: [
|
||||
{
|
||||
type: "resource-events",
|
||||
id: "event-1",
|
||||
attributes: { event_name: "CreateStack" },
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockResponse = new Response("", { status: 200 });
|
||||
fetchMock.mockResolvedValue(mockResponse);
|
||||
handleApiResponseMock.mockResolvedValue(mockData);
|
||||
|
||||
// When
|
||||
const result = await getResourceEvents("resource-123");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(mockData);
|
||||
expect(handleApiResponseMock).toHaveBeenCalledWith(mockResponse);
|
||||
});
|
||||
|
||||
it("returns error object for non-ok responses without calling handleApiResponse", async () => {
|
||||
// Given
|
||||
const errorBody = JSON.stringify({
|
||||
|
||||
@@ -14,13 +14,17 @@ import {
|
||||
ScanConfigurationRequestBody,
|
||||
} from "@/types/scan-configurations";
|
||||
|
||||
const SCAN_CONFIGURATION_PATH = "/scan-configurations";
|
||||
const SCAN_CONFIGURATION_PATH = "/scans/config";
|
||||
|
||||
// Scan Configuration IDs are UUIDs. Validate before interpolating into request
|
||||
// URLs so a malformed/crafted value can't inject path segments (SSRF / path
|
||||
// injection).
|
||||
const scanConfigurationIdSchema = z.uuid();
|
||||
|
||||
// Provider IDs are UUIDs too. Validate the whole array at the action boundary so
|
||||
// a malformed/crafted id fails here instead of relying on API-side validation.
|
||||
const providerIdsSchema = z.array(z.uuid());
|
||||
|
||||
const parseConfiguration = (value: string): Record<string, unknown> => {
|
||||
// Backend (YamlOrJsonField) accepts either a YAML string or a JSON object.
|
||||
// We parse client-side so failures surface as form errors, not 500s.
|
||||
@@ -38,6 +42,48 @@ const collectProviderIds = (formData: FormData): string[] => {
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
interface ApiErrorSource {
|
||||
pointer?: string;
|
||||
}
|
||||
|
||||
interface ApiError {
|
||||
detail?: string;
|
||||
title?: string;
|
||||
source?: ApiErrorSource;
|
||||
}
|
||||
|
||||
// Route each JSON:API error to the matching form field via its `source.pointer`
|
||||
// so it renders inline next to the offending input. Only errors we can't anchor
|
||||
// to a field fall back to `general` (surfaced as a toast). Shared by create and
|
||||
// update so both flows present validation errors identically — otherwise a
|
||||
// config error shows inline on create but as a toast on update.
|
||||
const mapApiErrorsToFields = (
|
||||
errorData: { errors?: ApiError[]; message?: string } | null | undefined,
|
||||
fallbackMessage: string,
|
||||
): ScanConfigurationErrors => {
|
||||
const apiErrors = Array.isArray(errorData?.errors) ? errorData!.errors! : [];
|
||||
|
||||
if (apiErrors.length === 0) {
|
||||
return { general: errorData?.message || fallbackMessage };
|
||||
}
|
||||
|
||||
const errors: ScanConfigurationErrors = {};
|
||||
const append = (key: keyof ScanConfigurationErrors, detail: string) => {
|
||||
errors[key] = errors[key] ? `${errors[key]}\n${detail}` : detail;
|
||||
};
|
||||
|
||||
for (const err of apiErrors) {
|
||||
const detail = err?.detail || err?.title || fallbackMessage;
|
||||
const pointer = err?.source?.pointer;
|
||||
if (pointer?.includes("name")) append("name", detail);
|
||||
else if (pointer?.includes("configuration"))
|
||||
append("configuration", detail);
|
||||
else if (pointer?.includes("provider_ids")) append("provider_ids", detail);
|
||||
else append("general", detail);
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const createScanConfiguration = async (
|
||||
_prevState: ScanConfigurationActionState,
|
||||
formData: FormData,
|
||||
@@ -95,20 +141,12 @@ export const createScanConfiguration = async (
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const detail =
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.message ||
|
||||
`Failed to create Scan Configuration: ${response.statusText}`;
|
||||
const pointer = errorData?.errors?.[0]?.source?.pointer as
|
||||
| string
|
||||
| undefined;
|
||||
const errors: ScanConfigurationErrors = {};
|
||||
if (pointer?.includes("name")) errors.name = detail;
|
||||
else if (pointer?.includes("configuration"))
|
||||
errors.configuration = detail;
|
||||
else if (pointer?.includes("provider_ids")) errors.provider_ids = detail;
|
||||
else errors.general = detail;
|
||||
return { errors };
|
||||
return {
|
||||
errors: mapApiErrorsToFields(
|
||||
errorData,
|
||||
`Failed to create Scan Configuration: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -199,11 +237,12 @@ export const updateScanConfiguration = async (
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
const detail =
|
||||
errorData?.errors?.[0]?.detail ||
|
||||
errorData?.message ||
|
||||
`Failed to update Scan Configuration: ${response.statusText}`;
|
||||
return { errors: { general: detail } };
|
||||
return {
|
||||
errors: mapApiErrorsToFields(
|
||||
errorData,
|
||||
`Failed to update Scan Configuration: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@@ -225,30 +264,67 @@ export const updateScanConfiguration = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getScanConfigurationSchema = async (): Promise<Record<
|
||||
string,
|
||||
unknown
|
||||
> | null> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/scan-configurations/schema`);
|
||||
// Attach/detach providers on a scan configuration without touching its name or
|
||||
// YAML — a partial PATCH of `provider_ids` only. Used by the provider row to
|
||||
// associate/disassociate a config (editing the config itself lives in the Scan
|
||||
// Config view). The backend's `(tenant, provider)` uniqueness means attaching a
|
||||
// provider here moves it off any other config automatically.
|
||||
export const setScanConfigurationProviders = async (
|
||||
configId: string,
|
||||
providerIds: string[],
|
||||
): Promise<ScanConfigurationActionState> => {
|
||||
const idResult = scanConfigurationIdSchema.safeParse(configId);
|
||||
if (!idResult.success) {
|
||||
return { errors: { general: "Invalid Scan Configuration ID" } };
|
||||
}
|
||||
const validId = idResult.data;
|
||||
const providerIdsResult = providerIdsSchema.safeParse(providerIds);
|
||||
if (!providerIdsResult.success) {
|
||||
return { errors: { provider_ids: "Invalid provider ID" } };
|
||||
}
|
||||
const validProviderIds = providerIdsResult.data;
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/scan-configurations/${validId}`);
|
||||
// Partial update: only provider_ids (name/configuration are optional on the
|
||||
// backend update serializer), so we don't type this as the full request body.
|
||||
const bodyData = {
|
||||
data: {
|
||||
type: "scan-configurations" as const,
|
||||
id: validId,
|
||||
attributes: { provider_ids: validProviderIds },
|
||||
},
|
||||
};
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Scan Configuration schema: ${response.statusText}`,
|
||||
);
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
errors: mapApiErrorsToFields(
|
||||
errorData,
|
||||
`Failed to update Scan Configuration: ${response.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
const json = await response.json();
|
||||
const schema = json?.data?.attributes?.schema as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
return schema ?? null;
|
||||
|
||||
revalidatePath(SCAN_CONFIGURATION_PATH);
|
||||
revalidatePath("/providers");
|
||||
return { success: "Scan Configuration updated successfully!" };
|
||||
} catch (error) {
|
||||
console.error("Error fetching Scan Configuration schema:", error);
|
||||
return null;
|
||||
console.error("Error updating Scan Configuration providers:", error);
|
||||
return {
|
||||
errors: {
|
||||
general:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error updating Scan Configuration. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { List, Settings } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
|
||||
@@ -15,14 +14,8 @@ export function MutelistTabs({ simpleContent }: MutelistTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="simple" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="simple" className="gap-2">
|
||||
<List className="size-4" />
|
||||
Simple
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="gap-2">
|
||||
<Settings className="size-4" />
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="simple">Simple</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="simple">{simpleContent}</TabsContent>
|
||||
|
||||
@@ -43,4 +43,13 @@ describe("providers page", () => {
|
||||
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
|
||||
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
|
||||
});
|
||||
|
||||
it("does not collapse scan config loading failures into an empty list", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pagePath = path.join(currentDir, "page.tsx");
|
||||
const source = readFileSync(pagePath, "utf8");
|
||||
|
||||
expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE");
|
||||
expect(source).not.toContain("catch {\n return [];");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { listScanConfigurations } from "@/actions/scan-configurations";
|
||||
import { ProvidersAccountsView } from "@/components/providers";
|
||||
import { SkeletonTableProviders } from "@/components/providers/table";
|
||||
import { CliImportBanner } from "@/components/scans";
|
||||
@@ -7,6 +8,10 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
type ScanConfigurationListState,
|
||||
} from "@/types/scan-configurations";
|
||||
|
||||
import { ProviderGroupsContent } from "./provider-groups-content";
|
||||
import { ProviderPageTabs } from "./provider-page-tabs";
|
||||
@@ -103,24 +108,45 @@ const ProviderGroupsFallback = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const loadScanConfigs = async (
|
||||
isCloud: boolean,
|
||||
): Promise<ScanConfigurationListState> => {
|
||||
if (!isCloud) {
|
||||
return { status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE, data: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
status: SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
data: await listScanConfigurations(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading provider scan configurations:", error);
|
||||
return { status: SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE, data: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const ProvidersTabContent = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const providersView = await loadProvidersAccountsViewData({
|
||||
searchParams,
|
||||
isCloud: process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true",
|
||||
});
|
||||
const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
|
||||
const [providersView, scanConfigsState] = await Promise.all([
|
||||
loadProvidersAccountsViewData({ searchParams, isCloud }),
|
||||
loadScanConfigs(isCloud),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProvidersAccountsView
|
||||
isCloud={process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"}
|
||||
isCloud={isCloud}
|
||||
filters={providersView.filters}
|
||||
providers={providersView.providers}
|
||||
providerGroups={providersView.providerGroups}
|
||||
metadata={providersView.metadata}
|
||||
rows={providersView.rows}
|
||||
scanConfigs={scanConfigsState.data}
|
||||
scanConfigStatus={scanConfigsState.status}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+68
-64
@@ -21,11 +21,10 @@ import {
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { fontMono } from "@/config/fonts";
|
||||
import {
|
||||
convertToYaml,
|
||||
defaultScanConfigurationYaml,
|
||||
validateScanConfigurationPayload,
|
||||
validateYaml,
|
||||
} from "@/lib/yaml";
|
||||
import { scanConfigurationFormSchema } from "@/types/formSchemas";
|
||||
import { ProviderProps } from "@/types/providers";
|
||||
@@ -37,7 +36,6 @@ interface ScanConfigurationEditorProps {
|
||||
richProviders: ProviderProps[];
|
||||
existingConfigs: ScanConfigurationData[];
|
||||
config: ScanConfigurationData | null;
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface ScanConfigurationFormProps {
|
||||
@@ -45,7 +43,6 @@ interface ScanConfigurationFormProps {
|
||||
richProviders: ProviderProps[];
|
||||
existingConfigs: ScanConfigurationData[];
|
||||
config: ScanConfigurationData | null;
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// `provider_ids` has a zod `.default([])`, so the resolver's input and output
|
||||
@@ -53,14 +50,11 @@ interface ScanConfigurationFormProps {
|
||||
type ScanConfigurationFormInput = z.input<typeof scanConfigurationFormSchema>;
|
||||
type ScanConfigurationFormValues = z.output<typeof scanConfigurationFormSchema>;
|
||||
|
||||
const MAX_ERRORS_SHOWN = 10;
|
||||
|
||||
function ScanConfigurationForm({
|
||||
onClose,
|
||||
richProviders,
|
||||
existingConfigs,
|
||||
config,
|
||||
schema,
|
||||
}: ScanConfigurationFormProps) {
|
||||
const isEdit = !!config;
|
||||
const { toast } = useToast();
|
||||
@@ -87,12 +81,13 @@ function ScanConfigurationForm({
|
||||
const configText = form.watch("configuration") || "";
|
||||
const selectedProviders = form.watch("provider_ids") || [];
|
||||
|
||||
// Real-time validation against the server schema (ranges/enums). Kept out of
|
||||
// form state because it's derived purely from the current YAML text — skip it
|
||||
// while the field is empty so we don't flag an error before the user types.
|
||||
const yamlValidation = configText.trim()
|
||||
? validateScanConfigurationPayload(configText, schema)
|
||||
: { isValid: true, errors: [] };
|
||||
// Mirror the Mutelist editor: the client validates YAML *syntax* live (that it
|
||||
// parses to a mapping); the API validates the configuration values
|
||||
// (ranges/enums) on save and returns them inline. Skip while empty so we don't
|
||||
// flag an error before the user types.
|
||||
const yamlSyntax = configText.trim()
|
||||
? validateYaml(configText)
|
||||
: { isValid: true as const };
|
||||
|
||||
// A provider can only be attached to one config at a time. We exclude
|
||||
// providers that are owned by *other* configs from the selector so the user
|
||||
@@ -111,9 +106,9 @@ function ScanConfigurationForm({
|
||||
const lockedCount = richProviders.length - selectableProviders.length;
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
// The inline panel already lists every schema/syntax error in real time, so
|
||||
// we don't duplicate them in a toast — just bring the panel into view.
|
||||
if (yamlValidation.errors.length > 0) {
|
||||
// Block on a YAML syntax error (the inline message already explains it); the
|
||||
// API validates the values on save and returns any errors inline.
|
||||
if (!yamlSyntax.isValid) {
|
||||
errorPanelRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
@@ -194,64 +189,64 @@ function ScanConfigurationForm({
|
||||
<FieldLabel htmlFor="scan-configuration-yaml">
|
||||
Configuration (YAML)
|
||||
</FieldLabel>
|
||||
<p className="text-default-500 text-tiny">
|
||||
Follows the structure of{" "}
|
||||
<CustomLink
|
||||
size="sm"
|
||||
href="https://github.com/prowler-cloud/prowler/blob/master/prowler/config/config.yaml"
|
||||
>
|
||||
prowler/config/config.yaml
|
||||
</CustomLink>
|
||||
. Allowed ranges and enums come from the server schema; invalid values
|
||||
are listed below in real time.
|
||||
</p>
|
||||
<ul className="text-default-500 text-tiny mb-1 list-disc pl-5">
|
||||
<li>
|
||||
Follows the structure of{" "}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/cli/tutorials/configuration_file"
|
||||
>
|
||||
prowler/config/config.yaml
|
||||
</CustomLink>
|
||||
; include only the keys you want to override.
|
||||
</li>
|
||||
<li>The configuration is validated on save.</li>
|
||||
<li>
|
||||
Learn more about configuring scans{" "}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
|
||||
>
|
||||
here
|
||||
</CustomLink>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
<Textarea
|
||||
id="scan-configuration-yaml"
|
||||
placeholder={defaultScanConfigurationYaml}
|
||||
rows={14}
|
||||
aria-invalid={!!configError || !yamlValidation.isValid}
|
||||
className={fontMono.className + " text-sm"}
|
||||
{...form.register("configuration")}
|
||||
aria-invalid={!!configError || !yamlSyntax.isValid}
|
||||
font="mono"
|
||||
{...form.register("configuration", {
|
||||
// A server-side validation error becomes stale the moment the user
|
||||
// edits the YAML — clear it so it can't linger next to the live
|
||||
// client-side syntax check.
|
||||
onChange: () => form.clearErrors("configuration"),
|
||||
})}
|
||||
/>
|
||||
<div aria-live="polite" className="mt-1" ref={errorPanelRef}>
|
||||
{yamlValidation.errors.length === 0 && configText.trim() ? (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="mt-1 flex flex-col gap-1"
|
||||
ref={errorPanelRef}
|
||||
>
|
||||
{!yamlSyntax.isValid ? (
|
||||
<FieldError>{`Invalid YAML format: ${yamlSyntax.error}`}</FieldError>
|
||||
) : configText.trim() && !configError ? (
|
||||
<p className="text-tiny text-text-success-primary">
|
||||
Configuration valid
|
||||
Valid YAML format
|
||||
</p>
|
||||
) : yamlValidation.errors.length > 0 ? (
|
||||
<div className="border-border-error rounded-md border bg-red-50 p-3 dark:bg-red-950/50">
|
||||
<p className="text-tiny text-text-error-primary mb-1 font-medium">
|
||||
{yamlValidation.errors.length} validation{" "}
|
||||
{yamlValidation.errors.length === 1 ? "error" : "errors"}:
|
||||
</p>
|
||||
<ul className="text-default-700 text-tiny list-disc space-y-1 pl-5">
|
||||
{yamlValidation.errors
|
||||
.slice(0, MAX_ERRORS_SHOWN)
|
||||
.map((err, idx) => (
|
||||
<li key={`${err.path}-${idx}`}>
|
||||
<code className="text-tiny">{err.path}</code>:{" "}
|
||||
<span>{err.message}</span>
|
||||
</li>
|
||||
))}
|
||||
{yamlValidation.errors.length > MAX_ERRORS_SHOWN && (
|
||||
<li>
|
||||
+ {yamlValidation.errors.length - MAX_ERRORS_SHOWN} more
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{configError && (
|
||||
<FieldError className="mt-1">{configError}</FieldError>
|
||||
)}
|
||||
{configError && <FieldError multiline>{configError}</FieldError>}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Attach to providers</FieldLabel>
|
||||
<FieldLabel>Attach to providers (optional)</FieldLabel>
|
||||
<p className="text-default-500 text-tiny">
|
||||
Pick the cloud providers that should use this configuration on their
|
||||
next scan.
|
||||
Pick the providers that should use this configuration on their next
|
||||
scan. You can save it without any and attach providers later — it just
|
||||
won't apply to a scan until one is attached.
|
||||
{lockedCount > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -274,6 +269,12 @@ function ScanConfigurationForm({
|
||||
form.setValue("provider_ids", values, { shouldValidate: true })
|
||||
}
|
||||
selectedValues={selectedProviders}
|
||||
// Here an empty selection means "no providers attached" (the field
|
||||
// is optional), not the filter default of "all providers". Override
|
||||
// the filter-oriented labels so the control reads correctly.
|
||||
placeholder="No providers selected"
|
||||
emptySelectionLabel="No providers selected"
|
||||
clearSelectionLabel="Clear selection"
|
||||
search={{
|
||||
placeholder: "Search providers...",
|
||||
emptyMessage: "No providers found.",
|
||||
@@ -293,7 +294,11 @@ function ScanConfigurationForm({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="lg" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isSubmitting || !yamlSyntax.isValid}
|
||||
>
|
||||
{isSubmitting ? "Saving..." : isEdit ? "Update" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -307,7 +312,6 @@ export function ScanConfigurationEditor({
|
||||
richProviders,
|
||||
existingConfigs,
|
||||
config,
|
||||
schema,
|
||||
}: ScanConfigurationEditorProps) {
|
||||
const isEdit = !!config;
|
||||
|
||||
@@ -319,6 +323,7 @@ export function ScanConfigurationEditor({
|
||||
}}
|
||||
title={isEdit ? "Edit Scan Configuration" : "New Scan Configuration"}
|
||||
size="2xl"
|
||||
scrollable
|
||||
>
|
||||
<ScanConfigurationForm
|
||||
key={config?.id ?? "new"}
|
||||
@@ -326,7 +331,6 @@ export function ScanConfigurationEditor({
|
||||
richProviders={richProviders}
|
||||
existingConfigs={existingConfigs}
|
||||
config={config}
|
||||
schema={schema}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
+22
-20
@@ -3,7 +3,11 @@
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { DateWithTime } from "@/components/ui/entities";
|
||||
import { DataTableColumnHeader } from "@/components/ui/table";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
@@ -61,26 +65,24 @@ export const createScanConfigurationsColumns = (
|
||||
id: "actions",
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(row.original)}
|
||||
aria-label={`Edit ${row.original.attributes.name}`}
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown
|
||||
ariaLabel={`Open actions menu for ${row.original.attributes.name}`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(row.original)}
|
||||
aria-label={`Delete ${row.original.attributes.name}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit"
|
||||
onSelect={() => onEdit(row.original)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete"
|
||||
destructive
|
||||
onSelect={() => onDelete(row.original)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
+19
-18
@@ -28,13 +28,11 @@ const FILTER_CONTROL_COLUMN_CLASS =
|
||||
interface ScanConfigurationsManagerProps {
|
||||
initialConfigs: ScanConfigurationData[];
|
||||
richProviders: ProviderProps[];
|
||||
schema: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function ScanConfigurationsManager({
|
||||
initialConfigs,
|
||||
richProviders,
|
||||
schema,
|
||||
}: ScanConfigurationsManagerProps) {
|
||||
const [configs, setConfigs] =
|
||||
useState<ScanConfigurationData[]>(initialConfigs);
|
||||
@@ -172,27 +170,31 @@ export function ScanConfigurationsManager({
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
)}
|
||||
<Button size="lg" onClick={openCreate} className="md:ml-auto">
|
||||
<Plus className="size-4" />
|
||||
New Scan Configuration
|
||||
</Button>
|
||||
<div className="md:ml-auto">
|
||||
<Button size="lg" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
New Scan Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{noMatchForProvider ? (
|
||||
<Card variant="base" className="p-8 text-center">
|
||||
<p className="text-default-700 text-sm font-medium">
|
||||
{providerFilter.length === 1
|
||||
? "No Scan Configuration is attached to this provider."
|
||||
: "No Scan Configuration is attached to any of the selected providers."}
|
||||
</p>
|
||||
<p className="text-default-500 mt-1 text-sm">
|
||||
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
|
||||
built-in defaults shipped with Prowler. Attach a Scan Configuration
|
||||
from the editor to override them.
|
||||
</p>
|
||||
<Card variant="base" padding="xl">
|
||||
<div className="text-center">
|
||||
<p className="text-default-700 text-sm font-medium">
|
||||
{providerFilter.length === 1
|
||||
? "No Scan Configuration is attached to this provider."
|
||||
: "No Scan Configuration is attached to any of the selected providers."}
|
||||
</p>
|
||||
<p className="text-default-500 mt-1 text-sm">
|
||||
The next scan{providerFilter.length === 1 ? "" : "s"} will use the
|
||||
built-in defaults shipped with Prowler. Attach a Scan
|
||||
Configuration from the editor to override them.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<DataTable
|
||||
@@ -215,7 +217,6 @@ export function ScanConfigurationsManager({
|
||||
richProviders={richProviders}
|
||||
existingConfigs={configs}
|
||||
config={editingConfig}
|
||||
schema={schema}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("scan config page", () => {
|
||||
it("does not block SSR on the full providers crawl", () => {
|
||||
// Given
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(path.join(currentDir, "page.tsx"), "utf8");
|
||||
|
||||
// Then
|
||||
expect(source).not.toContain("getAllProviders");
|
||||
expect(source).toContain("getProviders({ pageSize: 100 })");
|
||||
expect(source).toContain("throw new Error");
|
||||
});
|
||||
});
|
||||
+10
-11
@@ -1,10 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import {
|
||||
getScanConfigurationSchema,
|
||||
listScanConfigurations,
|
||||
} from "@/actions/scan-configurations";
|
||||
import { getProviders } from "@/actions/providers";
|
||||
import { listScanConfigurations } from "@/actions/scan-configurations";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
@@ -19,20 +16,22 @@ export default async function ScanConfigPage() {
|
||||
|
||||
// A failure here propagates to the `(prowler)/error.tsx` boundary instead of
|
||||
// rendering a false "no scan configurations" empty table during SSR.
|
||||
const [configs, providersResponse, schema] = await Promise.all([
|
||||
const [configs, providersResponse] = await Promise.all([
|
||||
listScanConfigurations(),
|
||||
getAllProviders({}),
|
||||
getScanConfigurationSchema(),
|
||||
getProviders({ pageSize: 100 }),
|
||||
]);
|
||||
|
||||
const richProviders = providersResponse?.data ?? [];
|
||||
if (!providersResponse) {
|
||||
throw new Error("Failed to load Providers for Scan Configuration.");
|
||||
}
|
||||
|
||||
const richProviders = providersResponse.data;
|
||||
|
||||
return (
|
||||
<ContentLayout title="Scan Configuration" icon="lucide:sliders">
|
||||
<ContentLayout title="Configuration" icon="lucide:sliders">
|
||||
<ScanConfigurationsManager
|
||||
initialConfigs={configs}
|
||||
richProviders={richProviders}
|
||||
schema={schema}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
@@ -221,7 +221,7 @@ export default async function Scans({
|
||||
|
||||
return (
|
||||
<ContentLayout
|
||||
title="Scan Jobs"
|
||||
title="Scans"
|
||||
icon="lucide:timer"
|
||||
onboardingAction={onboardingAction}
|
||||
>
|
||||
|
||||
@@ -60,11 +60,7 @@ export const ScanSelector = ({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{scans.map((scan) => (
|
||||
<SelectItem
|
||||
key={scan.id}
|
||||
value={scan.id}
|
||||
className="data-[state=checked]:bg-bg-neutral-tertiary [&_svg:not([class*='size-'])]:size-6"
|
||||
>
|
||||
<SelectItem key={scan.id} value={scan.id}>
|
||||
<ComplianceScanInfo scan={scan} />
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,9 @@ vi.mock("next/navigation", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children, ...props }: { children: ReactNode }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
@@ -35,6 +38,9 @@ vi.mock("@/components/shadcn", () => ({
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
Textarea: (props: InputHTMLAttributes<HTMLTextAreaElement>) => (
|
||||
<textarea {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
@@ -78,6 +84,44 @@ vi.mock("./notification-indicator", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
@@ -248,7 +292,7 @@ function renderSelectCell(overrides?: Partial<FindingGroupRow>) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
it("should not expose an impacted providers column", () => {
|
||||
it("should not expose triage and notes columns on group-level rows", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
@@ -257,12 +301,16 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
});
|
||||
|
||||
// When
|
||||
const impactedProvidersColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "impactedProviders",
|
||||
const columnIds = columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(impactedProvidersColumn).toBeUndefined();
|
||||
expect(columnIds).not.toContain("triage");
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(columnIds.at(-1)).toBe("actions");
|
||||
});
|
||||
|
||||
it("should render the first provider icon with its provider name", () => {
|
||||
@@ -290,22 +338,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
expect(button.tagName.toLowerCase()).toBe("button");
|
||||
});
|
||||
|
||||
it("should NOT render the check title as a <p> element", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// Then — <p> should not exist as the interactive element
|
||||
const paragraphs = document.querySelectorAll("p");
|
||||
const clickableParagraph = Array.from(paragraphs).find(
|
||||
(p) => p.textContent === "S3 Bucket Public Access",
|
||||
);
|
||||
expect(clickableParagraph).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call onDrillDown when the button is clicked", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
@@ -328,23 +360,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onDrillDown when Enter key is pressed on the button", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderFindingCell("My Check Title", onDrillDown);
|
||||
|
||||
// When — tab to button and press Enter
|
||||
const button = screen.getByRole("button", { name: "My Check Title" });
|
||||
button.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Then — native button handles Enter natively
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should allow expanding a group that only has PASS resources", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -396,26 +411,6 @@ describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
expect(screen.getByText("Fallback IaC Check")).toBeInTheDocument();
|
||||
expect(onDrillDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should keep fallback groups non-clickable when the displayed total is zero", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("No failing findings", onDrillDown, {
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "No failing findings" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("No failing findings")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — impacted resources count", () => {
|
||||
@@ -490,23 +485,6 @@ describe("column-finding-groups — group selection", () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(onDrillDown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should hide the chevron for zero-resource groups when the displayed total is zero", () => {
|
||||
// Given/When
|
||||
renderSelectCell({
|
||||
resourcesTotal: 0,
|
||||
resourcesFail: 0,
|
||||
failCount: 0,
|
||||
passCount: 0,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name: "Expand S3 Bucket Public Access",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — indicators", () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Button: ({ children, ...props }: ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
onCheckedChange,
|
||||
@@ -66,15 +73,65 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/info-field/info-field", () => ({
|
||||
InfoField: () => null,
|
||||
InfoField: ({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SelectValue: ({ children }: { children?: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: () => null,
|
||||
DateWithTime: ({
|
||||
dateTime,
|
||||
inline,
|
||||
}: {
|
||||
dateTime: string | null;
|
||||
inline?: boolean;
|
||||
}) => <time data-inline={inline ? "true" : "false"}>{dateTime ?? "-"}</time>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
@@ -120,8 +177,35 @@ vi.mock("./notification-indicator", () => ({
|
||||
}));
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import {
|
||||
CLOUD_ONLY_TOOLTIP_COPY,
|
||||
EDITING_UNAVAILABLE_COPY,
|
||||
} from "./finding-triage-cells";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeResource(
|
||||
overrides?: Partial<FindingResourceRow>,
|
||||
@@ -150,78 +234,203 @@ function makeResource(
|
||||
};
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
it("should pass delta to NotificationIndicator for resource rows", () => {
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
|
||||
return columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
}
|
||||
|
||||
const selectColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "select",
|
||||
);
|
||||
if (!selectColumn?.cell) {
|
||||
throw new Error("select column not found");
|
||||
}
|
||||
|
||||
const CellComponent = selectColumn.cell as (props: {
|
||||
row: {
|
||||
id: string;
|
||||
original: FindingResourceRow;
|
||||
toggleSelected: (selected: boolean) => void;
|
||||
};
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
id: "0",
|
||||
original: makeResource(),
|
||||
toggleSelected: vi.fn(),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Select resource")).toBeInTheDocument();
|
||||
expect(notificationIndicatorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delta: "new",
|
||||
isMuted: false,
|
||||
}),
|
||||
);
|
||||
function renderResourceActionsCell({
|
||||
resource = makeResource(),
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
resource?: FindingResourceRow;
|
||||
onTriageUpdateAction?: Parameters<
|
||||
typeof getColumnFindingResources
|
||||
>[0]["onTriageUpdateAction"];
|
||||
onTriageNoteLoadAction?: Parameters<
|
||||
typeof getColumnFindingResources
|
||||
>[0]["onTriageNoteLoadAction"];
|
||||
} = {}) {
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
});
|
||||
|
||||
it("should render the resource EntityInfo with resourceName as alias", () => {
|
||||
const actionsColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "actions",
|
||||
);
|
||||
if (!actionsColumn?.cell) {
|
||||
throw new Error("actions column not found");
|
||||
}
|
||||
const CellComponent = actionsColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: resource } })}</div>);
|
||||
}
|
||||
|
||||
describe("column-finding-resources", () => {
|
||||
it("should render actions as the last visible column after Triage without Notes", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
|
||||
const resourceColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "resource",
|
||||
);
|
||||
if (!resourceColumn?.cell) {
|
||||
throw new Error("resource column not found");
|
||||
}
|
||||
// When
|
||||
const columnIds = getColumnIds(columns);
|
||||
|
||||
const CellComponent = resourceColumn.cell as (props: {
|
||||
// Then
|
||||
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(
|
||||
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
|
||||
).toBe(56);
|
||||
});
|
||||
|
||||
it("should render Open note in resource actions without exposing note preview metadata", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({ hasVisibleNote: true }),
|
||||
}),
|
||||
onTriageUpdateAction: vi.fn(),
|
||||
onTriageNoteLoadAction: vi.fn(),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("button", { name: "Open note" })).toBeEnabled();
|
||||
expect(screen.queryByText("Sensitive note body")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/author/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/timestamp/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable Add Triage Note when no update handler is wired", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({ hasVisibleNote: false }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable Add Triage Note when an update handler is wired", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({ hasVisibleNote: false }),
|
||||
}),
|
||||
onTriageUpdateAction: vi.fn(),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should enable Add Triage Note for Cloud-only rows so users can open the billing upsell modal", () => {
|
||||
// Given
|
||||
renderResourceActionsCell({
|
||||
resource: makeResource({
|
||||
triage: makeTriageSummary({
|
||||
canEdit: false,
|
||||
hasVisibleNote: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should disable editable triage control when no update handler is wired", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
const triageColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "triage",
|
||||
);
|
||||
if (!triageColumn?.cell) {
|
||||
throw new Error("triage column not found");
|
||||
}
|
||||
const CellComponent = triageColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
// When
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
original: makeResource(),
|
||||
original: makeResource({
|
||||
triage: makeTriageSummary({ canEdit: true }),
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("my-bucket")).toBeInTheDocument();
|
||||
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => {
|
||||
// Given
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
const triageColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "triage",
|
||||
);
|
||||
if (!triageColumn?.cell) {
|
||||
throw new Error("triage column not found");
|
||||
}
|
||||
const CellComponent = triageColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
// When
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
original: makeResource({
|
||||
triage: makeTriageSummary({
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByText(CLOUD_ONLY_TOOLTIP_COPY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open Send to Jira modal with finding UUID directly", async () => {
|
||||
|
||||
@@ -24,15 +24,36 @@ import {
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
type DeltaType,
|
||||
NotificationIndicator,
|
||||
} from "./notification-indicator";
|
||||
|
||||
const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const ResourceRowActions = ({
|
||||
row,
|
||||
findingTitle,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
row: Row<FindingResourceRow>;
|
||||
findingTitle?: string;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}) => {
|
||||
const resource = row.original;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
@@ -113,6 +134,17 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown ariaLabel="Resource actions">
|
||||
<FindingNoteActionItem
|
||||
triage={resource.triage}
|
||||
findingContext={{
|
||||
title: findingTitle || resource.checkId,
|
||||
resource: resource.resourceName,
|
||||
provider: resource.providerAlias,
|
||||
providerType: resource.providerType,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
resource.isMuted ? (
|
||||
@@ -141,11 +173,19 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
interface GetColumnFindingResourcesOptions {
|
||||
rowSelection: RowSelectionState;
|
||||
selectableRowCount: number;
|
||||
findingTitle?: string;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}
|
||||
|
||||
export function getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: GetColumnFindingResourcesOptions): ColumnDef<FindingResourceRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -278,7 +318,9 @@ export function getColumnFindingResources({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InfoField label="Region" variant="compact">
|
||||
{row.original.region || "-"}
|
||||
<span className="block truncate whitespace-nowrap">
|
||||
{row.original.region || "-"}
|
||||
</span>
|
||||
</InfoField>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -291,7 +333,7 @@ export function getColumnFindingResources({
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<InfoField label="Last seen" variant="compact">
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} inline />
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} />
|
||||
</InfoField>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -312,11 +354,33 @@ export function getColumnFindingResources({
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column — mute only
|
||||
// Triage
|
||||
{
|
||||
id: "triage",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Triage" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
// Actions column — utility actions are kept last.
|
||||
{
|
||||
id: "actions",
|
||||
size: 56,
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => <ResourceRowActions row={row} />,
|
||||
cell: ({ row }) => (
|
||||
<ResourceRowActions
|
||||
row={row}
|
||||
findingTitle={findingTitle}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string | null }) => (
|
||||
<time>{dateTime ?? "-"}</time>
|
||||
),
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({ title }: { title: string }) => <span>{title}</span>,
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/select/select", () => ({
|
||||
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SelectTrigger: ({
|
||||
children,
|
||||
disabled,
|
||||
"aria-label": ariaLabel,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
"aria-label"?: string;
|
||||
}) => (
|
||||
<button aria-label={ariaLabel} disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/region-flags", () => ({
|
||||
getRegionFlag: () => "",
|
||||
}));
|
||||
|
||||
vi.mock("./finding-detail-drawer", () => ({
|
||||
FindingDetailDrawer: ({ trigger }: { trigger: ReactNode }) => <>{trigger}</>,
|
||||
}));
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./provider-icon-cell", () => ({
|
||||
ProviderIconCell: () => null,
|
||||
}));
|
||||
|
||||
import { getStandaloneFindingColumns } from "./column-standalone-findings";
|
||||
|
||||
describe("column-standalone-findings", () => {
|
||||
it("should render Triage and Actions as the last visible data columns without Notes", () => {
|
||||
// Given
|
||||
const columns = getStandaloneFindingColumns({ includeUpdatedAt: true });
|
||||
|
||||
// When
|
||||
const columnIds = columns.map(
|
||||
(column) =>
|
||||
(column as { id?: string; accessorKey?: string }).id ??
|
||||
(column as { id?: string; accessorKey?: string }).accessorKey,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(columnIds.slice(-2)).toEqual(["triage", "actions"]);
|
||||
expect(columnIds).not.toContain("notes");
|
||||
expect(
|
||||
(columns.at(-1) as { id?: string; size?: number } | undefined)?.size,
|
||||
).toBe(56);
|
||||
});
|
||||
});
|
||||
@@ -10,15 +10,27 @@ import {
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import { FindingProps, ProviderType } from "@/types";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { FindingDetailDrawer } from "./finding-detail-drawer";
|
||||
import { FindingTriageStatusCell } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { DeltaValues, NotificationIndicator } from "./notification-indicator";
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
interface GetStandaloneFindingColumnsOptions {
|
||||
includeUpdatedAt?: boolean;
|
||||
openFindingId?: string | null;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}
|
||||
|
||||
const getFindingsData = (row: { original: FindingProps }) => {
|
||||
@@ -68,6 +80,8 @@ function FindingTitleCell({
|
||||
export function getStandaloneFindingColumns({
|
||||
includeUpdatedAt = false,
|
||||
openFindingId = null,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: GetStandaloneFindingColumnsOptions = {}): ColumnDef<FindingProps>[] {
|
||||
const columns: ColumnDef<FindingProps>[] = [
|
||||
{
|
||||
@@ -129,14 +143,8 @@ export function getStandaloneFindingColumns({
|
||||
cell: ({ row }) => {
|
||||
const name = getResourceData(row, "name");
|
||||
const uid = getResourceData(row, "uid");
|
||||
const entityAlias =
|
||||
typeof name === "string" && name.trim().length > 0 && name !== "-"
|
||||
? name
|
||||
: undefined;
|
||||
const entityId =
|
||||
typeof uid === "string" && uid.trim().length > 0 && uid !== "-"
|
||||
? uid
|
||||
: undefined;
|
||||
const entityAlias = getOptionalText(name);
|
||||
const entityId = getOptionalText(uid);
|
||||
|
||||
return (
|
||||
<div className="max-w-[240px]">
|
||||
@@ -209,7 +217,7 @@ export function getStandaloneFindingColumns({
|
||||
const regionFlag =
|
||||
typeof region === "string" ? getRegionFlag(region) : "";
|
||||
return (
|
||||
<span className="text-text-neutral-primary flex max-w-[140px] items-center gap-1.5 truncate text-sm">
|
||||
<span className="text-text-neutral-primary flex max-w-[140px] min-w-0 items-center gap-1.5 truncate text-sm whitespace-nowrap">
|
||||
{regionFlag && (
|
||||
<span className="translate-y-px text-base leading-none">
|
||||
{regionFlag}
|
||||
@@ -242,5 +250,48 @@ export function getStandaloneFindingColumns({
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
id: "triage",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Triage" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<FindingTriageStatusCell
|
||||
triage={row.original.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 56,
|
||||
header: () => <div className="w-10" />,
|
||||
cell: ({ row }) => {
|
||||
const resourceName = getResourceData(row, "name");
|
||||
const providerAlias = getProviderData(row, "alias");
|
||||
const providerType = getProviderData(row, "provider");
|
||||
|
||||
return (
|
||||
<DataTableRowActions
|
||||
row={row}
|
||||
findingContext={{
|
||||
title: row.original.attributes.check_metadata.checktitle,
|
||||
resource: getOptionalText(resourceName),
|
||||
provider: getOptionalText(providerAlias),
|
||||
providerType: getOptionalText(providerType) as
|
||||
| ProviderType
|
||||
| undefined,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
);
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button onClick={onSelect} disabled={disabled}>
|
||||
@@ -45,7 +45,45 @@ vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => <span>Loading</span>,
|
||||
}));
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
vi.mock("./finding-note-modal", () => ({
|
||||
FindingNoteModal: ({
|
||||
open,
|
||||
triage,
|
||||
}: {
|
||||
open: boolean;
|
||||
triage: {
|
||||
noteBody: string;
|
||||
canEdit: boolean;
|
||||
disabledReason?: string;
|
||||
billingHref: string;
|
||||
};
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label="Note">
|
||||
<textarea
|
||||
aria-label="Note text"
|
||||
value={triage.noteBody}
|
||||
disabled={!triage.canEdit}
|
||||
readOnly
|
||||
/>
|
||||
{triage.disabledReason === "cloud_only" && (
|
||||
<a href={triage.billingHref}>Available in Prowler Cloud</a>
|
||||
)}
|
||||
<button disabled={!triage.canEdit}>Save changes</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
DataTableRowActions,
|
||||
type FindingRowData,
|
||||
} from "./data-table-row-actions";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
function deferredPromise<T>() {
|
||||
@@ -59,6 +97,40 @@ function deferredPromise<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFindingRow(overrides?: Partial<FindingRowData>) {
|
||||
return {
|
||||
original: {
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
muted: false,
|
||||
check_metadata: {
|
||||
checktitle: "S3 public access",
|
||||
},
|
||||
},
|
||||
triage: makeTriageSummary(),
|
||||
...overrides,
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("DataTableRowActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -179,4 +251,76 @@ describe("DataTableRowActions", () => {
|
||||
screen.getByRole("button", { name: "Mute Finding Group" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows Add Triage Note for editable findings without a note", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow()}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Add Triage Note" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it("loads an existing note before opening the note modal", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi.fn().mockResolvedValue({
|
||||
noteId: "note-1",
|
||||
noteBody: "Loaded existing note",
|
||||
});
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
triage: makeTriageSummary({ hasVisibleNote: true, notesCount: 1 }),
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageNoteLoadAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ triageId: "triage-1", notesCount: 1 }),
|
||||
);
|
||||
expect(await screen.findByRole("dialog", { name: "Note" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toHaveValue(
|
||||
"Loaded existing note",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a disabled Cloud-only note modal from finding actions", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
triage: makeTriageSummary({
|
||||
canEdit: false,
|
||||
hasVisibleNote: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog", { name: "Note" })).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,17 @@ import {
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import type {
|
||||
FindingTriageLoadedNote,
|
||||
FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import type { FindingTriageContext } from "./finding-note-modal";
|
||||
import { FindingNoteActionItem } from "./finding-triage-cells";
|
||||
import type { FindingTriageUpdateHandler } from "./finding-triage-status-control";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
export interface FindingRowData {
|
||||
@@ -26,6 +35,20 @@ export interface FindingRowData {
|
||||
checktitle?: string;
|
||||
};
|
||||
};
|
||||
triage?: FindingTriageSummary;
|
||||
relationships?: {
|
||||
resource?: {
|
||||
attributes?: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
provider?: {
|
||||
attributes?: {
|
||||
alias?: string;
|
||||
provider?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
// Flat shape for FindingGroupRow
|
||||
rowType?: string;
|
||||
checkId?: string;
|
||||
@@ -70,11 +93,19 @@ function extractRowInfo(data: FindingRowData) {
|
||||
interface DataTableRowActionsProps<T extends FindingRowData> {
|
||||
row: Row<T>;
|
||||
onMuteComplete?: (findingIds: string[]) => void;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}
|
||||
|
||||
export function DataTableRowActions<T extends FindingRowData>({
|
||||
row,
|
||||
onMuteComplete,
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: DataTableRowActionsProps<T>) {
|
||||
const router = useRouter();
|
||||
const finding = row.original;
|
||||
@@ -86,6 +117,18 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
>(null);
|
||||
|
||||
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
|
||||
const resolvedFindingContext = findingContext ?? {
|
||||
title: findingTitle,
|
||||
resource: getOptionalText(
|
||||
finding.relationships?.resource?.attributes?.name,
|
||||
),
|
||||
provider: getOptionalText(
|
||||
finding.relationships?.provider?.attributes?.alias,
|
||||
),
|
||||
providerType: getOptionalText(
|
||||
finding.relationships?.provider?.attributes?.provider,
|
||||
) as ProviderType | undefined,
|
||||
};
|
||||
|
||||
// Get selection context - if there are other selected rows, include them
|
||||
const selectionContext = useContext(FindingsSelectionContext);
|
||||
@@ -204,8 +247,19 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
preparationError={mutePreparationError}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<div
|
||||
className="flex items-center justify-end"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
{!isGroup && (
|
||||
<FindingNoteActionItem
|
||||
triage={finding.triage}
|
||||
findingContext={resolvedFindingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
isMuted ? (
|
||||
|
||||
@@ -68,6 +68,7 @@ export function FindingDetailDrawer({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -93,6 +94,7 @@ export function FindingDetailDrawer({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIcon: ({ type }: { type: string }) => (
|
||||
<span data-testid={`${type}-provider-badge`}>{type} icon</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingNoteModal,
|
||||
type FindingTriageContext,
|
||||
} from "./finding-note-modal";
|
||||
|
||||
function makeTriageDetail(
|
||||
overrides?: Partial<FindingTriageDetail>,
|
||||
): FindingTriageDetail {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
noteId: "note-1",
|
||||
noteBody: "Existing investigation note",
|
||||
maxNoteLength: 500,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderNoteModal({
|
||||
triage = makeTriageDetail(),
|
||||
onTriageUpdateAction = vi.fn(),
|
||||
onOpenChange = vi.fn(),
|
||||
findingContext = {
|
||||
title: "S3 bucket allows public reads",
|
||||
resource: "production-bucket",
|
||||
provider: "production-account",
|
||||
},
|
||||
}: {
|
||||
triage?: FindingTriageDetail;
|
||||
onTriageUpdateAction?: (input: UpdateFindingTriageInput) => void;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
findingContext?: FindingTriageContext;
|
||||
} = {}) {
|
||||
render(
|
||||
<FindingNoteModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
triage={triage}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { onTriageUpdateAction, onOpenChange };
|
||||
}
|
||||
|
||||
describe("FindingNoteModal", () => {
|
||||
it("should render the provider badge from the row provider type", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
findingContext: {
|
||||
title: "Azure finding",
|
||||
provider: "azure-subscription",
|
||||
providerType: "azure",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("azure-provider-badge")).toBeVisible();
|
||||
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open with title Add Triage Note and current status preselected", () => {
|
||||
// Given / When
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
const dialog = screen.getByRole("dialog", { name: "Add Triage Note" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("S3 bucket allows public reads"));
|
||||
expect(
|
||||
within(dialog).getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
expect(
|
||||
within(dialog).getByText(/automatically changed to Resolved/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("should send existing note changes with noteId and without duplicate-note status payload", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({ onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.clear(textarea);
|
||||
await user.type(textarea, "Documented owner follow-up.");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "Documented owner follow-up.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send status plus note only when creating the first note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
noteBody: "",
|
||||
hasVisibleNote: false,
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
|
||||
// When
|
||||
const textarea = screen.getByLabelText("Note text");
|
||||
await user.type(textarea, " Initial triage note. ");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "Initial triage note.",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an empty body when an existing note is cleared", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({ onOpenChange, onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "",
|
||||
});
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should keep the modal open and show an error when note update fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
renderNoteModal({ onOpenChange, onTriageUpdateAction });
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.type(screen.getByLabelText("Note text"), "Changed note");
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
await screen.findByText("Could not update the note. Please try again."),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeInTheDocument();
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should render counter, privacy copy, and cancel/update actions", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
renderNoteModal({ onOpenChange });
|
||||
|
||||
// When
|
||||
await user.clear(screen.getByLabelText("Note text"));
|
||||
await user.type(screen.getByLabelText("Note text"), "abc");
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("3/500")).toBeInTheDocument();
|
||||
expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Save changes" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable controls and show the Cloud upsell badge for non-paying users", () => {
|
||||
// Given
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
}),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveAttribute("data-disabled", "");
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
expect(screen.queryByText(/will be muted/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show modal-origin Mutelist info and still save accepted-risk statuses", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
renderNoteModal({
|
||||
triage: makeTriageDetail({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
noteBody: "",
|
||||
}),
|
||||
onTriageUpdateAction,
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText(/will be muted/i)).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useRef, useState } from "react";
|
||||
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageStatus,
|
||||
isMutelistShortcutStatus,
|
||||
} from "@/types/findings-triage";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
type FindingTriageUpdateHandler,
|
||||
} from "./finding-triage-status-control";
|
||||
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
|
||||
|
||||
export interface FindingTriageContext {
|
||||
title: string;
|
||||
resource?: string;
|
||||
provider?: string;
|
||||
providerType?: ProviderType;
|
||||
}
|
||||
|
||||
interface FindingNoteModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triage: FindingTriageDetail;
|
||||
findingContext: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
}
|
||||
|
||||
const MUTELIST_INFO_COPY =
|
||||
"This finding will be muted through the existing Mutelist flow.";
|
||||
const REMEDIATING_INFO_COPY =
|
||||
"Once this finding is fixed and passes in the next scan, it will be automatically changed to Resolved.";
|
||||
|
||||
export function FindingNoteModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
triage,
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
}: FindingNoteModalProps) {
|
||||
// Local state needed: modal edits are buffered until the user chooses Update.
|
||||
const [selectedStatus, setSelectedStatus] = useState<FindingTriageStatus>(
|
||||
triage.status,
|
||||
);
|
||||
const [note, setNote] = useState(triage.noteBody);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const noteTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const canSubmit =
|
||||
triage.canEdit && Boolean(onTriageUpdateAction) && !isSubmitting;
|
||||
const isCloudOnly =
|
||||
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
|
||||
const shouldShowMutelistInfo =
|
||||
canSubmit &&
|
||||
!triage.isMuted &&
|
||||
selectedStatus !== triage.status &&
|
||||
isMutelistShortcutStatus(selectedStatus);
|
||||
const shouldShowRemediatingInfo =
|
||||
selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING;
|
||||
// Opened from a dropdown item: move focus into the dialog on mount so Radix's
|
||||
// aria-hidden is not applied to the still-focused dropdown that opened it.
|
||||
const handleOpenAutoFocus = (event: Event) => {
|
||||
const textarea = noteTextareaRef.current;
|
||||
if (textarea && !textarea.disabled) {
|
||||
event.preventDefault();
|
||||
textarea.focus();
|
||||
}
|
||||
// Otherwise let Radix auto-focus the first control inside the dialog.
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const updateInput = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus,
|
||||
noteBody: note,
|
||||
});
|
||||
|
||||
if (!updateInput) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await onTriageUpdateAction?.(updateInput);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
setSubmitError("Could not update the note. Please try again.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
title="Add Triage Note"
|
||||
size="lg"
|
||||
>
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-bg-neutral-tertiary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
{findingContext.providerType ? (
|
||||
<ProviderTypeIcon type={findingContext.providerType} size={22} />
|
||||
) : (
|
||||
<span className="text-text-neutral-secondary text-xs font-semibold">
|
||||
{findingContext.provider?.slice(0, 3).toUpperCase() ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-neutral-primary text-sm font-semibold">
|
||||
{findingContext.title}
|
||||
</p>
|
||||
{(findingContext.resource || findingContext.provider) && (
|
||||
<p className="text-text-neutral-secondary mt-1 text-xs">
|
||||
{[findingContext.resource, findingContext.provider]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-neutral-primary text-sm font-semibold">
|
||||
Status:
|
||||
</span>
|
||||
<FindingTriageStatusControl
|
||||
origin={FINDING_TRIAGE_ORIGIN.MODAL}
|
||||
triage={triage}
|
||||
value={selectedStatus}
|
||||
onValueChange={setSelectedStatus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{shouldShowMutelistInfo && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>{MUTELIST_INFO_COPY}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{shouldShowRemediatingInfo && (
|
||||
<Alert variant="info">
|
||||
<AlertDescription>{REMEDIATING_INFO_COPY}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<Alert variant="error">
|
||||
<AlertDescription>{submitError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
ref={noteTextareaRef}
|
||||
id="finding-triage-note"
|
||||
aria-label="Note text"
|
||||
value={note}
|
||||
maxLength={triage.maxNoteLength}
|
||||
disabled={!canSubmit}
|
||||
textareaSize="lg"
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
{triage.privacyCopy}
|
||||
</p>
|
||||
<p className="text-text-neutral-tertiary shrink-0 text-xs">
|
||||
{note.length}/{triage.maxNoteLength}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<span className="relative inline-flex">
|
||||
{isCloudOnly && (
|
||||
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
|
||||
<CloudFeatureBadgeLink href={triage.billingHref} />
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type={canSubmit ? "submit" : "button"}
|
||||
size="lg"
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{isSubmitting
|
||||
? "Saving..."
|
||||
: canSubmit || isCloudOnly
|
||||
? "Save changes"
|
||||
: "Unavailable"}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/components/shadcn/modal", () => ({
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
title?: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "./finding-triage-cells";
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("finding triage cells", () => {
|
||||
it("should open the Note modal from the note action with the current status preselected", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
})}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const addNoteButton = screen.getByRole("button", {
|
||||
name: "Add Triage Note",
|
||||
});
|
||||
expect(addNoteButton).toHaveTextContent("Add Triage Note");
|
||||
await user.click(addNoteButton);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
});
|
||||
|
||||
it("should not propagate table status clicks to the row", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={onRowClick}>
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
|
||||
// Then
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should render status picker with fixed width and colored options", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(statusControl);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Triage").parentElement).toHaveClass(
|
||||
"text-text-neutral-secondary",
|
||||
"text-[10px]",
|
||||
"whitespace-nowrap",
|
||||
);
|
||||
expect(statusControl.parentElement).toHaveClass("w-32");
|
||||
expect(statusControl).toHaveAttribute("data-size", "xs");
|
||||
expect(within(statusControl).getByText("Under Review")).toHaveClass(
|
||||
"text-text-warning-primary",
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Open" })).getByText("Open"),
|
||||
).toHaveClass("text-text-error-primary");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Under Review" })).getByText(
|
||||
"Under Review",
|
||||
),
|
||||
).toHaveClass("text-text-warning-primary");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Remediating" })).getByText(
|
||||
"Remediating",
|
||||
),
|
||||
).toHaveClass("text-bg-data-info");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "Risk Accepted" })).getByText(
|
||||
"Risk Accepted",
|
||||
),
|
||||
).toHaveClass("text-bg-pass");
|
||||
expect(
|
||||
within(screen.getByRole("option", { name: "False Positive" })).getByText(
|
||||
"False Positive",
|
||||
),
|
||||
).toHaveClass("text-text-neutral-secondary");
|
||||
});
|
||||
|
||||
it("should disable table status mutation when no update handler is wired", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
canEdit: true,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(statusControl);
|
||||
|
||||
// Then
|
||||
expect(statusControl).toBeDisabled();
|
||||
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not open an editable empty-note modal for an existing note without a loader", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: true })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const existingNoteButton = screen.getByRole("button", {
|
||||
name: "Open note",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(existingNoteButton);
|
||||
|
||||
// Then
|
||||
expect(existingNoteButton).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should load an existing note before opening the modal", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi.fn().mockResolvedValue({
|
||||
noteId: "note-1",
|
||||
noteBody: "Loaded existing note",
|
||||
});
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: true, notesCount: 1 })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageNoteLoadAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ triageId: "triage-1", notesCount: 1 }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toHaveValue(
|
||||
"Loaded existing note",
|
||||
);
|
||||
});
|
||||
|
||||
it("should open a disabled billing upsell modal for Cloud-only Add Triage Note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({
|
||||
canEdit: false,
|
||||
hasVisibleNote: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
|
||||
})}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Add Triage Note" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("Note text")).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save changes" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
|
||||
).toHaveAttribute("href", "https://prowler.com/pricing");
|
||||
});
|
||||
|
||||
it("should disable Add Triage Note when no update handler is wired", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: false, canEdit: true })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const addNoteButton = screen.getByRole("button", {
|
||||
name: "Add Triage Note",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(addNoteButton);
|
||||
|
||||
// Then
|
||||
expect(addNoteButton).toBeDisabled();
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expose a screen-reader error when an existing note cannot load", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageNoteLoadAction = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("load failed"));
|
||||
render(
|
||||
<FindingNoteActionItem
|
||||
triage={makeTriageSummary({ hasVisibleNote: true, notesCount: 1 })}
|
||||
findingContext={{ title: "S3 bucket allows public reads" }}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open note" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not load the existing note.",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Add Triage Note" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the optimistic table status while stale props are rendered during update", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveUpdate: () => void = () => {};
|
||||
const onTriageUpdateAction = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
}),
|
||||
);
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When: user selects a new status.
|
||||
await user.click(statusControl);
|
||||
await user.click(screen.getByRole("option", { name: "Under Review" }));
|
||||
|
||||
// Then: the optimistic status is visible immediately.
|
||||
expect(statusControl).toHaveTextContent("Under Review");
|
||||
|
||||
// When: the parent renders stale data while the request is still pending.
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then: the control must not flicker back to Open.
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Under Review");
|
||||
|
||||
// When: backend completes and fresh props arrive.
|
||||
resolveUpdate();
|
||||
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalled());
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Under Review");
|
||||
});
|
||||
|
||||
it("should not resurrect a stale optimistic status after the server later returns the previous status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn().mockResolvedValue(undefined);
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: user optimistically moves Open -> Under Review and it succeeds.
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Under Review" }));
|
||||
await waitFor(() => expect(onTriageUpdateAction).toHaveBeenCalled());
|
||||
|
||||
// And: fresh props converge on the optimistic status (server confirmed).
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: a later refetch legitimately returns the previous status again.
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then: the real server status wins; the stale optimistic value is gone.
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Open");
|
||||
});
|
||||
|
||||
it("should refresh the visible table status when triage props change", () => {
|
||||
// Given
|
||||
const { rerender } = render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Open");
|
||||
|
||||
// When
|
||||
rerender(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
})}
|
||||
onTriageUpdateAction={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toHaveTextContent("Remediating");
|
||||
});
|
||||
|
||||
it("should not submit when table status selection matches the current status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Open" }));
|
||||
|
||||
// Then
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should rollback table status and expose an error when update fails", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn().mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(statusControl);
|
||||
await user.click(screen.getByRole("option", { name: "Remediating" }));
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Could not update triage status.",
|
||||
);
|
||||
expect(statusControl).toHaveTextContent("Open");
|
||||
});
|
||||
|
||||
it("should not confirm when moving between Mutelist shortcut statuses", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
label: "Risk Accepted",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "False Positive" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog", { name: "Mute finding?" })).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
isMuted: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not confirm or mute again when an already muted finding enters a shortcut status", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onTriageUpdateAction = vi.fn();
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
isMuted: true,
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("combobox", { name: "Triage status" }));
|
||||
await user.click(screen.getByRole("option", { name: "Risk Accepted" }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog", { name: "Mute finding?" })).toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("should confirm before applying Mutelist shortcut statuses from the table", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveUpdate: () => void = () => {};
|
||||
const onTriageUpdateAction = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveUpdate = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<FindingTriageStatusCell
|
||||
triage={makeTriageSummary({
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
})}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusControl = screen.getByRole("combobox", {
|
||||
name: "Triage status",
|
||||
});
|
||||
|
||||
// When: user selects a Mutelist shortcut.
|
||||
await user.click(statusControl);
|
||||
await user.click(screen.getByRole("option", { name: "False Positive" }));
|
||||
|
||||
// Then: the user is warned before the server action handles muting.
|
||||
expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible();
|
||||
expect(onTriageUpdateAction).not.toHaveBeenCalled();
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Mute finding" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(onTriageUpdateAction).toHaveBeenCalledWith({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.FALSE_POSITIVE,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: false,
|
||||
}),
|
||||
);
|
||||
expect(statusControl).toHaveTextContent("False Positive");
|
||||
|
||||
resolveUpdate();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareText } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageLoadedNote,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import {
|
||||
FindingNoteModal,
|
||||
type FindingTriageContext,
|
||||
} from "./finding-note-modal";
|
||||
import {
|
||||
FindingTriageStatusControl,
|
||||
type FindingTriageUpdateHandler,
|
||||
} from "./finding-triage-status-control";
|
||||
|
||||
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
|
||||
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
|
||||
|
||||
const getDisabledCopy = ({
|
||||
triage,
|
||||
hasUpdateHandler,
|
||||
}: {
|
||||
triage: FindingTriageSummary;
|
||||
hasUpdateHandler: boolean;
|
||||
}): string | undefined => {
|
||||
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
|
||||
return CLOUD_ONLY_TOOLTIP_COPY;
|
||||
}
|
||||
|
||||
if (triage.canEdit && !hasUpdateHandler) {
|
||||
return EDITING_UNAVAILABLE_COPY;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getTriageDetailFromSummary = (
|
||||
triage: FindingTriageSummary,
|
||||
loadedNote?: FindingTriageLoadedNote,
|
||||
): FindingTriageDetail => ({
|
||||
...triage,
|
||||
noteId: loadedNote?.noteId ?? null,
|
||||
noteBody: loadedNote?.noteBody ?? "",
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
});
|
||||
|
||||
export function FindingTriageStatusCell({
|
||||
triage,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
triage?: FindingTriageSummary;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
}) {
|
||||
const [optimisticStatus, setOptimisticStatus] = useState<{
|
||||
token: string;
|
||||
findingId: string;
|
||||
triageId: string | null;
|
||||
previousStatus: FindingTriageStatus;
|
||||
status: FindingTriageStatus;
|
||||
} | null>(null);
|
||||
|
||||
// Retire the optimistic status once the server converges or the row changes, so a stale value can't resurface.
|
||||
if (
|
||||
optimisticStatus &&
|
||||
(!triage ||
|
||||
optimisticStatus.findingId !== triage.findingId ||
|
||||
optimisticStatus.triageId !== triage.triageId ||
|
||||
triage.status === optimisticStatus.status)
|
||||
) {
|
||||
setOptimisticStatus(null);
|
||||
}
|
||||
|
||||
const optimisticMatchesCurrentTriage =
|
||||
Boolean(triage) &&
|
||||
optimisticStatus?.findingId === triage?.findingId &&
|
||||
optimisticStatus?.triageId === triage?.triageId &&
|
||||
optimisticStatus?.previousStatus === triage?.status &&
|
||||
optimisticStatus?.status !== triage?.status;
|
||||
|
||||
if (!triage) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
const displayedTriage =
|
||||
optimisticMatchesCurrentTriage && optimisticStatus
|
||||
? {
|
||||
...triage,
|
||||
status: optimisticStatus.status,
|
||||
label: FINDING_TRIAGE_STATUS_LABELS[optimisticStatus.status],
|
||||
}
|
||||
: triage;
|
||||
|
||||
const handleTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
const optimisticToken = input.status ? crypto.randomUUID() : null;
|
||||
|
||||
if (input.status && optimisticToken) {
|
||||
setOptimisticStatus({
|
||||
token: optimisticToken,
|
||||
findingId: input.findingId,
|
||||
triageId: input.triageId,
|
||||
previousStatus: input.previousStatus ?? triage.status,
|
||||
status: input.status,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await onTriageUpdateAction?.(input);
|
||||
} catch (error) {
|
||||
setOptimisticStatus((current) =>
|
||||
current?.token === optimisticToken ? null : current,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const control = (
|
||||
<div
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<FindingTriageStatusControl
|
||||
key={displayedTriage.findingId}
|
||||
origin={FINDING_TRIAGE_ORIGIN.TABLE}
|
||||
triage={displayedTriage}
|
||||
onTriageUpdateAction={
|
||||
onTriageUpdateAction ? handleTriageUpdate : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const disabledCopy = getDisabledCopy({
|
||||
triage,
|
||||
hasUpdateHandler: Boolean(onTriageUpdateAction),
|
||||
});
|
||||
if (!disabledCopy) {
|
||||
return control;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Block-level so the tooltip wrapper doesn't add inline baseline spacing
|
||||
that would push the compact "Triage" label below the sibling columns. */}
|
||||
<span className="flex">{control}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disabledCopy}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingNoteActionItem({
|
||||
triage,
|
||||
findingContext = { title: "Finding" },
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
triage?: FindingTriageSummary;
|
||||
findingContext?: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}) {
|
||||
if (!triage) {
|
||||
return <span className="text-text-neutral-tertiary text-sm">-</span>;
|
||||
}
|
||||
|
||||
const triageIdentity = `${triage.findingId}:${triage.triageId ?? "virtual"}`;
|
||||
|
||||
return (
|
||||
<FindingNoteActionItemContent
|
||||
key={triageIdentity}
|
||||
triage={triage}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={onTriageNoteLoadAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingNoteActionItemContent({
|
||||
triage,
|
||||
findingContext,
|
||||
onTriageUpdateAction,
|
||||
onTriageNoteLoadAction,
|
||||
}: {
|
||||
triage: FindingTriageSummary;
|
||||
findingContext: FindingTriageContext;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
onTriageNoteLoadAction?: (
|
||||
triage: FindingTriageSummary,
|
||||
) => Promise<FindingTriageLoadedNote>;
|
||||
}) {
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [loadedNote, setLoadedNote] = useState<FindingTriageLoadedNote>();
|
||||
const [isLoadingNote, setIsLoadingNote] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const hasUpdateHandler = Boolean(onTriageUpdateAction);
|
||||
const isCloudOnly =
|
||||
triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY;
|
||||
const canOpenNewNoteModal =
|
||||
!triage.hasVisibleNote &&
|
||||
((triage.canEdit && hasUpdateHandler) || isCloudOnly);
|
||||
const canOpenExistingNoteModal =
|
||||
triage.hasVisibleNote &&
|
||||
triage.canEdit &&
|
||||
hasUpdateHandler &&
|
||||
Boolean(onTriageNoteLoadAction) &&
|
||||
!isLoadingNote;
|
||||
const disabledCopy = getDisabledCopy({ triage, hasUpdateHandler });
|
||||
const canOpenNoteModal = triage.hasVisibleNote
|
||||
? canOpenExistingNoteModal
|
||||
: canOpenNewNoteModal;
|
||||
const label = isLoadingNote
|
||||
? "Loading note..."
|
||||
: triage.hasVisibleNote
|
||||
? "Open note"
|
||||
: "Add Triage Note";
|
||||
|
||||
const handleNoteSelect = async () => {
|
||||
if (!canOpenNoteModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!triage.hasVisibleNote) {
|
||||
setIsNoteModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onTriageNoteLoadAction) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadError(null);
|
||||
setIsLoadingNote(true);
|
||||
|
||||
try {
|
||||
const note = await onTriageNoteLoadAction(triage);
|
||||
setLoadedNote(note);
|
||||
setIsNoteModalOpen(true);
|
||||
} catch {
|
||||
setLoadError("Could not load the existing note.");
|
||||
} finally {
|
||||
setIsLoadingNote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const noteModal = isNoteModalOpen ? (
|
||||
<FindingNoteModal
|
||||
open={isNoteModalOpen}
|
||||
onOpenChange={setIsNoteModalOpen}
|
||||
triage={getTriageDetailFromSummary(triage, loadedNote)}
|
||||
findingContext={findingContext}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionDropdownItem
|
||||
icon={<MessageSquareText className="size-5" />}
|
||||
label={label}
|
||||
disabled={!canOpenNoteModal}
|
||||
title={
|
||||
triage.hasVisibleNote && !canOpenExistingNoteModal
|
||||
? "Existing note cannot be loaded from the table."
|
||||
: disabledCopy
|
||||
}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
void handleNoteSelect();
|
||||
}}
|
||||
/>
|
||||
{loadError && (
|
||||
<span className="sr-only" role="alert">
|
||||
{loadError}
|
||||
</span>
|
||||
)}
|
||||
{noteModal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { InfoField } from "@/components/shadcn/info-field/info-field";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_ORIGIN,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
type FindingTriageManualStatus,
|
||||
type FindingTriageStatus,
|
||||
type FindingTriageSummary,
|
||||
isManualStatus,
|
||||
isMutelistShortcutStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export type FindingTriageUpdateHandler = (
|
||||
input: UpdateFindingTriageInput,
|
||||
) => void | Promise<void>;
|
||||
|
||||
type TriageStatusPickerSize = NonNullable<
|
||||
ComponentProps<typeof SelectTrigger>["size"]
|
||||
>;
|
||||
|
||||
const TRIAGE_STATUS_TEXT_CLASS = {
|
||||
open: "text-text-error-primary",
|
||||
under_review: "text-text-warning-primary",
|
||||
remediating: "text-bg-data-info",
|
||||
resolved: "text-bg-pass",
|
||||
risk_accepted: "text-bg-pass",
|
||||
false_positive: "text-text-neutral-secondary",
|
||||
reopened: "text-text-error-primary",
|
||||
} as const satisfies Record<FindingTriageStatus, string>;
|
||||
|
||||
const MUTELIST_CONFIRMATION_TITLE = "Mute finding?";
|
||||
const MUTELIST_CONFIRMATION_COPY =
|
||||
"Changing to this triage status will mute the finding.";
|
||||
|
||||
function TriageStatusPicker({
|
||||
disabled,
|
||||
size = "sm",
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
size?: TriageStatusPickerSize;
|
||||
value: FindingTriageStatus;
|
||||
onValueChange: (status: FindingTriageManualStatus) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextStatus) => {
|
||||
if (isManualStatus(nextStatus as FindingTriageStatus)) {
|
||||
onValueChange(nextStatus as FindingTriageManualStatus);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="Triage status"
|
||||
disabled={disabled}
|
||||
size={size}
|
||||
iconSize="sm"
|
||||
>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[value])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[value]}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FINDING_TRIAGE_MANUAL_STATUS_VALUES.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
<span className={cn("truncate", TRIAGE_STATUS_TEXT_CLASS[status])}>
|
||||
{FINDING_TRIAGE_STATUS_LABELS[status]}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type TableStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.TABLE;
|
||||
triage: FindingTriageSummary;
|
||||
onTriageUpdateAction?: FindingTriageUpdateHandler;
|
||||
};
|
||||
|
||||
type ModalStatusControlProps = {
|
||||
origin: typeof FINDING_TRIAGE_ORIGIN.MODAL;
|
||||
triage: FindingTriageSummary;
|
||||
value: FindingTriageStatus;
|
||||
onValueChange: (status: FindingTriageManualStatus) => void;
|
||||
};
|
||||
|
||||
type FindingTriageStatusControlProps =
|
||||
| TableStatusControlProps
|
||||
| ModalStatusControlProps;
|
||||
|
||||
export function FindingTriageStatusControl(
|
||||
props: FindingTriageStatusControlProps,
|
||||
) {
|
||||
const [tableUpdateError, setTableUpdateError] = useState<string | null>(null);
|
||||
const [isTableUpdating, setIsTableUpdating] = useState(false);
|
||||
const [pendingShortcutStatus, setPendingShortcutStatus] =
|
||||
useState<FindingTriageManualStatus | null>(null);
|
||||
const triage = props.triage;
|
||||
|
||||
if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) {
|
||||
return (
|
||||
<TriageStatusPicker
|
||||
disabled={!triage.canEdit}
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canMutateFromTable =
|
||||
triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating;
|
||||
|
||||
const applyTableStatus = async (status: FindingTriageManualStatus) => {
|
||||
if (!props.onTriageUpdateAction || status === triage.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTableUpdateError(null);
|
||||
setIsTableUpdating(true);
|
||||
|
||||
try {
|
||||
await props.onTriageUpdateAction({
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
status,
|
||||
previousStatus: triage.status,
|
||||
isMuted: triage.isMuted,
|
||||
});
|
||||
} catch {
|
||||
setTableUpdateError("Could not update triage status.");
|
||||
} finally {
|
||||
setIsTableUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldConfirmMute = (status: FindingTriageManualStatus) =>
|
||||
!triage.isMuted &&
|
||||
isMutelistShortcutStatus(status) &&
|
||||
!isMutelistShortcutStatus(triage.status);
|
||||
|
||||
const handleTableValueChange = (status: FindingTriageManualStatus) => {
|
||||
if (!props.onTriageUpdateAction || status === triage.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldConfirmMute(status)) {
|
||||
setPendingShortcutStatus(status);
|
||||
return;
|
||||
}
|
||||
|
||||
void applyTableStatus(status);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoField label="Triage" variant="compact">
|
||||
<div className="w-32">
|
||||
<TriageStatusPicker
|
||||
disabled={!canMutateFromTable}
|
||||
size="xs"
|
||||
value={triage.status}
|
||||
onValueChange={handleTableValueChange}
|
||||
/>
|
||||
</div>
|
||||
</InfoField>
|
||||
{tableUpdateError && (
|
||||
<span className="sr-only" role="alert">
|
||||
{tableUpdateError}
|
||||
</span>
|
||||
)}
|
||||
<Modal
|
||||
open={pendingShortcutStatus !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingShortcutStatus(null);
|
||||
}
|
||||
}}
|
||||
title={MUTELIST_CONFIRMATION_TITLE}
|
||||
description={MUTELIST_CONFIRMATION_COPY}
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPendingShortcutStatus(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const status = pendingShortcutStatus;
|
||||
setPendingShortcutStatus(null);
|
||||
if (status) {
|
||||
void applyTableStatus(status);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Mute finding
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageDetail,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { buildFindingTriageUpdateInput } from "./finding-triage-submit";
|
||||
|
||||
function makeTriageDetail(
|
||||
overrides?: Partial<FindingTriageDetail>,
|
||||
): FindingTriageDetail {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: true,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
noteId: "note-1",
|
||||
noteBody: "Existing investigation note",
|
||||
maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildFindingTriageUpdateInput", () => {
|
||||
it("should return null when neither status nor note changed", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: "Existing investigation note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should update an existing note through noteId without duplicating note creation", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: " Updated existing note ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "Updated existing note",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send status plus note only when creating the first note", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail({
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
noteBody: "",
|
||||
hasVisibleNote: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: " First note ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: null,
|
||||
notesCount: 0,
|
||||
noteId: null,
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
note: "First note",
|
||||
});
|
||||
});
|
||||
|
||||
it("should send only status when status changes and an existing note is unchanged", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
noteBody: "Existing investigation note",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
status: FINDING_TRIAGE_STATUS.RISK_ACCEPTED,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an empty note when an existing note is cleared", () => {
|
||||
// Given
|
||||
const triage = makeTriageDetail();
|
||||
|
||||
// When
|
||||
const result = buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
noteBody: " ",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 1,
|
||||
noteId: "note-1",
|
||||
isMuted: false,
|
||||
note: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
type FindingTriageDetail,
|
||||
type FindingTriageManualStatus,
|
||||
type FindingTriageStatus,
|
||||
isManualStatus,
|
||||
type UpdateFindingTriageInput,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
export interface BuildFindingTriageUpdateInputParams {
|
||||
triage: FindingTriageDetail;
|
||||
selectedStatus: FindingTriageStatus;
|
||||
noteBody: string;
|
||||
}
|
||||
|
||||
export function buildFindingTriageUpdateInput({
|
||||
triage,
|
||||
selectedStatus,
|
||||
noteBody,
|
||||
}: BuildFindingTriageUpdateInputParams): UpdateFindingTriageInput | null {
|
||||
const trimmedNote = noteBody.trim();
|
||||
const statusChanged = selectedStatus !== triage.status;
|
||||
const shouldCreateFirstNote =
|
||||
triage.notesCount === 0 && trimmedNote.length > 0;
|
||||
const shouldUpdateExistingNote =
|
||||
triage.notesCount > 0 &&
|
||||
triage.noteId !== null &&
|
||||
trimmedNote !== triage.noteBody;
|
||||
const shouldIncludeStatus =
|
||||
isManualStatus(selectedStatus) && (statusChanged || shouldCreateFirstNote);
|
||||
|
||||
if (
|
||||
!shouldIncludeStatus &&
|
||||
!shouldCreateFirstNote &&
|
||||
!shouldUpdateExistingNote
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
noteId: triage.noteId,
|
||||
isMuted: triage.isMuted,
|
||||
...(shouldIncludeStatus
|
||||
? {
|
||||
status: selectedStatus as FindingTriageManualStatus,
|
||||
previousStatus: triage.status,
|
||||
}
|
||||
: {}),
|
||||
...(shouldCreateFirstNote || shouldUpdateExistingNote
|
||||
? { note: trimmedNote }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
Table,
|
||||
@@ -73,6 +77,7 @@ export function FindingsGroupDrillDown({
|
||||
handleMuteComplete,
|
||||
handleRowSelectionChange,
|
||||
resolveSelectedFindingIds,
|
||||
updateTriageOptimistically,
|
||||
} = useFindingGroupResourceState({
|
||||
group,
|
||||
filters,
|
||||
@@ -82,6 +87,10 @@ export function FindingsGroupDrillDown({
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle: group.checkTitle,
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -249,6 +258,7 @@ export function FindingsGroupDrillDown({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./column-finding-resources";
|
||||
export * from "./column-standalone-findings";
|
||||
export * from "./data-table-row-actions";
|
||||
export * from "./finding-detail-drawer";
|
||||
export * from "./finding-triage-cells";
|
||||
export * from "./findings-group-drill-down";
|
||||
export * from "./findings-group-table";
|
||||
export * from "./findings-selection-context";
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("inline resource container", () => {
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const filePath = path.join(currentDir, "inline-resource-container.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("uses the shared finding-group resource state hook", () => {
|
||||
expect(source).toContain("useFindingGroupResourceState");
|
||||
expect(source).not.toContain("useInfiniteResources");
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,16 @@ import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronsDown } from "lucide-react";
|
||||
import { useImperativeHandle, useRef } from "react";
|
||||
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state";
|
||||
import { useScrollHint } from "@/hooks/use-scroll-hint";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
@@ -52,6 +57,22 @@ interface InlineResourceContainerProps {
|
||||
|
||||
/** Max skeleton rows that fit in the 440px scroll container */
|
||||
const MAX_SKELETON_ROWS = 7;
|
||||
const ACTIONS_COLUMN_ID = "actions";
|
||||
const COMPACT_LABELED_COLUMN_IDS = new Set([
|
||||
"service",
|
||||
"region",
|
||||
"lastSeen",
|
||||
"failingFor",
|
||||
"triage",
|
||||
]);
|
||||
const STICKY_RESOURCE_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary";
|
||||
|
||||
const getResourceCellClassName = (columnId: string) =>
|
||||
cn(
|
||||
COMPACT_LABELED_COLUMN_IDS.has(columnId) && "align-top",
|
||||
columnId === ACTIONS_COLUMN_ID && STICKY_RESOURCE_ACTION_CELL_CLASS,
|
||||
);
|
||||
|
||||
function ResourceSkeletonRow({
|
||||
isEmptyStateSized = false,
|
||||
@@ -111,9 +132,17 @@ function ResourceSkeletonRow({
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-4.5 w-16 rounded" />
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
{/* Triage */}
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell
|
||||
className={cn(cellClassName, STICKY_RESOURCE_ACTION_CELL_CLASS)}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -160,6 +189,7 @@ export function InlineResourceContainer({
|
||||
handleMuteComplete,
|
||||
handleRowSelectionChange,
|
||||
resolveSelectedFindingIds,
|
||||
updateTriageOptimistically,
|
||||
} = useFindingGroupResourceState({
|
||||
group,
|
||||
filters,
|
||||
@@ -186,6 +216,10 @@ export function InlineResourceContainer({
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection,
|
||||
selectableRowCount,
|
||||
findingTitle: group.checkTitle,
|
||||
onTriageUpdateAction: (input) =>
|
||||
updateTriageOptimistically(input, updateFindingTriage),
|
||||
onTriageNoteLoadAction: loadLatestFindingTriageNote,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -214,7 +248,7 @@ export function InlineResourceContainer({
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
<td colSpan={columnCount} className="p-0">
|
||||
<td colSpan={columnCount} className="max-w-0 p-0">
|
||||
<AnimatePresence initial>
|
||||
<motion.div
|
||||
// Onboarding anchor: the "Review the affected resources" tour step.
|
||||
@@ -228,10 +262,10 @@ export function InlineResourceContainer({
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={combinedScrollRef}
|
||||
className="max-h-[440px] overflow-y-auto pl-6"
|
||||
className="minimal-scrollbar max-h-[440px] overflow-auto pl-6"
|
||||
>
|
||||
{/* Resource rows or skeleton placeholder */}
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<table className="-mt-2.5 w-max min-w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
@@ -245,7 +279,7 @@ export function InlineResourceContainer({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="cursor-pointer"
|
||||
className="group cursor-pointer"
|
||||
onClick={(e) => {
|
||||
// Don't open drawer if clicking interactive elements
|
||||
// (links, buttons, checkboxes, dropdown items)
|
||||
@@ -260,7 +294,12 @@ export function InlineResourceContainer({
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={getResourceCellClassName(
|
||||
cell.column.id,
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
@@ -336,6 +375,7 @@ export function InlineResourceContainer({
|
||||
onNavigatePrev={drawer.navigatePrev}
|
||||
onNavigateNext={drawer.navigateNext}
|
||||
onMuteComplete={handleDrawerMuteComplete}
|
||||
onTriageUpdate={drawer.patchTriageUpdate}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>
|
||||
);
|
||||
|
||||
+268
-65
@@ -22,6 +22,8 @@ const {
|
||||
mockClipboardWriteText,
|
||||
mockSearchParamsState,
|
||||
mockNotificationIndicator,
|
||||
mockUpdateFindingTriage,
|
||||
mockLoadLatestFindingTriageNote,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetComplianceIcon: vi.fn((_: string) => null as string | null),
|
||||
mockGetCompliancesOverview: vi.fn(),
|
||||
@@ -29,6 +31,8 @@ const {
|
||||
mockClipboardWriteText: vi.fn(),
|
||||
mockSearchParamsState: { value: "" },
|
||||
mockNotificationIndicator: vi.fn(),
|
||||
mockUpdateFindingTriage: vi.fn(),
|
||||
mockLoadLatestFindingTriageNote: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -255,6 +259,11 @@ vi.mock("@/actions/compliances", () => ({
|
||||
getCompliancesOverview: mockGetCompliancesOverview,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
updateFindingTriage: mockUpdateFindingTriage,
|
||||
loadLatestFindingTriageNote: mockLoadLatestFindingTriageNote,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
getComplianceIcon: mockGetComplianceIcon,
|
||||
}));
|
||||
@@ -309,8 +318,12 @@ vi.mock("@/components/ui/table", () => ({
|
||||
TableBody: ({ children }: { children: ReactNode }) => (
|
||||
<tbody>{children}</tbody>
|
||||
),
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableCell: ({ children, ...props }: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td {...props}>{children}</td>
|
||||
),
|
||||
TableHead: ({ children, ...props }: HTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th {...props}>{children}</th>
|
||||
),
|
||||
TableHeader: ({ children }: { children: ReactNode }) => (
|
||||
<thead>{children}</thead>
|
||||
),
|
||||
@@ -360,6 +373,96 @@ vi.mock("../notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" } as const,
|
||||
}));
|
||||
|
||||
vi.mock("../finding-triage-cells", () => ({
|
||||
FindingNoteActionItem: ({
|
||||
triage,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
triage?: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
label: string;
|
||||
isMuted: boolean;
|
||||
};
|
||||
onTriageUpdateAction?: (input: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
previousStatus: string;
|
||||
isMuted: boolean;
|
||||
note: string;
|
||||
}) => Promise<void>;
|
||||
}) =>
|
||||
triage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onTriageUpdateAction?.({
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
status: "remediating",
|
||||
previousStatus: triage.status,
|
||||
isMuted: triage.isMuted,
|
||||
note: "Investigating",
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Triage Note
|
||||
</button>
|
||||
) : null,
|
||||
FindingTriageStatusCell: ({
|
||||
triage,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
triage?: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
label: string;
|
||||
isMuted: boolean;
|
||||
};
|
||||
onTriageUpdateAction?: (input: {
|
||||
findingId: string;
|
||||
findingUid: string;
|
||||
triageId: string | null;
|
||||
notesCount: number;
|
||||
status: string;
|
||||
previousStatus: string;
|
||||
isMuted: boolean;
|
||||
}) => Promise<void>;
|
||||
}) =>
|
||||
triage ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Triage status"
|
||||
onClick={() =>
|
||||
onTriageUpdateAction?.({
|
||||
findingId: triage.findingId,
|
||||
findingUid: triage.findingUid,
|
||||
triageId: triage.triageId,
|
||||
notesCount: triage.notesCount,
|
||||
status: "remediating",
|
||||
previousStatus: triage.status,
|
||||
isMuted: triage.isMuted,
|
||||
})
|
||||
}
|
||||
>
|
||||
{triage.label}
|
||||
</button>
|
||||
) : (
|
||||
<span>-</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-skeleton", () => ({
|
||||
ResourceDetailSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
@@ -374,6 +477,10 @@ vi.mock("../../muted", () => ({
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -404,6 +511,24 @@ const mockCheckMeta: CheckMeta = {
|
||||
additionalUrls: [],
|
||||
};
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "prowler-finding-uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const mockFinding: ResourceDrawerFinding = {
|
||||
id: "finding-1",
|
||||
uid: "uid-1",
|
||||
@@ -479,6 +604,147 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
expect(srOnlyLabel).toHaveTextContent("View Resource");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
it("should render Triage and Add Triage Note for other findings rows", () => {
|
||||
// Given
|
||||
const otherFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
}),
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[otherFinding]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const row = screen.getByText("EC2 Check").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Triage")).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", {
|
||||
name: "Triage status",
|
||||
}),
|
||||
).toHaveTextContent("Remediating");
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", {
|
||||
name: "Add Triage Note",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Mute" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("button", { name: "Send to Jira" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should keep the other findings actions cell sticky on the right edge", () => {
|
||||
// Given
|
||||
const otherFinding: ResourceDrawerFinding = {
|
||||
...mockFinding,
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
checkId: "ec2_check",
|
||||
checkTitle: "EC2 Check",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
}),
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[otherFinding]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const row = screen.getByText("EC2 Check").closest("tr");
|
||||
expect(row).not.toBeNull();
|
||||
const actionsCell = within(row as HTMLElement)
|
||||
.getByRole("button", { name: "Send to Jira" })
|
||||
.closest("td");
|
||||
|
||||
// Then
|
||||
expect(actionsCell).toHaveClass("sticky");
|
||||
expect(actionsCell).toHaveClass("right-0");
|
||||
expect(actionsCell).toHaveClass("z-20");
|
||||
expect(actionsCell).toHaveClass("bg-bg-neutral-secondary");
|
||||
expect(actionsCell).toHaveClass("before:bg-gradient-to-r");
|
||||
expect(actionsCell).toHaveClass("before:to-bg-neutral-secondary");
|
||||
});
|
||||
|
||||
it("should update simple drawer triage without using the mute refresh path", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
const onMuteComplete = vi.fn();
|
||||
mockUpdateFindingTriage.mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
triage: makeTriageSummary(),
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={onMuteComplete}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Triage Note" }));
|
||||
|
||||
// Then
|
||||
expect(mockUpdateFindingTriage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
findingId: "finding-1",
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
note: "Investigating",
|
||||
}),
|
||||
);
|
||||
expect(onMuteComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const mockResourceRow: FindingResourceRow = {
|
||||
id: "row-1",
|
||||
rowType: "resource",
|
||||
@@ -1002,69 +1268,6 @@ describe("ResourceDetailDrawerContent — Risk section styling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: Compliance icon styling should match master
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance icon styling", () => {
|
||||
it("should render framework icons inside the same white chip used in master", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockImplementation((framework: string) =>
|
||||
framework === "CIS-1.4" ? "/cis.svg" : null,
|
||||
);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const icon = screen.getByRole("img", { name: "CIS-1.4" });
|
||||
const chip = icon.closest("div");
|
||||
|
||||
// Then
|
||||
expect(chip).toHaveClass("bg-white");
|
||||
expect(chip).toHaveClass("border-gray-300");
|
||||
});
|
||||
|
||||
it("should render framework fallback pills with the same master styling", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockReturnValue(null);
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const chip = screen.getByText("PCI-DSS");
|
||||
|
||||
// Then
|
||||
expect(chip).toHaveClass("bg-white");
|
||||
expect(chip).toHaveClass("border-gray-300");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
||||
+74
-5
@@ -16,7 +16,11 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
type ResourceDrawerFinding,
|
||||
updateFindingTriage,
|
||||
} from "@/actions/findings";
|
||||
import { MarkdownContainer } from "@/components/findings/markdown-container";
|
||||
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
|
||||
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
|
||||
@@ -69,17 +73,26 @@ import {
|
||||
} from "@/components/ui/table/status-finding-badge";
|
||||
import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { formatDuration } from "@/lib/date-utils";
|
||||
import { shouldRefreshAfterTriageUpdate } from "@/lib/finding-triage";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import { Muted } from "../../muted";
|
||||
import { DeltaIndicator } from "../delta-indicator";
|
||||
import {
|
||||
FindingNoteActionItem,
|
||||
FindingTriageStatusCell,
|
||||
} from "../finding-triage-cells";
|
||||
import { DeltaValues, NotificationIndicator } from "../notification-indicator";
|
||||
import { ResourceDetailSkeleton } from "./resource-detail-skeleton";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
const OTHER_FINDINGS_ACTION_CELL_CLASS =
|
||||
"sticky right-0 z-20 min-w-12 last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary";
|
||||
|
||||
/** Strip markdown code fences (```lang ... ```) so CodeSnippet shows clean code. */
|
||||
function stripCodeFences(code: string): string {
|
||||
return code
|
||||
@@ -326,6 +339,7 @@ interface ResourceDetailDrawerContentProps {
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawerContent({
|
||||
@@ -341,6 +355,7 @@ export function ResourceDetailDrawerContent({
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
onTriageUpdate,
|
||||
}: ResourceDetailDrawerContentProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
@@ -410,6 +425,7 @@ export function ResourceDetailDrawerContent({
|
||||
const resourceUid = currentResource?.resourceUid ?? f?.resourceUid;
|
||||
const resourceService = currentResource?.service ?? f?.resourceService;
|
||||
const resourceRegion = currentResource?.region ?? f?.resourceRegion;
|
||||
const findingTriage = f?.triage ?? currentResource?.triage;
|
||||
const resourceRegionLabel = resourceRegion || "-";
|
||||
const firstSeenAt = currentResource?.firstSeenAt ?? f?.firstSeenAt ?? null;
|
||||
const lastSeenAt = currentResource?.lastSeenAt ?? f?.updatedAt ?? null;
|
||||
@@ -455,6 +471,16 @@ export function ResourceDetailDrawerContent({
|
||||
currentResource?.statusExtended || f?.statusExtended;
|
||||
const showOverviewStatusExtended = Boolean(overviewStatusExtended);
|
||||
|
||||
const handleDrawerTriageUpdate = async (input: UpdateFindingTriageInput) => {
|
||||
await updateFindingTriage(input);
|
||||
if (shouldRefreshAfterTriageUpdate(input)) {
|
||||
onMuteComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
onTriageUpdate?.(input);
|
||||
};
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
if (!complianceScanId || resolvingFramework) {
|
||||
return;
|
||||
@@ -800,6 +826,19 @@ export function ResourceDetailDrawerContent({
|
||||
variant="bordered"
|
||||
ariaLabel="Resource actions"
|
||||
>
|
||||
{findingTriage && (
|
||||
<FindingNoteActionItem
|
||||
triage={findingTriage}
|
||||
findingContext={{
|
||||
title: checkMeta.checkTitle,
|
||||
resource: resourceName,
|
||||
provider: providerAlias,
|
||||
providerType,
|
||||
}}
|
||||
onTriageUpdateAction={handleDrawerTriageUpdate}
|
||||
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
f.isMuted ? (
|
||||
@@ -1194,6 +1233,11 @@ export function ResourceDetailDrawerContent({
|
||||
Time
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<span className="text-text-neutral-secondary text-sm font-medium">
|
||||
Triage
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -1213,11 +1257,12 @@ export function ResourceDetailDrawerContent({
|
||||
new Set(prev).add(finding.id),
|
||||
)
|
||||
}
|
||||
onTriageUpdateAction={handleDrawerTriageUpdate}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-16 text-center">
|
||||
<TableCell colSpan={7} className="h-16 text-center">
|
||||
<span className="text-text-neutral-tertiary text-sm">
|
||||
{showSyntheticResourceHint
|
||||
? "No other findings are available for this IaC resource."
|
||||
@@ -1403,7 +1448,10 @@ function OtherFindingsNavigationSkeletonRows() {
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-20 rounded" />
|
||||
</TableCell>
|
||||
<TableCell className="w-10">
|
||||
<TableCell>
|
||||
<Skeleton className="h-8 w-20 rounded-lg" />
|
||||
</TableCell>
|
||||
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1495,10 +1543,12 @@ function OtherFindingRow({
|
||||
finding,
|
||||
isOptimisticallyMuted,
|
||||
onMuted,
|
||||
onTriageUpdateAction,
|
||||
}: {
|
||||
finding: ResourceDrawerFinding;
|
||||
isOptimisticallyMuted: boolean;
|
||||
onMuted: () => void;
|
||||
onTriageUpdateAction: (input: UpdateFindingTriageInput) => Promise<void>;
|
||||
}) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
@@ -1526,7 +1576,7 @@ function OtherFindingRow({
|
||||
findingTitle={finding.checkTitle}
|
||||
/>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
className="group cursor-pointer"
|
||||
onClick={() => window.open(findingUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
<TableCell className="w-14">
|
||||
@@ -1559,9 +1609,28 @@ function OtherFindingRow({
|
||||
<TableCell>
|
||||
<DateWithTime dateTime={finding.updatedAt} />
|
||||
</TableCell>
|
||||
<TableCell className="w-10">
|
||||
<TableCell>
|
||||
<FindingTriageStatusCell
|
||||
triage={finding.triage}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={OTHER_FINDINGS_ACTION_CELL_CLASS}>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ActionDropdown ariaLabel="Finding actions">
|
||||
{finding.triage && (
|
||||
<FindingNoteActionItem
|
||||
triage={finding.triage}
|
||||
findingContext={{
|
||||
title: finding.checkTitle,
|
||||
resource: finding.resourceName,
|
||||
provider: finding.providerAlias,
|
||||
providerType: finding.providerType,
|
||||
}}
|
||||
onTriageUpdateAction={onTriageUpdateAction}
|
||||
onTriageNoteLoadAction={loadLatestFindingTriageNote}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
isMuted ? (
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DrawerTitle,
|
||||
} from "@/components/shadcn";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
@@ -31,6 +32,7 @@ interface ResourceDetailDrawerProps {
|
||||
onNavigatePrev: () => void;
|
||||
onNavigateNext: () => void;
|
||||
onMuteComplete: () => void;
|
||||
onTriageUpdate?: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
export function ResourceDetailDrawer({
|
||||
@@ -48,6 +50,7 @@ export function ResourceDetailDrawer({
|
||||
onNavigatePrev,
|
||||
onNavigateNext,
|
||||
onMuteComplete,
|
||||
onTriageUpdate,
|
||||
}: ResourceDetailDrawerProps) {
|
||||
return (
|
||||
<Drawer direction="right" open={open} onOpenChange={onOpenChange}>
|
||||
@@ -76,6 +79,7 @@ export function ResourceDetailDrawer({
|
||||
onNavigatePrev={onNavigatePrev}
|
||||
onNavigateNext={onNavigateNext}
|
||||
onMuteComplete={onMuteComplete}
|
||||
onTriageUpdate={onTriageUpdate}
|
||||
/>
|
||||
)}
|
||||
</DrawerContent>
|
||||
|
||||
+100
@@ -31,6 +31,10 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
} from "@/types/findings-triage";
|
||||
|
||||
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
|
||||
@@ -106,6 +110,24 @@ function makeDrawerFinding(
|
||||
};
|
||||
}
|
||||
|
||||
function makeTriageSummary(
|
||||
overrides?: Partial<FindingTriageSummary>,
|
||||
): FindingTriageSummary {
|
||||
return {
|
||||
findingId: "finding-1",
|
||||
findingUid: "uid-1",
|
||||
triageId: "triage-1",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
label: "Under Review",
|
||||
hasVisibleNote: false,
|
||||
isMuted: false,
|
||||
canEdit: true,
|
||||
billingHref: "https://prowler.com/pricing",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: AbortController cleanup on unmount
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -811,4 +833,82 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
"finding-4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should patch current and other finding triage locally without refetching", async () => {
|
||||
const resources = [makeResource()];
|
||||
|
||||
// Given
|
||||
getFindingByIdMock.mockResolvedValue({ data: ["detail"] });
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({
|
||||
data: ["resource"],
|
||||
});
|
||||
adaptFindingsByResourceResponseMock.mockImplementation(
|
||||
(response: { data: string[] }) =>
|
||||
response.data[0] === "detail"
|
||||
? [
|
||||
makeDrawerFinding({
|
||||
id: "finding-1",
|
||||
triage: makeTriageSummary(),
|
||||
}),
|
||||
]
|
||||
: [
|
||||
makeDrawerFinding({
|
||||
id: "finding-2",
|
||||
uid: "uid-2",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
status: FINDING_TRIAGE_STATUS.OPEN,
|
||||
label: "Open",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const findingFetchCount = getFindingByIdMock.mock.calls.length;
|
||||
const resourceFetchCount =
|
||||
getLatestFindingsByResourceUidMock.mock.calls.length;
|
||||
|
||||
// When
|
||||
act(() => {
|
||||
result.current.patchTriageUpdate({
|
||||
findingId: "finding-2",
|
||||
findingUid: "uid-2",
|
||||
triageId: "triage-2",
|
||||
notesCount: 0,
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
previousStatus: FINDING_TRIAGE_STATUS.OPEN,
|
||||
isMuted: false,
|
||||
note: "Investigating",
|
||||
});
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.current.otherFindings[0]?.triage).toEqual(
|
||||
expect.objectContaining({
|
||||
status: FINDING_TRIAGE_STATUS.REMEDIATING,
|
||||
label: "Remediating",
|
||||
hasVisibleNote: true,
|
||||
notesCount: 1,
|
||||
}),
|
||||
);
|
||||
expect(result.current.currentFinding?.triage?.status).toBe(
|
||||
FINDING_TRIAGE_STATUS.UNDER_REVIEW,
|
||||
);
|
||||
expect(getFindingByIdMock).toHaveBeenCalledTimes(findingFetchCount);
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(
|
||||
resourceFetchCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
import {
|
||||
applyOptimisticTriageSummaryUpdate,
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
// React doesn't batch away the skeleton frame when switching resources.
|
||||
@@ -67,6 +73,8 @@ interface UseResourceDetailDrawerReturn {
|
||||
navigateNext: () => void;
|
||||
/** Clear cache for current resource and re-fetch (e.g. after muting). */
|
||||
refetchCurrent: () => void;
|
||||
/** Patch triage state locally after a successful lightweight triage update. */
|
||||
patchTriageUpdate: (input: UpdateFindingTriageInput) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -287,6 +295,62 @@ export function useResourceDetailDrawer({
|
||||
fetchFindings(resource);
|
||||
};
|
||||
|
||||
const patchFindingTriage = (
|
||||
finding: ResourceDrawerFinding | null,
|
||||
input: UpdateFindingTriageInput,
|
||||
): ResourceDrawerFinding | null => {
|
||||
if (!finding?.triage || finding.triage.findingId !== input.findingId) {
|
||||
return finding;
|
||||
}
|
||||
|
||||
const shouldMarkMuted = shouldMarkFindingMutedForTriageUpdate(input);
|
||||
|
||||
return {
|
||||
...finding,
|
||||
isMuted: shouldMarkMuted ? true : finding.isMuted,
|
||||
mutedReason:
|
||||
shouldMarkMuted && input.isMuted !== true && input.status
|
||||
? getOptimisticTriageMutedReason(input.status)
|
||||
: finding.mutedReason,
|
||||
triage: applyOptimisticTriageSummaryUpdate(finding.triage, input),
|
||||
};
|
||||
};
|
||||
|
||||
const patchTriageUpdate = (input: UpdateFindingTriageInput) => {
|
||||
currentFindingCacheRef.current.forEach((finding, key) => {
|
||||
const patchedFinding = patchFindingTriage(finding, input);
|
||||
if (patchedFinding !== finding) {
|
||||
currentFindingCacheRef.current.set(key, patchedFinding);
|
||||
}
|
||||
});
|
||||
|
||||
otherFindingsCacheRef.current.forEach((findings, key) => {
|
||||
const patchedFindings = findings.map((finding) =>
|
||||
patchFindingTriage(finding, input),
|
||||
);
|
||||
|
||||
if (
|
||||
patchedFindings.some((finding, index) => finding !== findings[index])
|
||||
) {
|
||||
otherFindingsCacheRef.current.set(
|
||||
key,
|
||||
patchedFindings.filter(
|
||||
(finding): finding is ResourceDrawerFinding => finding !== null,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setCurrentFinding((finding) => patchFindingTriage(finding, input));
|
||||
setOtherFindings((findings) =>
|
||||
findings
|
||||
.map((finding) => patchFindingTriage(finding, input))
|
||||
.filter(
|
||||
(finding): finding is ResourceDrawerFinding => finding !== null,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const navigateTo = (index: number) => {
|
||||
const resource = resources[index];
|
||||
if (!resource) return;
|
||||
@@ -335,5 +399,6 @@ export function useResourceDetailDrawer({
|
||||
navigatePrev,
|
||||
navigateNext,
|
||||
refetchCurrent,
|
||||
patchTriageUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
PROVIDERS_ROW_TYPE,
|
||||
type ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
type ScanConfigurationData,
|
||||
} from "@/types/scan-configurations";
|
||||
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
|
||||
|
||||
const { dataTableMockState, getColumnProvidersMock } = vi.hoisted(() => ({
|
||||
@@ -40,6 +44,7 @@ vi.mock("./table", () => ({
|
||||
|
||||
import {
|
||||
computeSelectedScheduleProviders,
|
||||
createScanConfigIdByProviderId,
|
||||
ProvidersAccountsTable,
|
||||
} from "./providers-accounts-table";
|
||||
|
||||
@@ -90,6 +95,17 @@ const createProviderRow = (
|
||||
const providerOne = createProviderRow("provider-1", "111111111111", "Prod");
|
||||
const providerTwo = createProviderRow("provider-2", "222222222222", "Stage");
|
||||
const providerThree = createProviderRow("provider-3", "333333333333", "Dev");
|
||||
const scanConfig: ScanConfigurationData = {
|
||||
type: "scan-configurations",
|
||||
id: "config-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
name: "Strict AWS",
|
||||
configuration: {},
|
||||
providers: ["provider-1"],
|
||||
},
|
||||
};
|
||||
|
||||
const organizationRow: ProvidersTableRow = {
|
||||
id: "org-1",
|
||||
@@ -147,9 +163,44 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes populated scan configs to provider row action columns", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsTable
|
||||
isCloud
|
||||
metadata={metadata}
|
||||
rows={[]}
|
||||
scanConfigs={[scanConfig]}
|
||||
scanScheduleCapability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
|
||||
onOpenProviderWizard={vi.fn()}
|
||||
onOpenOrganizationWizard={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const call = getColumnProvidersMock.mock.calls.at(-1);
|
||||
expect(call?.[8]).toEqual([scanConfig]);
|
||||
expect(call?.[9]).toBe(SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE);
|
||||
expect(call?.[10]).toBeInstanceOf(Map);
|
||||
expect((call?.[10] as Map<string, string>).get("provider-1")).toBe(
|
||||
"config-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("precomputes scan config ids by provider id once for row actions", () => {
|
||||
// Given/When
|
||||
const lookup = createScanConfigIdByProviderId([scanConfig]);
|
||||
|
||||
// Then
|
||||
expect(lookup.get("provider-1")).toBe("config-1");
|
||||
});
|
||||
|
||||
describe("schedule provider selection", () => {
|
||||
it("uses the selected provider id for provider rows", () => {
|
||||
// Given
|
||||
@@ -268,6 +319,9 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -301,6 +355,9 @@ describe("ProvidersAccountsTable", () => {
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
SCAN_SCHEDULE_CAPABILITY.ADVANCED,
|
||||
[],
|
||||
SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
expect.any(Map),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
isProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
@@ -26,6 +31,10 @@ interface ProvidersAccountsTableProps {
|
||||
metadata?: MetaDataProps;
|
||||
rows: ProvidersTableRow[];
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
/** All scan configurations in the tenant, for the provider row's associate/
|
||||
* disassociate action (Cloud-only). */
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void;
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void;
|
||||
}
|
||||
@@ -154,11 +163,27 @@ export function computeSelectedScheduleProviders(
|
||||
return { providerIds, providers };
|
||||
}
|
||||
|
||||
export function createScanConfigIdByProviderId(
|
||||
scanConfigs: ScanConfigurationData[],
|
||||
): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
|
||||
for (const config of scanConfigs) {
|
||||
for (const providerId of config.attributes.providers) {
|
||||
lookup.set(providerId, config.id);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function ProvidersAccountsTableContent({
|
||||
isCloud,
|
||||
metadata,
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
scanConfigs,
|
||||
scanConfigStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
}: ProvidersAccountsTableProps) {
|
||||
@@ -170,6 +195,9 @@ function ProvidersAccountsTableContent({
|
||||
rowSelection,
|
||||
);
|
||||
const selectedScheduleProviderIds = selectedScheduleProviders.providerIds;
|
||||
const scanConfigIdByProviderId = createScanConfigIdByProviderId(
|
||||
scanConfigs ?? [],
|
||||
);
|
||||
|
||||
const clearSelection = () => setRowSelection({});
|
||||
|
||||
@@ -182,6 +210,9 @@ function ProvidersAccountsTableContent({
|
||||
onOpenProviderWizard,
|
||||
onOpenOrganizationWizard,
|
||||
scanScheduleCapability,
|
||||
scanConfigs ?? [],
|
||||
scanConfigStatus,
|
||||
scanConfigIdByProviderId,
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,6 +30,10 @@ import {
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProviderGroup } from "@/types/components";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
import type {
|
||||
ScanConfigurationData,
|
||||
ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type { ScanScheduleCapability } from "@/types/schedules";
|
||||
|
||||
const addProviderFlow = getFlowById("add-provider")!;
|
||||
@@ -56,6 +60,10 @@ interface ProvidersAccountsViewProps {
|
||||
rows: ProvidersTableRow[];
|
||||
/** Cloud overlay seam for provider-creation scan launch. */
|
||||
scanScheduleCapability?: ScanScheduleCapability;
|
||||
/** All scan configurations in the tenant, for the provider row's associate/
|
||||
* disassociate action (Cloud-only). */
|
||||
scanConfigs?: ScanConfigurationData[];
|
||||
scanConfigStatus?: ScanConfigurationListStatus;
|
||||
isScanLimitReached?: boolean;
|
||||
}
|
||||
|
||||
@@ -67,6 +75,8 @@ export function ProvidersAccountsView({
|
||||
providerGroups = [],
|
||||
rows,
|
||||
scanScheduleCapability,
|
||||
scanConfigs,
|
||||
scanConfigStatus,
|
||||
isScanLimitReached,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const pathname = usePathname();
|
||||
@@ -157,6 +167,8 @@ export function ProvidersAccountsView({
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
scanScheduleCapability={scanScheduleCapability}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
import { ManageScanConfigModal } from "./manage-scan-config-modal";
|
||||
|
||||
const { setScanConfigurationProvidersMock, toastMock } = vi.hoisted(() => ({
|
||||
setScanConfigurationProvidersMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/scan-configurations", () => ({
|
||||
setScanConfigurationProviders: setScanConfigurationProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Radix Select relies on pointer-capture and scrollIntoView, which jsdom does
|
||||
// not implement. Polyfill them so the dropdown can open in tests.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "hasPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "setPointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "releasePointerCapture", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
const makeConfig = (
|
||||
id: string,
|
||||
name: string,
|
||||
providers: string[],
|
||||
): ScanConfigurationData => ({
|
||||
type: "scan-configurations",
|
||||
id,
|
||||
attributes: {
|
||||
inserted_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
name,
|
||||
configuration: {},
|
||||
providers,
|
||||
},
|
||||
});
|
||||
|
||||
const renderModal = (
|
||||
overrides: Partial<React.ComponentProps<typeof ManageScanConfigModal>> = {},
|
||||
) => {
|
||||
const onOpenChange = vi.fn();
|
||||
const onSaved = vi.fn();
|
||||
const props: React.ComponentProps<typeof ManageScanConfigModal> = {
|
||||
open: true,
|
||||
onOpenChange,
|
||||
providerId: "provider-1",
|
||||
providerLabel: "AWS App Account",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", []),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
currentConfigId: null,
|
||||
onSaved,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
render(<ManageScanConfigModal {...props} />);
|
||||
return { onOpenChange, onSaved, props };
|
||||
};
|
||||
|
||||
const openSelectAndChoose = async (
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
optionName: RegExp,
|
||||
) => {
|
||||
await user.click(
|
||||
screen.getByRole("combobox", { name: /scan configuration/i }),
|
||||
);
|
||||
await user.click(await screen.findByRole("option", { name: optionName }));
|
||||
};
|
||||
|
||||
const clickSave = (user: ReturnType<typeof userEvent.setup>) =>
|
||||
user.click(screen.getByRole("button", { name: /^save$/i }));
|
||||
|
||||
describe("ManageScanConfigModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setScanConfigurationProvidersMock.mockResolvedValue({
|
||||
success: "Scan Configuration updated successfully!",
|
||||
});
|
||||
});
|
||||
|
||||
it("has an accessible label on the configuration select", () => {
|
||||
renderModal();
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /scan configuration/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("attaches the provider to the chosen configuration", async () => {
|
||||
// Given an unattached provider.
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
|
||||
|
||||
// When the user picks "Config A" and saves.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the provider is added to that config's provider list.
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-a",
|
||||
["provider-1"],
|
||||
),
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("detaches the provider when Default is selected", async () => {
|
||||
// Given a provider currently attached to Config A (alongside another).
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange, onSaved } = renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", ["provider-1", "provider-2"]),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
});
|
||||
|
||||
// When the user switches back to Default and saves.
|
||||
await openSelectAndChoose(user, /^default$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then only this provider is dropped — the rest stay attached.
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-a",
|
||||
["provider-2"],
|
||||
),
|
||||
);
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("moves the provider to another configuration", async () => {
|
||||
// Given a provider attached to Config A.
|
||||
const user = userEvent.setup();
|
||||
renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", ["provider-1"]),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
});
|
||||
|
||||
// When the user picks Config B and saves.
|
||||
await openSelectAndChoose(user, /^config b$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the provider is attached to Config B (the backend detaches it from A).
|
||||
await waitFor(() =>
|
||||
expect(setScanConfigurationProvidersMock).toHaveBeenCalledWith(
|
||||
"config-b",
|
||||
["provider-1"],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call the action when the selection is unchanged", async () => {
|
||||
// Given a provider already attached to Config A.
|
||||
const user = userEvent.setup();
|
||||
const { onOpenChange } = renderModal({
|
||||
currentConfigId: "config-a",
|
||||
scanConfigs: [makeConfig("config-a", "Config A", ["provider-1"])],
|
||||
});
|
||||
|
||||
// When the user saves without changing the selection.
|
||||
await clickSave(user);
|
||||
|
||||
// Then no request is sent, and the modal just closes.
|
||||
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("surfaces a destructive toast and keeps the modal open on failure", async () => {
|
||||
// Given the action returns a field error.
|
||||
const user = userEvent.setup();
|
||||
setScanConfigurationProvidersMock.mockResolvedValue({
|
||||
errors: { general: "Boom" },
|
||||
});
|
||||
const { onOpenChange, onSaved } = renderModal({ currentConfigId: null });
|
||||
|
||||
// When the user attaches and saves.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
await clickSave(user);
|
||||
|
||||
// Then the error is toasted and the modal stays open for a retry.
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variant: "destructive",
|
||||
description: "Boom",
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resets a cancelled selection when the modal is reopened", async () => {
|
||||
// Given a provider with no attached config.
|
||||
const user = userEvent.setup();
|
||||
const baseProps: React.ComponentProps<typeof ManageScanConfigModal> = {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
providerId: "provider-1",
|
||||
providerLabel: "AWS App Account",
|
||||
scanConfigs: [
|
||||
makeConfig("config-a", "Config A", []),
|
||||
makeConfig("config-b", "Config B", []),
|
||||
],
|
||||
currentConfigId: null,
|
||||
onSaved: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<ManageScanConfigModal {...baseProps} />);
|
||||
|
||||
// When the user picks Config A but closes the modal without saving.
|
||||
await openSelectAndChoose(user, /^config a$/i);
|
||||
rerender(<ManageScanConfigModal {...baseProps} open={false} />);
|
||||
|
||||
// And reopens it for the same (still unattached) provider.
|
||||
rerender(<ManageScanConfigModal {...baseProps} open />);
|
||||
|
||||
// Then the selection falls back to Default — the stale choice is gone, so
|
||||
// saving without touching the dropdown sends nothing.
|
||||
await clickSave(user);
|
||||
expect(setScanConfigurationProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { setScanConfigurationProviders } from "@/actions/scan-configurations";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { ScanConfigurationData } from "@/types/scan-configurations";
|
||||
|
||||
// Sentinel for the "Default" option: detaches the provider so its scans fall
|
||||
// back to Prowler's built-in SDK defaults. Select values must be non-empty
|
||||
// strings, so we can't use "".
|
||||
const DEFAULT_VALUE = "__default__";
|
||||
|
||||
interface ManageScanConfigModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
scanConfigs: ScanConfigurationData[];
|
||||
/** The config this provider is currently attached to, if any. */
|
||||
currentConfigId: string | null;
|
||||
/** Called after a successful associate/disassociate so the parent can refresh. */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ManageScanConfigFormProps = Omit<ManageScanConfigModalProps, "open">;
|
||||
|
||||
export function ManageScanConfigModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
...formProps
|
||||
}: ManageScanConfigModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Scan Configuration"
|
||||
size="md"
|
||||
>
|
||||
{/* Only mount the form while the modal is open so a fresh instance is
|
||||
created on every reopen — its selection always initializes from the
|
||||
provider's current config, never from a stale, cancelled selection.
|
||||
The key resets it again if the attached config changes mid-open. */}
|
||||
{open && (
|
||||
<ManageScanConfigForm
|
||||
key={`${formProps.providerId}:${formProps.currentConfigId ?? DEFAULT_VALUE}`}
|
||||
onOpenChange={onOpenChange}
|
||||
{...formProps}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ManageScanConfigForm({
|
||||
onOpenChange,
|
||||
providerId,
|
||||
providerLabel,
|
||||
scanConfigs,
|
||||
currentConfigId,
|
||||
onSaved,
|
||||
}: ManageScanConfigFormProps) {
|
||||
const { toast } = useToast();
|
||||
const [selected, setSelected] = useState<string>(
|
||||
currentConfigId ?? DEFAULT_VALUE,
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
// No change — nothing to do.
|
||||
if (selected === (currentConfigId ?? DEFAULT_VALUE)) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reportError = (description: string) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description,
|
||||
});
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let result;
|
||||
if (selected === DEFAULT_VALUE) {
|
||||
// Detach: drop this provider from its current config.
|
||||
if (!currentConfigId) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
const current = scanConfigs.find((c) => c.id === currentConfigId);
|
||||
// Bail if we don't have the current config loaded: sending a full
|
||||
// provider_ids replacement off a synthetic empty list would clear every
|
||||
// other provider attached to this configuration.
|
||||
if (!current) {
|
||||
reportError(
|
||||
"This scan configuration is no longer available. Refresh and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const next = current.attributes.providers.filter(
|
||||
(id) => id !== providerId,
|
||||
);
|
||||
result = await setScanConfigurationProviders(currentConfigId, next);
|
||||
} else {
|
||||
// Attach: add this provider to the chosen config. The backend moves it
|
||||
// off any other config automatically (one config per provider).
|
||||
const target = scanConfigs.find((c) => c.id === selected);
|
||||
// Same guard as the detach path: never replace provider_ids based on a
|
||||
// config we don't actually have.
|
||||
if (!target) {
|
||||
reportError(
|
||||
"This scan configuration is no longer available. Refresh and try again.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const next = Array.from(
|
||||
new Set([...target.attributes.providers, providerId]),
|
||||
);
|
||||
result = await setScanConfigurationProviders(selected, next);
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
toast({
|
||||
title: "Scan Configuration updated",
|
||||
description: result.success,
|
||||
});
|
||||
onSaved();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
reportError(
|
||||
result?.errors?.general ||
|
||||
result?.errors?.provider_ids ||
|
||||
"Failed to update the Scan Configuration. Please try again.",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// An invocation-level failure (transport/framework) rejects instead of
|
||||
// returning an error object — surface it instead of failing silently.
|
||||
reportError("Failed to update the Scan Configuration. Please try again.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-default-500 text-tiny">
|
||||
Choose the scan configuration to apply to{" "}
|
||||
<strong>{providerLabel}</strong> on its next scan, or leave default. To
|
||||
create or edit configurations, go to{" "}
|
||||
<CustomLink size="xs" href="/scans/config" target="_self">
|
||||
Scan Config
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
|
||||
{/* Always show the dropdown with Default — even with no custom configs,
|
||||
the provider can fall back to Prowler's SDK defaults. */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<Select value={selected} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label="Scan configuration">
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_VALUE}>Default</SelectItem>
|
||||
{scanConfigs.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.attributes.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-default-500 text-tiny">
|
||||
<strong>Default</strong>
|
||||
{
|
||||
" uses Prowler's scan configuration baseline. Read more about it in the "
|
||||
}
|
||||
<CustomLink
|
||||
size="xs"
|
||||
href="https://docs.prowler.com/user-guide/tutorials/prowler-app-scan-configuration"
|
||||
>
|
||||
documentation
|
||||
</CustomLink>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
ProvidersProviderRow,
|
||||
ProvidersTableRow,
|
||||
} from "@/types/providers-table";
|
||||
import {
|
||||
SCAN_CONFIGURATION_LIST_STATUS,
|
||||
ScanConfigurationData,
|
||||
type ScanConfigurationListStatus,
|
||||
} from "@/types/scan-configurations";
|
||||
import type {
|
||||
ScanScheduleCapability,
|
||||
ScanScheduleProvider,
|
||||
@@ -113,6 +118,9 @@ export function getColumnProviders(
|
||||
onOpenProviderWizard: (initialData?: ProviderWizardInitialData) => void,
|
||||
onOpenOrganizationWizard: (initialData: OrgWizardInitialData) => void,
|
||||
scanScheduleCapability?: ScanScheduleCapability,
|
||||
scanConfigs: ScanConfigurationData[] = [],
|
||||
scanConfigStatus: ScanConfigurationListStatus = SCAN_CONFIGURATION_LIST_STATUS.AVAILABLE,
|
||||
scanConfigIdByProviderId: ReadonlyMap<string, string> = new Map(),
|
||||
): ColumnDef<ProvidersTableRow>[] {
|
||||
return [
|
||||
{
|
||||
@@ -315,6 +323,9 @@ export function getColumnProviders(
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hasSelection = Object.values(rowSelection).some(Boolean);
|
||||
const currentScanConfigId = isProvidersOrganizationRow(row.original)
|
||||
? null
|
||||
: (scanConfigIdByProviderId.get(row.original.id) ?? null);
|
||||
|
||||
return (
|
||||
<DataTableRowActions
|
||||
@@ -327,6 +338,9 @@ export function getColumnProviders(
|
||||
onClearSelection={onClearSelection}
|
||||
onOpenProviderWizard={onOpenProviderWizard}
|
||||
onOpenOrganizationWizard={onOpenOrganizationWizard}
|
||||
scanConfigs={scanConfigs}
|
||||
scanConfigStatus={scanConfigStatus}
|
||||
currentScanConfigId={currentScanConfigId}
|
||||
capability={scanScheduleCapability}
|
||||
/>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user