Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions prowler/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
VENV_BIN = python3 -m venv
VENV_DIR ?= .venv
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
VENV_RUN = . $(VENV_ACTIVATE)
FRONTEND_FOLDER = frontend
BACKEND_FOLDER = backend
COREPACK_EXISTS := $(shell command -v corepack)
YARN_EXISTS := $(shell command -v yarn)

INFO_COLOR = \033[0;36m
NO_COLOR = \033[m

venv: $(VENV_ACTIVATE)

$(VENV_ACTIVATE):
test -d .venv || $(VENV_BIN) .venv
$(VENV_RUN); pip install --upgrade pip setuptools plux build wheel
$(VENV_RUN); pip install -e .[dev]
touch $(VENV_DIR)/bin/activate

check-frontend-deps:
@if [ -z "$(YARN_EXISTS)" ]; then \
npm install --global yarn; \
fi
@if [ -z "$(COREPACK_EXISTS)" ]; then \
npm install -g corepack; \
fi

clean:
rm -rf .venv/
rm -rf build/
rm -rf .eggs/
rm -rf $(BACKEND_FOLDER)/*.egg-info/
rm -rf $(BACKEND_FOLDER)/localstack_prowler.egg-info/

install-backend: venv
$(VENV_RUN); python -m plux entrypoints

install-frontend: check-frontend-deps
cd $(FRONTEND_FOLDER) && yarn install

build-frontend:
@if [ ! -d "$(FRONTEND_FOLDER)/node_modules" ]; then \
$(MAKE) install-frontend; \
fi
cd $(FRONTEND_FOLDER); rm -rf build && NODE_ENV=prod npm run build

start-frontend:
cd $(FRONTEND_FOLDER); yarn start

install: venv install-backend install-frontend

dist: venv build-frontend
$(VENV_RUN); python -m build

help:
@grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "$(INFO_COLOR)%-30s$(NO_COLOR) %s\n", $$1, $$2}'

.PHONY: clean dist install install-backend install-frontend build-frontend start-frontend venv
88 changes: 88 additions & 0 deletions prowler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# LocalStack Prowler Extension

Run [Prowler](https://github.com/prowler-cloud/prowler) security checks against your LocalStack environment directly from a built-in web UI.

The extension launches Prowler as a Docker sidecar container on demand, scans your LocalStack resources, and presents the findings in a filterable, sortable table with no external tooling required.

## Install

```bash
localstack extensions install localstack-prowler
```

**Requirements**: LocalStack Pro, Docker socket available (`/var/run/docker.sock`).

## Access the UI

Once LocalStack is running with the extension loaded, open:

```
http://localhost.localstack.cloud:4566/_extension/prowler
```

From there you can choose which AWS services and severity levels to scan, click **Run Scan**, and watch findings appear in real time.

## REST API

The extension also exposes a REST API at `/_extension/prowler/api`:

| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/status` | Current scan state and summary counts |
| `POST` | `/api/scans` | Start a new scan (body: `{"services": [], "severity": []}`) |
| `GET` | `/api/scans/latest` | Full findings from the most recent completed scan |

Starting a scan while one is already running returns `409 Conflict`.

### Example

```bash
# Start a scan for S3 at critical/high severity
curl -X POST http://localhost.localstack.cloud:4566/_extension/prowler/api/scans \
-H "Content-Type: application/json" \
-d '{"services": ["s3"], "severity": ["critical", "high"]}'

# Poll until completed
curl http://localhost.localstack.cloud:4566/_extension/prowler/api/status

# Retrieve findings
curl http://localhost.localstack.cloud:4566/_extension/prowler/api/scans/latest
```

## Configuration

| Environment Variable | Default | Description |
|----------------------|---------|-------------|
| `PROWLER_LOCALSTACK_ENDPOINT` | `http://host.docker.internal:4566` | LocalStack endpoint passed to the Prowler container |
| `PROWLER_DOCKER_IMAGE` | `prowlercloud/prowler:latest` | Prowler Docker image to use |

Set these as LocalStack environment variables, e.g. via `DOCKER_FLAGS` or in your `docker-compose.yml`.

## Development

### Install local development version

```bash
make install
```

Then enable dev mode and start LocalStack:

```bash
localstack extensions dev enable .
EXTENSION_DEV_MODE=1 LOCALSTACK_AUTH_TOKEN=<token> localstack start -d
```

### Build the frontend

```bash
make install-frontend
make build-frontend
```

The compiled assets are written to `backend/localstack_prowler/static/` and served by the extension automatically.

## Licensing

- [Prowler](https://github.com/prowler-cloud/prowler) is licensed under the Apache License 2.0
- This extension is licensed under the Apache License 2.0
1 change: 1 addition & 0 deletions prowler/backend.pth
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
backend
Empty file.
Empty file.
137 changes: 137 additions & 0 deletions prowler/backend/localstack_prowler/api/web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import json
import logging

from localstack.http import Request, Response, route

from .. import static
from ..scanner import ScanInProgressError, scanner

LOG = logging.getLogger(__name__)

ALLOWED_SEVERITIES = {"critical", "high", "medium", "low", "informational"}
ALLOWED_SERVICES = {
"accessanalyzer",
"acm",
"cloudformation",
"cloudfront",
"cloudtrail",
"cloudwatch",
"cognito",
"config",
"dynamodb",
"ec2",
"ecr",
"ecs",
"eks",
"elb",
"elbv2",
"emr",
"eventbridge",
"glacier",
"glue",
"guardduty",
"iam",
"kms",
"lambda",
"opensearch",
"organizations",
"rds",
"redshift",
"route53",
"s3",
"sagemaker",
"secretsmanager",
"ses",
"shield",
"sns",
"sqs",
"ssm",
"stepfunctions",
"sts",
"transfer",
"waf",
}


def _json(data, status=200):
return Response(
response=json.dumps(data),
status=status,
mimetype="application/json",
)


class WebApp:
# Static UI

@route("/")
def index(self, request: Request, *args, **kwargs):
return Response.for_resource(static, "index.html")

@route("/<path:path>")
def static_files(self, request: Request, path: str, **kwargs):
# Route API calls through to their handlers (catch-all guard)
if path.startswith("api/"):
return _json({"error": "Not found"}, 404)
try:
return Response.for_resource(static, path)
except Exception:
return Response.for_resource(static, "index.html")

# REST API

@route("/api/status", methods=["GET"])
def api_status(self, request: Request, **kwargs):
"""Return current scan state and summary."""
state = scanner.get_state()
return _json(state.to_dict())

@route("/api/scans", methods=["POST"])
def api_start_scan(self, request: Request, **kwargs):
"""Trigger a new scan. Body (optional): {"services": [], "severity": []}"""
body = {}
if request.data:
try:
body = json.loads(request.data)
except Exception:
return _json({"error": "Invalid JSON body"}, 400)

services = body.get("services") or []
severity = body.get("severity") or []

if not isinstance(services, list):
return _json({"error": "'services' must be a list of strings"}, 400)
if not isinstance(severity, list):
return _json({"error": "'severity' must be a list of strings"}, 400)
if any(not isinstance(item, str) for item in services):
return _json({"error": "'services' must only contain strings"}, 400)
if any(not isinstance(item, str) for item in severity):
return _json({"error": "'severity' must only contain strings"}, 400)

services = [item.strip().lower() for item in services]
severity = [item.strip().lower() for item in severity]

unknown_services = [item for item in services if item not in ALLOWED_SERVICES]
if unknown_services:
return _json({"error": f"Unknown services: {', '.join(sorted(set(unknown_services)))}"}, 400)

unknown_severity = [item for item in severity if item not in ALLOWED_SEVERITIES]
if unknown_severity:
return _json({"error": f"Unknown severity: {', '.join(sorted(set(unknown_severity)))}"}, 400)

try:
scan_id = scanner.start_scan(services=services, severity=severity)
return _json({"scan_id": scan_id, "status": "running"}, 202)
except ScanInProgressError as e:
return _json({"error": str(e)}, 409)

@route("/api/scans/latest", methods=["GET"])
def api_latest_scan(self, request: Request, **kwargs):
"""Return full findings of the latest scan."""
state = scanner.get_state()
if not state.scan_id:
return _json({"error": "No scan has been run yet"}, 404)

result = state.to_dict()
result["findings"] = state.findings
return _json(result)
25 changes: 25 additions & 0 deletions prowler/backend/localstack_prowler/extension.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import logging
import typing as t

from localstack.extensions.patterns.webapp import WebAppExtension

from .api.web import WebApp
from .scanner import scanner

LOG = logging.getLogger(__name__)


class ProwlerExtension(WebAppExtension):
name = "prowler"

def __init__(self):
super().__init__(template_package_path=None)

def on_platform_ready(self):
LOG.info("Prowler extension ready — pre-pulling Docker image in background")
import threading
prefetch_thread = threading.Thread(target=scanner.prefetch_image, daemon=True)
prefetch_thread.start()

def collect_routes(self, routes: list[t.Any]):
routes.append(WebApp())
Loading