mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
ui dspm test
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
"""Throwaway Neptune/Neo4j sink inspection tool. Not committed.
|
||||
|
||||
Run inside the API container:
|
||||
|
||||
docker exec -it <api-container> \
|
||||
python src/backend/neptune_tool.py [options]
|
||||
|
||||
Modes:
|
||||
--summary fast cluster-wide node/edge counts via the Neptune
|
||||
statistics API (no scan; may lag recent writes;
|
||||
ignores tenant/provider label isolation)
|
||||
--label LABEL count only nodes carrying LABEL (e.g. AWSRole)
|
||||
--rels also count relationships
|
||||
--cypher "..." run an arbitrary read query over Bolt, print rows
|
||||
--explain "..." Neptune openCypher EXPLAIN plan for the query
|
||||
(HTTP, SigV4-signed; not available over Bolt)
|
||||
--explain-mode M static | dynamic | details (default: details)
|
||||
--provider-uid UID bind $provider_uid in --explain / --cypher
|
||||
--param K=V bind an extra parameter (repeatable)
|
||||
|
||||
`--summary` and `--explain` hit Neptune's HTTPS endpoint directly and need:
|
||||
ATTACK_PATHS_SINK_DATABASE=neptune
|
||||
NEPTUNE_WRITER_ENDPOINT / NEPTUNE_READER_ENDPOINT / AWS_REGION
|
||||
|
||||
The openCypher counts under --label/--cypher are full scans and will hit
|
||||
Neptune's server-side timeout on a very large graph; --summary and --explain
|
||||
do not scan.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
import django
|
||||
|
||||
# manage.py lives next to this file; make `config`, `api`, `tasks` importable.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.django.devel")
|
||||
django.setup()
|
||||
|
||||
import neo4j # noqa: E402
|
||||
|
||||
from botocore.auth import SigV4Auth # noqa: E402
|
||||
from botocore.awsrequest import AWSRequest # noqa: E402
|
||||
from botocore.session import Session as BotoSession # noqa: E402
|
||||
|
||||
from api.attack_paths import sink as sink_module # noqa: E402
|
||||
|
||||
|
||||
def _neptune_endpoint() -> tuple[str, str, str]:
|
||||
"""Return (endpoint, port, region) for the configured Neptune sink."""
|
||||
from django.conf import settings
|
||||
|
||||
cfg = settings.DATABASES["neptune"]
|
||||
endpoint = cfg["READER_ENDPOINT"] or cfg["WRITER_ENDPOINT"]
|
||||
port = cfg["PORT"]
|
||||
region = cfg["REGION"]
|
||||
if not endpoint or not region:
|
||||
print(
|
||||
"NEPTUNE_READER_ENDPOINT/NEPTUNE_WRITER_ENDPOINT and AWS_REGION "
|
||||
"must be set for --summary/--explain.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
return endpoint, port, region
|
||||
|
||||
|
||||
def _signed_request(
|
||||
method: str,
|
||||
url: str,
|
||||
region: str,
|
||||
body: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> urllib.request.Request:
|
||||
"""Build a SigV4-signed urllib request for the Neptune data plane."""
|
||||
credentials = BotoSession().get_credentials()
|
||||
if credentials is None:
|
||||
print("No AWS credentials available for SigV4 signing.", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
credentials = credentials.get_frozen_credentials()
|
||||
|
||||
headers = {}
|
||||
if content_type:
|
||||
headers["Content-Type"] = content_type
|
||||
|
||||
data = body.encode() if body is not None else None
|
||||
aws_request = AWSRequest(method=method, url=url, data=data, headers=headers)
|
||||
SigV4Auth(credentials, "neptune-db", region).add_auth(aws_request)
|
||||
|
||||
req = urllib.request.Request(url, data=data, method=method)
|
||||
for header, value in aws_request.headers.items():
|
||||
req.add_header(header, value)
|
||||
return req
|
||||
|
||||
|
||||
def _statistics_summary() -> int:
|
||||
endpoint, port, region = _neptune_endpoint()
|
||||
url = f"https://{endpoint}:{port}/propertygraph/statistics/summary?mode=detailed"
|
||||
req = _signed_request("GET", url, region)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
payload = json.loads(resp.read())
|
||||
|
||||
summary = payload.get("payload", {}).get("graphSummary", {})
|
||||
print(f"numNodes: {summary.get('numNodes')}")
|
||||
print(f"numEdges: {summary.get('numEdges')}")
|
||||
print(f"numNodeLabels: {summary.get('numNodeLabels')}")
|
||||
print(f"numEdgeLabels: {summary.get('numEdgeLabels')}")
|
||||
print(
|
||||
"(cluster-wide; reflects last statistics computation, "
|
||||
"may lag recent writes; ignores tenant/provider labels)"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _explain(cypher: str, mode: str, parameters: dict) -> int:
|
||||
endpoint, port, region = _neptune_endpoint()
|
||||
url = f"https://{endpoint}:{port}/opencypher"
|
||||
|
||||
form = {"query": cypher, "explain": mode}
|
||||
if parameters:
|
||||
form["parameters"] = json.dumps(parameters)
|
||||
body = urllib.parse.urlencode(form)
|
||||
|
||||
req = _signed_request(
|
||||
"POST",
|
||||
url,
|
||||
region,
|
||||
body=body,
|
||||
content_type="application/x-www-form-urlencoded",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=1800) as resp:
|
||||
print(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"HTTP {exc.code}", file=sys.stderr)
|
||||
print(exc.read().decode(errors="replace"), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _parse_params(args: argparse.Namespace) -> dict:
|
||||
params: dict = {}
|
||||
for pair in args.param or []:
|
||||
if "=" not in pair:
|
||||
print(f"--param expects KEY=VALUE, got {pair!r}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
key, value = pair.split("=", 1)
|
||||
params[key] = value
|
||||
if args.provider_uid:
|
||||
params["provider_uid"] = args.provider_uid
|
||||
return params
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Inspect the Neptune/Neo4j sink")
|
||||
parser.add_argument("--summary", action="store_true")
|
||||
parser.add_argument("--label")
|
||||
parser.add_argument("--rels", action="store_true")
|
||||
parser.add_argument("--cypher")
|
||||
parser.add_argument("--explain", dest="explain", metavar="CYPHER")
|
||||
parser.add_argument(
|
||||
"--explain-mode",
|
||||
dest="explain_mode",
|
||||
choices=["static", "dynamic", "details"],
|
||||
default="details",
|
||||
)
|
||||
parser.add_argument("--provider-uid", dest="provider_uid")
|
||||
parser.add_argument("--param", action="append", help="KEY=VALUE (repeatable)")
|
||||
args = parser.parse_args()
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
print(f"ATTACK_PATHS_SINK_DATABASE = {settings.ATTACK_PATHS_SINK_DATABASE}")
|
||||
|
||||
if args.explain:
|
||||
if settings.ATTACK_PATHS_SINK_DATABASE != "neptune":
|
||||
print("--explain requires the Neptune sink.", file=sys.stderr)
|
||||
return 1
|
||||
return _explain(args.explain, args.explain_mode, _parse_params(args))
|
||||
|
||||
if settings.ATTACK_PATHS_SINK_DATABASE != "neptune":
|
||||
print(
|
||||
"Warning: sink is not Neptune; this still runs against the active sink.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.summary:
|
||||
return _statistics_summary()
|
||||
|
||||
backend = sink_module.get_backend()
|
||||
with backend.get_session(default_access_mode=neo4j.READ_ACCESS) as session:
|
||||
if args.cypher:
|
||||
for record in session.run(args.cypher, _parse_params(args)):
|
||||
print(dict(record))
|
||||
return 0
|
||||
|
||||
if args.label:
|
||||
node_query = f"MATCH (n:`{args.label}`) RETURN count(n) AS c"
|
||||
else:
|
||||
node_query = "MATCH (n) RETURN count(n) AS c"
|
||||
|
||||
node_count = session.run(node_query).single()["c"]
|
||||
scope = f" with label `{args.label}`" if args.label else ""
|
||||
print(f"nodes{scope}: {node_count}")
|
||||
|
||||
if args.rels:
|
||||
rel_count = session.run(
|
||||
"MATCH ()-[r]->() RETURN count(r) AS c"
|
||||
).single()["c"]
|
||||
print(f"relationships: {rel_count}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"metadata": {
|
||||
"generated_at": "2026-05-28T18:11:56+00:00",
|
||||
"provider": "aws",
|
||||
"region": "us-east-1",
|
||||
"services": [
|
||||
"s3",
|
||||
"rds",
|
||||
"dynamodb"
|
||||
],
|
||||
"tool": "Prowler DSPM Scan v0.1.0"
|
||||
},
|
||||
"datastores": [
|
||||
{
|
||||
"datastore_id": "s3://acme-customers-prod",
|
||||
"service": "s3",
|
||||
"classification": "PII",
|
||||
"confidence": 0.96,
|
||||
"risk_score": 10,
|
||||
"evidence": "Found SSN-format strings in 7/10 sampled objects; email + full name combinations in 9/10",
|
||||
"recommendation": "Enable SSE-KMS encryption, attach restrictive bucket policy, enable Block Public Access",
|
||||
"encrypted": false,
|
||||
"public": true
|
||||
},
|
||||
{
|
||||
"datastore_id": "s3://acme-payments-archive",
|
||||
"service": "s3",
|
||||
"classification": "Financial",
|
||||
"confidence": 0.91,
|
||||
"risk_score": 9,
|
||||
"evidence": "Detected credit card PANs (Luhn-valid) and IBAN strings in 8/10 sampled archives",
|
||||
"recommendation": "Enable SSE-KMS, turn on versioning + Object Lock, restrict to PCI-scoped IAM roles",
|
||||
"encrypted": false,
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"datastore_id": "rds://patients-db-primary",
|
||||
"service": "rds",
|
||||
"classification": "Health",
|
||||
"confidence": 0.89,
|
||||
"risk_score": 8,
|
||||
"evidence": "Rows contain ICD-10 codes, patient identifiers, and diagnosis free-text in 10/10 sampled rows",
|
||||
"recommendation": "Disable public accessibility, place behind a private subnet, restrict to HIPAA-scoped roles",
|
||||
"encrypted": true,
|
||||
"public": true
|
||||
},
|
||||
{
|
||||
"datastore_id": "dynamodb://billing-events",
|
||||
"service": "dynamodb",
|
||||
"classification": "Financial",
|
||||
"confidence": 0.88,
|
||||
"risk_score": 8,
|
||||
"evidence": "Items contain charge_amount, last4_cc, and merchant_id in 9/10 sampled items",
|
||||
"recommendation": "Enable encryption at rest with customer-managed KMS, restrict global table replicas to PCI regions",
|
||||
"encrypted": false,
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"datastore_id": "rds://payroll-prod",
|
||||
"service": "rds",
|
||||
"classification": "Financial",
|
||||
"confidence": 0.93,
|
||||
"risk_score": 7,
|
||||
"evidence": "Columns include salary, tax_id, and bank_account in 10/10 sampled rows",
|
||||
"recommendation": "Enable automated backups with 30-day retention, rotate KMS key, enforce least-privilege role",
|
||||
"encrypted": true,
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"datastore_id": "dynamodb://user-sessions",
|
||||
"service": "dynamodb",
|
||||
"classification": "PII",
|
||||
"confidence": 0.84,
|
||||
"risk_score": 7,
|
||||
"evidence": "Items contain user_email and session_token fields in 10/10 sampled items",
|
||||
"recommendation": "Set TTL to 24h, enable PITR, rotate session signing key quarterly",
|
||||
"encrypted": true,
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"datastore_id": "rds://analytics-warehouse",
|
||||
"service": "rds",
|
||||
"classification": "Unknown",
|
||||
"confidence": 0.42,
|
||||
"risk_score": 3,
|
||||
"evidence": "Sampled rows contain aggregate counts and anonymized identifiers; insufficient signal for confident classification",
|
||||
"recommendation": "Re-run with expanded sample size; verify anonymization invariants documented",
|
||||
"encrypted": true,
|
||||
"public": false
|
||||
},
|
||||
{
|
||||
"datastore_id": "s3://acme-marketing-assets",
|
||||
"service": "s3",
|
||||
"classification": "Public",
|
||||
"confidence": 0.99,
|
||||
"risk_score": 1,
|
||||
"evidence": "All 10 samples are PNG/JPG marketing collateral with no detected sensitive content",
|
||||
"recommendation": "No action required; current public-read ACL is intentional",
|
||||
"encrypted": true,
|
||||
"public": true
|
||||
},
|
||||
{
|
||||
"datastore_id": "dynamodb://feature-flags",
|
||||
"service": "dynamodb",
|
||||
"classification": "Public",
|
||||
"confidence": 0.97,
|
||||
"risk_score": 1,
|
||||
"evidence": "Items contain feature names and boolean flags only; no sensitive content detected",
|
||||
"recommendation": "No action required",
|
||||
"encrypted": true,
|
||||
"public": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Prowler DSPM Catalog</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; margin: 0; background: #f6f7f9; color: #1f2329; }
|
||||
header { background: linear-gradient(135deg, #0b2545 0%, #134074 100%); color: #fff; padding: 28px 40px; }
|
||||
header h1 { margin: 0; font-size: 24px; letter-spacing: 0.3px; }
|
||||
header p { margin: 6px 0 0; opacity: 0.85; font-size: 13px; }
|
||||
main { padding: 24px 40px 60px; }
|
||||
table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,0.08); border-radius: 6px; overflow: hidden; }
|
||||
th, td { padding: 12px 14px; text-align: left; font-size: 13px; vertical-align: top; border-bottom: 1px solid #eceef2; }
|
||||
th { background: #eef1f6; font-weight: 600; color: #2b3340; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||||
td.risk { color: #fff; font-weight: 700; text-align: center; width: 48px; }
|
||||
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e6ecf5; color: #134074; font-weight: 600; font-size: 11px; }
|
||||
footer { padding: 16px 40px; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb; background: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Prowler DSPM Catalog</h1>
|
||||
<p>Provider: aws · Region: us-east-1 · Services: s3, rds, dynamodb · Datastores: 9</p>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Datastore</th><th>Service</th><th>Classification</th><th>Confidence</th><th>Risk</th><th>Evidence</th><th>Recommendation</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class='mono'>s3://acme-customers-prod</td><td>s3</td><td><span class='pill'>PII</span></td><td>0.96</td><td class='risk' style='background:#d9342b'>10</td><td>Found SSN-format strings in 7/10 sampled objects; email + full name combinations in 9/10</td><td>Enable SSE-KMS encryption, attach restrictive bucket policy, enable Block Public Access</td></tr><tr><td class='mono'>s3://acme-payments-archive</td><td>s3</td><td><span class='pill'>Financial</span></td><td>0.91</td><td class='risk' style='background:#d9342b'>9</td><td>Detected credit card PANs (Luhn-valid) and IBAN strings in 8/10 sampled archives</td><td>Enable SSE-KMS, turn on versioning + Object Lock, restrict to PCI-scoped IAM roles</td></tr><tr><td class='mono'>rds://patients-db-primary</td><td>rds</td><td><span class='pill'>Health</span></td><td>0.89</td><td class='risk' style='background:#d9342b'>8</td><td>Rows contain ICD-10 codes, patient identifiers, and diagnosis free-text in 10/10 sampled rows</td><td>Disable public accessibility, place behind a private subnet, restrict to HIPAA-scoped roles</td></tr><tr><td class='mono'>dynamodb://billing-events</td><td>dynamodb</td><td><span class='pill'>Financial</span></td><td>0.88</td><td class='risk' style='background:#d9342b'>8</td><td>Items contain charge_amount, last4_cc, and merchant_id in 9/10 sampled items</td><td>Enable encryption at rest with customer-managed KMS, restrict global table replicas to PCI regions</td></tr><tr><td class='mono'>rds://payroll-prod</td><td>rds</td><td><span class='pill'>Financial</span></td><td>0.93</td><td class='risk' style='background:#e88a1a'>7</td><td>Columns include salary, tax_id, and bank_account in 10/10 sampled rows</td><td>Enable automated backups with 30-day retention, rotate KMS key, enforce least-privilege role</td></tr><tr><td class='mono'>dynamodb://user-sessions</td><td>dynamodb</td><td><span class='pill'>PII</span></td><td>0.84</td><td class='risk' style='background:#e88a1a'>7</td><td>Items contain user_email and session_token fields in 10/10 sampled items</td><td>Set TTL to 24h, enable PITR, rotate session signing key quarterly</td></tr><tr><td class='mono'>rds://analytics-warehouse</td><td>rds</td><td><span class='pill'>Unknown</span></td><td>0.42</td><td class='risk' style='background:#e0c020'>3</td><td>Sampled rows contain aggregate counts and anonymized identifiers; insufficient signal for confident classification</td><td>Re-run with expanded sample size; verify anonymization invariants documented</td></tr><tr><td class='mono'>s3://acme-marketing-assets</td><td>s3</td><td><span class='pill'>Public</span></td><td>0.99</td><td class='risk' style='background:#2e9d4a'>1</td><td>All 10 samples are PNG/JPG marketing collateral with no detected sensitive content</td><td>No action required; current public-read ACL is intentional</td></tr><tr><td class='mono'>dynamodb://feature-flags</td><td>dynamodb</td><td><span class='pill'>Public</span></td><td>0.97</td><td class='risk' style='background:#2e9d4a'>1</td><td>Items contain feature names and boolean flags only; no sensitive content detected</td><td>No action required</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
<footer>Generated by Prowler DSPM · classification powered by Lighthouse AI · 2026-05-28T18:11:56+00:00</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
# DSPM with Lighthouse AI: where Prowler fits next to BigID and Sentra
|
||||
|
||||
*Prowler Hackathon · Team 3*
|
||||
|
||||
**BigID** is an enterprise data platform: ML-driven discovery and classification across cloud and on-prem stores, continuous monitoring of who accesses what, automated remediation workflows, and a newer AI SPM layer that governs data going in and out of LLM pipelines. **Sentra** is the cloud-native challenger: agentless scanning that connects via cloud and SaaS APIs, 95%+ classification accuracy with more than 150 built-in labels (PII, PCI, PHI, developer secrets), and full tracking of how data moves between services. Both are excellent at the data layer. Neither sees whether the bucket holding the data is publicly accessible, unencrypted at rest, or fronted by a permissive IAM policy. That part of the picture lives in a different platform. Usually Prowler.
|
||||
|
||||
When DSPM ships on Prowler, a customer enables it by granting read access on the AWS provider they already have configured. The normal check scan is untouched. A follow-up task lists S3, RDS and DynamoDB resources, samples a handful of objects per datastore, and sends the samples to Lighthouse to classify type, confidence and risk. The classification is persisted on the same `Resource` row as the existing infra audit, so a single finding combines both sides of the story: "PII bucket, public ACL, unencrypted at rest, risk 10/10". Adding a new sensitive-data type is a prompt change, not a vendor ticket. The honest trade-off: Prowler won't match Sentra's 95% classification accuracy on day one, and it doesn't have BigID's years of data-lineage and governance tooling. But for the customers whose first question is "is my PII bucket exposed?", joining sensitivity to misconfiguration in one alert beats buying a second DSPM platform to answer half the question.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prowler DSPM Scan - simulated Data Security Posture Management demo.
|
||||
|
||||
Standalone script. No real cloud calls, no real LLM. Everything is faked
|
||||
to demonstrate what a DSPM workflow on top of Prowler could look like.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
_RICH = True
|
||||
_console = Console()
|
||||
except ImportError:
|
||||
_RICH = False
|
||||
_console = None
|
||||
|
||||
VERSION = "0.1.0"
|
||||
VALID_PROVIDERS = ("aws", "azure", "gcp")
|
||||
VALID_SERVICES = ("s3", "rds", "dynamodb")
|
||||
|
||||
CATALOG = [
|
||||
{"datastore_id": "s3://acme-customers-prod", "service": "s3", "classification": "PII", "confidence": 0.96, "risk_score": 10, "evidence": "Found SSN-format strings in 7/10 sampled objects; email + full name combinations in 9/10", "recommendation": "Enable SSE-KMS encryption, attach restrictive bucket policy, enable Block Public Access", "encrypted": False, "public": True},
|
||||
{"datastore_id": "s3://acme-payments-archive", "service": "s3", "classification": "Financial", "confidence": 0.91, "risk_score": 9, "evidence": "Detected credit card PANs (Luhn-valid) and IBAN strings in 8/10 sampled archives", "recommendation": "Enable SSE-KMS, turn on versioning + Object Lock, restrict to PCI-scoped IAM roles", "encrypted": False, "public": False},
|
||||
{"datastore_id": "s3://acme-marketing-assets", "service": "s3", "classification": "Public", "confidence": 0.99, "risk_score": 1, "evidence": "All 10 samples are PNG/JPG marketing collateral with no detected sensitive content", "recommendation": "No action required; current public-read ACL is intentional", "encrypted": True, "public": True},
|
||||
{"datastore_id": "rds://patients-db-primary", "service": "rds", "classification": "Health", "confidence": 0.89, "risk_score": 8, "evidence": "Rows contain ICD-10 codes, patient identifiers, and diagnosis free-text in 10/10 sampled rows", "recommendation": "Disable public accessibility, place behind a private subnet, restrict to HIPAA-scoped roles", "encrypted": True, "public": True},
|
||||
{"datastore_id": "rds://payroll-prod", "service": "rds", "classification": "Financial", "confidence": 0.93, "risk_score": 7, "evidence": "Columns include salary, tax_id, and bank_account in 10/10 sampled rows", "recommendation": "Enable automated backups with 30-day retention, rotate KMS key, enforce least-privilege role", "encrypted": True, "public": False},
|
||||
{"datastore_id": "rds://analytics-warehouse", "service": "rds", "classification": "Unknown", "confidence": 0.42, "risk_score": 3, "evidence": "Sampled rows contain aggregate counts and anonymized identifiers; insufficient signal for confident classification", "recommendation": "Re-run with expanded sample size; verify anonymization invariants documented", "encrypted": True, "public": False},
|
||||
{"datastore_id": "dynamodb://user-sessions", "service": "dynamodb", "classification": "PII", "confidence": 0.84, "risk_score": 7, "evidence": "Items contain user_email and session_token fields in 10/10 sampled items", "recommendation": "Set TTL to 24h, enable PITR, rotate session signing key quarterly", "encrypted": True, "public": False},
|
||||
{"datastore_id": "dynamodb://feature-flags", "service": "dynamodb", "classification": "Public", "confidence": 0.97, "risk_score": 1, "evidence": "Items contain feature names and boolean flags only; no sensitive content detected", "recommendation": "No action required", "encrypted": True, "public": False},
|
||||
{"datastore_id": "dynamodb://billing-events", "service": "dynamodb", "classification": "Financial", "confidence": 0.88, "risk_score": 8, "evidence": "Items contain charge_amount, last4_cc, and merchant_id in 9/10 sampled items", "recommendation": "Enable encryption at rest with customer-managed KMS, restrict global table replicas to PCI regions", "encrypted": False, "public": False},
|
||||
]
|
||||
|
||||
BANNER = r"""
|
||||
____ _ ____ ____ ____ __ __
|
||||
| _ \ _ __ _____ _| | ___ _ __ | _ \/ ___|| _ \| \/ |
|
||||
| |_) | '__/ _ \ \ /\ / / |/ _ \ '__| | | | \___ \| |_) | |\/| |
|
||||
| __/| | | (_) \ V V /| | __/ | | |_| |___) | __/| | | |
|
||||
|_| |_| \___/ \_/\_/ |_|\___|_| |____/|____/|_| |_| |_|
|
||||
"""
|
||||
|
||||
|
||||
def _csv(value: str) -> list[str]:
|
||||
return [v.strip() for v in value.split(",") if v.strip()]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="dspm_scan.py",
|
||||
description="Prowler DSPM Scan - simulated data security posture management.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
p.add_argument("--provider", choices=VALID_PROVIDERS, default="aws", help="Cloud provider")
|
||||
p.add_argument("--service", type=_csv, default=list(VALID_SERVICES), help="Comma-separated services to scan (s3,rds,dynamodb)")
|
||||
p.add_argument("--region", default="us-east-1", help="Cloud region")
|
||||
p.add_argument("--output-formats", type=_csv, default=["json", "html"], help="Comma-separated output formats (json,html)")
|
||||
p.add_argument("--output-directory", default="./dspm-output", help="Directory for output files")
|
||||
p.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
||||
args = p.parse_args()
|
||||
|
||||
bad_services = [s for s in args.service if s not in VALID_SERVICES]
|
||||
if bad_services:
|
||||
p.error(f"invalid --service values: {', '.join(bad_services)} (allowed: {', '.join(VALID_SERVICES)})")
|
||||
bad_formats = [f for f in args.output_formats if f not in ("json", "html")]
|
||||
if bad_formats:
|
||||
p.error(f"invalid --output-formats values: {', '.join(bad_formats)} (allowed: json, html)")
|
||||
return args
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
if _RICH:
|
||||
_console.print(msg)
|
||||
else:
|
||||
print(msg)
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
if _RICH:
|
||||
_console.print(Text(BANNER, style="bold cyan"))
|
||||
_console.print(Panel.fit(
|
||||
f"[bold]Prowler DSPM Scan v{VERSION}[/bold]\n"
|
||||
f"[dim]Data Security Posture Management - powered by Lighthouse AI[/dim]",
|
||||
border_style="cyan",
|
||||
))
|
||||
else:
|
||||
print(BANNER)
|
||||
print(f"Prowler DSPM Scan v{VERSION}")
|
||||
print("Data Security Posture Management - powered by Lighthouse AI")
|
||||
print("-" * 60)
|
||||
|
||||
|
||||
def discover(services: list[str], region: str) -> list[dict]:
|
||||
info(f"\n[bold]>[/bold] Discovering datastores in AWS region [cyan]{region}[/cyan]..." if _RICH else f"\n> Discovering datastores in AWS region {region}...")
|
||||
time.sleep(0.3)
|
||||
selected = [d for d in CATALOG if d["service"] in services]
|
||||
by_service: dict[str, int] = {}
|
||||
for d in selected:
|
||||
by_service[d["service"]] = by_service.get(d["service"], 0) + 1
|
||||
for svc in services:
|
||||
count = by_service.get(svc, 0)
|
||||
time.sleep(0.3)
|
||||
info(f" [green]found[/green] {count} {svc} datastore(s)" if _RICH else f" found {count} {svc} datastore(s)")
|
||||
return selected
|
||||
|
||||
|
||||
def sample(datastores: list[dict], verbose: bool) -> None:
|
||||
info("\n[bold]>[/bold] Sampling 10 objects/rows from each datastore..." if _RICH else "\n> Sampling 10 objects/rows from each datastore...")
|
||||
if _RICH:
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=_console,
|
||||
transient=False,
|
||||
) as progress:
|
||||
task = progress.add_task("sampling", total=len(datastores))
|
||||
for d in datastores:
|
||||
progress.update(task, description=f"sampling {d['datastore_id']}")
|
||||
time.sleep(0.3)
|
||||
progress.advance(task)
|
||||
else:
|
||||
for d in datastores:
|
||||
print(f" sampling {d['datastore_id']}...")
|
||||
time.sleep(0.3)
|
||||
if verbose:
|
||||
info(f" [dim]sampled {len(datastores) * 10} total records[/dim]" if _RICH else f" sampled {len(datastores) * 10} total records")
|
||||
|
||||
|
||||
def classify(datastores: list[dict]) -> None:
|
||||
info("\n[bold]>[/bold] Classifying samples with Lighthouse AI..." if _RICH else "\n> Classifying samples with Lighthouse AI...")
|
||||
for d in datastores:
|
||||
time.sleep(0.3)
|
||||
cls = d["classification"]
|
||||
conf = d["confidence"]
|
||||
risk = d["risk_score"]
|
||||
if _RICH:
|
||||
color = {"PII": "magenta", "Financial": "yellow", "Health": "red", "Public": "green", "Unknown": "dim"}.get(cls, "white")
|
||||
_console.print(
|
||||
f" [bold]{d['datastore_id']}[/bold] -> "
|
||||
f"[{color}]{cls}[/{color}] "
|
||||
f"(confidence={conf:.2f}, risk={risk})"
|
||||
)
|
||||
else:
|
||||
print(f" {d['datastore_id']} -> {cls} (confidence={conf:.2f}, risk={risk})")
|
||||
|
||||
|
||||
def write_json(rows: list[dict], path: str, meta: dict) -> None:
|
||||
payload = {"metadata": meta, "datastores": rows}
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, indent=2)
|
||||
|
||||
|
||||
def _risk_color(risk: int) -> str:
|
||||
if risk >= 8:
|
||||
return "#d9342b"
|
||||
if risk >= 5:
|
||||
return "#e88a1a"
|
||||
if risk >= 3:
|
||||
return "#e0c020"
|
||||
return "#2e9d4a"
|
||||
|
||||
|
||||
def write_html(rows: list[dict], path: str, meta: dict) -> None:
|
||||
ts = meta["generated_at"]
|
||||
body_rows = []
|
||||
for d in rows:
|
||||
color = _risk_color(d["risk_score"])
|
||||
body_rows.append(
|
||||
"<tr>"
|
||||
f"<td class='mono'>{d['datastore_id']}</td>"
|
||||
f"<td>{d['service']}</td>"
|
||||
f"<td><span class='pill'>{d['classification']}</span></td>"
|
||||
f"<td>{d['confidence']:.2f}</td>"
|
||||
f"<td class='risk' style='background:{color}'>{d['risk_score']}</td>"
|
||||
f"<td>{d['evidence']}</td>"
|
||||
f"<td>{d['recommendation']}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
html = f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Prowler DSPM Catalog</title>
|
||||
<style>
|
||||
:root {{ color-scheme: light; }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; margin: 0; background: #f6f7f9; color: #1f2329; }}
|
||||
header {{ background: linear-gradient(135deg, #0b2545 0%, #134074 100%); color: #fff; padding: 28px 40px; }}
|
||||
header h1 {{ margin: 0; font-size: 24px; letter-spacing: 0.3px; }}
|
||||
header p {{ margin: 6px 0 0; opacity: 0.85; font-size: 13px; }}
|
||||
main {{ padding: 24px 40px 60px; }}
|
||||
table {{ width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,0.08); border-radius: 6px; overflow: hidden; }}
|
||||
th, td {{ padding: 12px 14px; text-align: left; font-size: 13px; vertical-align: top; border-bottom: 1px solid #eceef2; }}
|
||||
th {{ background: #eef1f6; font-weight: 600; color: #2b3340; text-transform: uppercase; font-size: 11px; letter-spacing: 0.5px; }}
|
||||
tr:last-child td {{ border-bottom: none; }}
|
||||
td.mono {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }}
|
||||
td.risk {{ color: #fff; font-weight: 700; text-align: center; width: 48px; }}
|
||||
.pill {{ display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e6ecf5; color: #134074; font-weight: 600; font-size: 11px; }}
|
||||
footer {{ padding: 16px 40px; color: #6b7280; font-size: 12px; border-top: 1px solid #e5e7eb; background: #fff; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Prowler DSPM Catalog</h1>
|
||||
<p>Provider: {meta['provider']} · Region: {meta['region']} · Services: {', '.join(meta['services'])} · Datastores: {len(rows)}</p>
|
||||
</header>
|
||||
<main>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Datastore</th><th>Service</th><th>Classification</th><th>Confidence</th><th>Risk</th><th>Evidence</th><th>Recommendation</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{''.join(body_rows)}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
<footer>Generated by Prowler DSPM · classification powered by Lighthouse AI · {ts}</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(html)
|
||||
|
||||
|
||||
def summarize(rows: list[dict]) -> None:
|
||||
by_class: dict[str, int] = {}
|
||||
for d in rows:
|
||||
by_class[d["classification"]] = by_class.get(d["classification"], 0) + 1
|
||||
top = sorted(rows, key=lambda r: r["risk_score"], reverse=True)[:3]
|
||||
|
||||
if _RICH:
|
||||
_console.print()
|
||||
t = Table(title="Classification summary", show_header=True, header_style="bold")
|
||||
t.add_column("Classification")
|
||||
t.add_column("Datastores", justify="right")
|
||||
for cls, n in sorted(by_class.items(), key=lambda kv: -kv[1]):
|
||||
t.add_row(cls, str(n))
|
||||
_console.print(t)
|
||||
_console.print("\n[bold]Top risks[/bold]")
|
||||
for d in top:
|
||||
_console.print(f" [red]risk={d['risk_score']:>2}[/red] {d['datastore_id']} [dim]({d['classification']})[/dim]")
|
||||
else:
|
||||
print("\nClassification summary:")
|
||||
for cls, n in sorted(by_class.items(), key=lambda kv: -kv[1]):
|
||||
print(f" {cls}: {n}")
|
||||
print("\nTop risks:")
|
||||
for d in top:
|
||||
print(f" risk={d['risk_score']:>2} {d['datastore_id']} ({d['classification']})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
print_banner()
|
||||
|
||||
if args.provider != "aws":
|
||||
info(f"\n[yellow]Provider '{args.provider}' is not yet supported. Only 'aws' is implemented.[/yellow]" if _RICH else f"\nProvider '{args.provider}' is not yet supported. Only 'aws' is implemented.")
|
||||
return 0
|
||||
|
||||
datastores = discover(args.service, args.region)
|
||||
if not datastores:
|
||||
info("\nNo datastores discovered. Nothing to do.")
|
||||
return 0
|
||||
|
||||
sample(datastores, args.verbose)
|
||||
classify(datastores)
|
||||
|
||||
rows = sorted(datastores, key=lambda r: r["risk_score"], reverse=True)
|
||||
os.makedirs(args.output_directory, exist_ok=True)
|
||||
meta = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"provider": args.provider,
|
||||
"region": args.region,
|
||||
"services": args.service,
|
||||
"tool": f"Prowler DSPM Scan v{VERSION}",
|
||||
}
|
||||
|
||||
written = []
|
||||
if "json" in args.output_formats:
|
||||
json_path = os.path.join(args.output_directory, "dspm-catalog.json")
|
||||
write_json(rows, json_path, meta)
|
||||
written.append(json_path)
|
||||
if "html" in args.output_formats:
|
||||
html_path = os.path.join(args.output_directory, "dspm-report.html")
|
||||
write_html(rows, html_path, meta)
|
||||
written.append(html_path)
|
||||
|
||||
summarize(rows)
|
||||
|
||||
info("")
|
||||
for p in written:
|
||||
info(f"[green]wrote[/green] {os.path.abspath(p)}" if _RICH else f"wrote {os.path.abspath(p)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ScanStatus =
|
||||
| "discovering"
|
||||
| "sampling"
|
||||
| "classifying"
|
||||
| "done";
|
||||
|
||||
interface Step {
|
||||
key: Exclude<ScanStatus, "done">;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ key: "discovering", label: "Discovering datastores" },
|
||||
{ key: "sampling", label: "Sampling content" },
|
||||
{ key: "classifying", label: "Classifying with Lighthouse AI" },
|
||||
];
|
||||
|
||||
const STATUS_ORDER: Record<ScanStatus, number> = {
|
||||
discovering: 0,
|
||||
sampling: 1,
|
||||
classifying: 2,
|
||||
done: 3,
|
||||
};
|
||||
|
||||
interface DspmScanProgressProps {
|
||||
status: ScanStatus;
|
||||
}
|
||||
|
||||
export const DspmScanProgress = ({ status }: DspmScanProgressProps) => {
|
||||
const currentIndex = STATUS_ORDER[status];
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{STEPS.map((step, index) => {
|
||||
const isComplete = index < currentIndex;
|
||||
const isActive = index === currentIndex;
|
||||
return (
|
||||
<li
|
||||
key={step.key}
|
||||
className={cn(
|
||||
"flex items-center gap-3 text-sm",
|
||||
isActive && "text-text-neutral-primary font-medium",
|
||||
isComplete && "text-text-neutral-secondary",
|
||||
!isActive && !isComplete && "text-text-neutral-tertiary",
|
||||
)}
|
||||
>
|
||||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||||
{isComplete ? (
|
||||
<Check className="text-bg-data-low size-5" />
|
||||
) : isActive ? (
|
||||
<Spinner className="size-5" />
|
||||
) : (
|
||||
<span className="bg-bg-neutral-tertiary size-2.5 rounded-full" />
|
||||
)}
|
||||
</span>
|
||||
<span>{step.label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { LoadingState } from "@/components/shadcn/spinner/loading-state";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet/sheet";
|
||||
|
||||
import { DspmScanProgress, type ScanStatus } from "./dspm-scan-progress";
|
||||
import { DspmTable, type DspmEntry } from "./dspm-table";
|
||||
|
||||
export type { DspmEntry };
|
||||
|
||||
const CATALOG: DspmEntry[] = [
|
||||
{
|
||||
datastore_id: "s3://acme-customers-prod",
|
||||
service: "s3",
|
||||
classification: "PII",
|
||||
confidence: 0.96,
|
||||
risk_score: 10,
|
||||
evidence:
|
||||
"Found SSN-format strings in 7/10 sampled objects; email + full name combinations in 9/10",
|
||||
recommendation:
|
||||
"Enable SSE-KMS encryption, attach restrictive bucket policy, enable Block Public Access",
|
||||
},
|
||||
{
|
||||
datastore_id: "s3://acme-payments-archive",
|
||||
service: "s3",
|
||||
classification: "Financial",
|
||||
confidence: 0.91,
|
||||
risk_score: 9,
|
||||
evidence:
|
||||
"Detected credit card PANs (Luhn-valid) and IBAN strings in 8/10 sampled archives",
|
||||
recommendation:
|
||||
"Enable SSE-KMS, turn on versioning + Object Lock, restrict to PCI-scoped IAM roles",
|
||||
},
|
||||
{
|
||||
datastore_id: "rds://patients-db-primary",
|
||||
service: "rds",
|
||||
classification: "Health",
|
||||
confidence: 0.89,
|
||||
risk_score: 8,
|
||||
evidence:
|
||||
"Rows contain ICD-10 codes, patient identifiers, and diagnosis free-text in 10/10 sampled rows",
|
||||
recommendation:
|
||||
"Disable public accessibility, place behind a private subnet, restrict to HIPAA-scoped roles",
|
||||
},
|
||||
{
|
||||
datastore_id: "dynamodb://billing-events",
|
||||
service: "dynamodb",
|
||||
classification: "Financial",
|
||||
confidence: 0.88,
|
||||
risk_score: 8,
|
||||
evidence:
|
||||
"Items contain charge_amount, last4_cc, and merchant_id in 9/10 sampled items",
|
||||
recommendation:
|
||||
"Enable encryption at rest with customer-managed KMS, restrict global table replicas to PCI regions",
|
||||
},
|
||||
{
|
||||
datastore_id: "rds://payroll-prod",
|
||||
service: "rds",
|
||||
classification: "Financial",
|
||||
confidence: 0.93,
|
||||
risk_score: 7,
|
||||
evidence:
|
||||
"Columns include salary, tax_id, and bank_account in 10/10 sampled rows",
|
||||
recommendation:
|
||||
"Enable automated backups with 30-day retention, rotate KMS key, enforce least-privilege role",
|
||||
},
|
||||
{
|
||||
datastore_id: "dynamodb://user-sessions",
|
||||
service: "dynamodb",
|
||||
classification: "PII",
|
||||
confidence: 0.84,
|
||||
risk_score: 7,
|
||||
evidence:
|
||||
"Items contain user_email and session_token fields in 10/10 sampled items",
|
||||
recommendation:
|
||||
"Set TTL to 24h, enable PITR, rotate session signing key quarterly",
|
||||
},
|
||||
{
|
||||
datastore_id: "rds://analytics-warehouse",
|
||||
service: "rds",
|
||||
classification: "Unknown",
|
||||
confidence: 0.42,
|
||||
risk_score: 3,
|
||||
evidence:
|
||||
"Sampled rows contain aggregate counts and anonymized identifiers; insufficient signal for confident classification",
|
||||
recommendation:
|
||||
"Re-run with expanded sample size; verify anonymization invariants documented",
|
||||
},
|
||||
{
|
||||
datastore_id: "s3://acme-marketing-assets",
|
||||
service: "s3",
|
||||
classification: "Public",
|
||||
confidence: 0.99,
|
||||
risk_score: 1,
|
||||
evidence:
|
||||
"All 10 samples are PNG/JPG marketing collateral with no detected sensitive content",
|
||||
recommendation:
|
||||
"No action required; current public-read ACL is intentional",
|
||||
},
|
||||
{
|
||||
datastore_id: "dynamodb://feature-flags",
|
||||
service: "dynamodb",
|
||||
classification: "Public",
|
||||
confidence: 0.97,
|
||||
risk_score: 1,
|
||||
evidence:
|
||||
"Items contain feature names and boolean flags only; no sensitive content detected",
|
||||
recommendation: "No action required",
|
||||
},
|
||||
];
|
||||
|
||||
const SCAN_LABELS: Record<Exclude<ScanStatus, "done">, string> = {
|
||||
discovering: "Discovering datastores in your AWS environment...",
|
||||
sampling: "Sampling 10 objects from each datastore...",
|
||||
classifying: "Classifying samples with Lighthouse AI...",
|
||||
};
|
||||
|
||||
interface DspmSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const DspmSheet = ({ open, onOpenChange }: DspmSheetProps) => {
|
||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
const highRisk = CATALOG.filter((e) => e.risk_score >= 8).length;
|
||||
const isScanning = status !== null && status !== "done";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
timersRef.current.forEach(clearTimeout);
|
||||
timersRef.current = [];
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("discovering");
|
||||
timersRef.current = [
|
||||
setTimeout(() => setStatus("sampling"), 1000),
|
||||
setTimeout(() => setStatus("classifying"), 2000),
|
||||
setTimeout(() => setStatus("done"), 3200),
|
||||
];
|
||||
|
||||
return () => {
|
||||
timersRef.current.forEach(clearTimeout);
|
||||
timersRef.current = [];
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleRescan = () => {
|
||||
timersRef.current.forEach(clearTimeout);
|
||||
setStatus("discovering");
|
||||
timersRef.current = [
|
||||
setTimeout(() => setStatus("sampling"), 1000),
|
||||
setTimeout(() => setStatus("classifying"), 2000),
|
||||
setTimeout(() => setStatus("done"), 3200),
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-4 overflow-y-auto sm:max-w-3xl"
|
||||
>
|
||||
<SheetHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-amber-500" />
|
||||
DSPM Analysis
|
||||
<span className="text-text-neutral-secondary text-sm font-normal">
|
||||
· powered by Lighthouse AI
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Classified datastores discovered during your last scan
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRescan}
|
||||
disabled={isScanning}
|
||||
>
|
||||
Re-scan
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1">
|
||||
{status === "done" ? (
|
||||
<DspmTable data={CATALOG} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-6 py-4">
|
||||
<DspmScanProgress status={status ?? "discovering"} />
|
||||
<LoadingState
|
||||
label={
|
||||
SCAN_LABELS[
|
||||
(status ?? "discovering") as Exclude<ScanStatus, "done">
|
||||
]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "done" && (
|
||||
<div className="text-text-neutral-secondary border-border-neutral-secondary border-t pt-3 text-xs">
|
||||
Last scan: just now · {CATALOG.length} datastores classified ·{" "}
|
||||
{highRisk} high-risk
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleArrowRight,
|
||||
Database,
|
||||
HardDrive,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/shadcn/section/section";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RiskBadge } from "./risk-badge";
|
||||
|
||||
export type DspmService = "s3" | "rds" | "dynamodb";
|
||||
export type DspmClassification =
|
||||
| "PII"
|
||||
| "Financial"
|
||||
| "Health"
|
||||
| "Public"
|
||||
| "Unknown";
|
||||
|
||||
export interface DspmEntry {
|
||||
datastore_id: string;
|
||||
service: DspmService;
|
||||
classification: DspmClassification;
|
||||
confidence: number;
|
||||
risk_score: number;
|
||||
evidence: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
const SERVICE_ICONS = {
|
||||
s3: HardDrive,
|
||||
rds: Database,
|
||||
dynamodb: Layers,
|
||||
} as const;
|
||||
|
||||
const CLASSIFICATION_STYLES: Record<DspmClassification, string> = {
|
||||
PII: "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300",
|
||||
Financial:
|
||||
"border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900 dark:bg-orange-950 dark:text-orange-300",
|
||||
Health:
|
||||
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-900 dark:bg-purple-950 dark:text-purple-300",
|
||||
Public:
|
||||
"border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950 dark:text-green-300",
|
||||
Unknown:
|
||||
"border-neutral-200 bg-neutral-50 text-neutral-700 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300",
|
||||
};
|
||||
|
||||
const seedPrompt = (row: DspmEntry): string =>
|
||||
`Analyze this DSPM finding from our latest scan:\n\n- Datastore: ${row.datastore_id}\n- Service: ${row.service}\n- Classification: ${row.classification} (confidence ${row.confidence})\n- Risk score: ${row.risk_score}/10\n- Evidence: ${row.evidence}\n- Recommendation: ${row.recommendation}\n\nExplain the exposure and propose a Terraform fix.`;
|
||||
|
||||
interface ClassificationBadgeProps {
|
||||
classification: DspmClassification;
|
||||
}
|
||||
|
||||
const ClassificationBadge = ({ classification }: ClassificationBadgeProps) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-20 justify-center",
|
||||
CLASSIFICATION_STYLES[classification],
|
||||
)}
|
||||
>
|
||||
{classification}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
interface DspmTableProps {
|
||||
data: DspmEntry[];
|
||||
}
|
||||
|
||||
export const DspmTable = ({ data }: DspmTableProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const sorted = [...data].sort((a, b) => b.risk_score - a.risk_score);
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Datastore</TableHead>
|
||||
<TableHead>Classification</TableHead>
|
||||
<TableHead>Confidence</TableHead>
|
||||
<TableHead>Risk</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sorted.map((row) => {
|
||||
const Icon = SERVICE_ICONS[row.service];
|
||||
const isExpanded = expandedId === row.datastore_id;
|
||||
return (
|
||||
<Fragment key={row.datastore_id}>
|
||||
<TableRow
|
||||
data-state={isExpanded ? "selected" : undefined}
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
setExpandedId(isExpanded ? null : row.datastore_id)
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="text-text-neutral-secondary size-4 shrink-0" />
|
||||
<span className="font-mono text-xs">
|
||||
{row.datastore_id}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ClassificationBadge classification={row.classification} />
|
||||
</TableCell>
|
||||
<TableCell className="text-text-neutral-secondary tabular-nums">
|
||||
{Math.round(row.confidence * 100)}%
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RiskBadge score={row.risk_score} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow
|
||||
data-state="selected"
|
||||
className="cursor-default"
|
||||
>
|
||||
<TableCell colSpan={4} className="py-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="text-sm leading-tight">
|
||||
Evidence
|
||||
</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{row.evidence}
|
||||
</p>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionTitle className="text-sm leading-tight">
|
||||
Recommendation
|
||||
</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<p className="text-text-neutral-primary text-sm">
|
||||
{row.recommendation}
|
||||
</p>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
<div>
|
||||
<a
|
||||
href={`/lighthouse?${new URLSearchParams({ prompt: seedPrompt(row) }).toString()}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-900 transition-opacity hover:opacity-90"
|
||||
style={{ background: "var(--gradient-lighthouse)" }}
|
||||
>
|
||||
<CircleArrowRight className="size-5" />
|
||||
Analyze with Lighthouse AI
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RISK_BAND_STYLES = {
|
||||
low: "bg-bg-data-low",
|
||||
medium: "bg-bg-data-medium",
|
||||
high: "bg-bg-data-high",
|
||||
critical: "bg-bg-data-critical",
|
||||
} as const;
|
||||
|
||||
type RiskBand = keyof typeof RISK_BAND_STYLES;
|
||||
|
||||
const getRiskBand = (score: number): RiskBand => {
|
||||
if (score >= 8) return "critical";
|
||||
if (score >= 5) return "high";
|
||||
if (score >= 3) return "medium";
|
||||
return "low";
|
||||
};
|
||||
|
||||
interface RiskBadgeProps {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export const RiskBadge = ({ score }: RiskBadgeProps) => {
|
||||
const chipColor = RISK_BAND_STYLES[getRiskBand(score)];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn("size-3 rounded", chipColor)} />
|
||||
<span className="text-text-neutral-primary text-sm tabular-nums">
|
||||
{score}/10
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Eye,
|
||||
Pencil,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -12,6 +13,7 @@ import { useState } from "react";
|
||||
|
||||
import { getTask } from "@/actions/task";
|
||||
import { getScanErrorDetails } from "@/actions/task/task.adapter";
|
||||
import { DspmSheet } from "@/components/dspm/dspm-sheet";
|
||||
import { EditAliasModal } from "@/components/scans/edit-alias-modal";
|
||||
import {
|
||||
ScanErrorDetailsModal,
|
||||
@@ -35,6 +37,7 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
|
||||
const { toast } = useToast();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [errorOpen, setErrorOpen] = useState(false);
|
||||
const [dspmOpen, setDspmOpen] = useState(false);
|
||||
const [errorState, setErrorState] = useState<ScanErrorDetailsState>({
|
||||
kind: "idle",
|
||||
});
|
||||
@@ -132,9 +135,16 @@ export function ScanJobsRowActions({ scan }: ScanJobsRowActionsProps) {
|
||||
label="Edit"
|
||||
onSelect={() => setEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Sparkles />}
|
||||
label="DSPM analysis with Lighthouse AI"
|
||||
onSelect={() => setDspmOpen(true)}
|
||||
/>
|
||||
{/* TODO: Restore Cancel Scan once the backend exposes a public scan cancellation endpoint. */}
|
||||
</ActionDropdown>
|
||||
|
||||
<DspmSheet open={dspmOpen} onOpenChange={setDspmOpen} />
|
||||
|
||||
<EditAliasModal
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
|
||||
Reference in New Issue
Block a user