wafpass-server 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ release:
9
+ name: Build & publish release
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ id-token: write # required for PyPI trusted publishing (OIDC)
14
+
15
+ steps:
16
+ - name: Checkout
17
+ uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ token: ${{ secrets.GITHUB_TOKEN }}
21
+
22
+ - name: Compute version
23
+ id: ver
24
+ run: |
25
+ BASE=$(cat VERSION | tr -d '[:space:]')
26
+ echo "Base version: $BASE"
27
+
28
+ LATEST=$(git tag -l "v${BASE}.*" | sort -V | tail -1)
29
+ echo "Latest matching tag: ${LATEST:-none}"
30
+
31
+ if [ -z "$LATEST" ]; then
32
+ PATCH=0
33
+ else
34
+ PATCH=$(echo "$LATEST" | sed "s/v${BASE}\.//")
35
+ PATCH=$((PATCH + 1))
36
+ fi
37
+
38
+ FULL="${BASE}.${PATCH}"
39
+ TAG="v${FULL}"
40
+ echo "New version: $TAG"
41
+
42
+ echo "full=${FULL}" >> $GITHUB_OUTPUT
43
+ echo "tag=${TAG}" >> $GITHUB_OUTPUT
44
+
45
+ - name: Bump version in pyproject.toml
46
+ run: |
47
+ VER="${{ steps.ver.outputs.full }}"
48
+ sed -i "s/^version = \".*\"/version = \"${VER}\"/" pyproject.toml
49
+
50
+ - name: Set up Python
51
+ uses: actions/setup-python@v5
52
+ with:
53
+ python-version: "3.12"
54
+
55
+ - name: Install build tools
56
+ run: pip install build
57
+
58
+ - name: Build
59
+ run: python -m build
60
+
61
+ - name: Install package + test deps
62
+ run: pip install ".[dev]"
63
+
64
+ - name: Test
65
+ run: pytest
66
+
67
+ # ── Publish to PyPI (trusted publishing / OIDC) ───────────────────────────
68
+ - name: Publish to PyPI
69
+ uses: pypa/gh-action-pypi-publish@release/v1
70
+
71
+ - name: Create GitHub release
72
+ uses: softprops/action-gh-release@v2
73
+ with:
74
+ tag_name: ${{ steps.ver.outputs.tag }}
75
+ name: WAF++ PASS Server ${{ steps.ver.outputs.tag }}
76
+ generate_release_notes: true
77
+ files: |
78
+ dist/*.whl
79
+ dist/*.tar.gz
@@ -0,0 +1,22 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN pip install --no-cache-dir hatchling build
6
+
7
+ # Install wafpass-core from the local sibling package (pass/).
8
+ # This must happen before wafpass-server is installed so that
9
+ # `from wafpass.control_schema import WizardControl` resolves correctly.
10
+ COPY pass/ /tmp/wafpass-core/
11
+ RUN pip install --no-cache-dir /tmp/wafpass-core && rm -rf /tmp/wafpass-core
12
+
13
+ COPY wafpass-server/pyproject.toml wafpass-server/VERSION wafpass-server/README.md ./
14
+ COPY wafpass-server/wafpass_server/ wafpass_server/
15
+ COPY wafpass-server/alembic/ alembic/
16
+ COPY wafpass-server/alembic.ini wafpass-server/entrypoint.sh ./
17
+
18
+ RUN pip install --no-cache-dir . && chmod +x entrypoint.sh
19
+
20
+ EXPOSE 8000
21
+
22
+ ENTRYPOINT ["./entrypoint.sh"]
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: wafpass-server
3
+ Version: 0.3.4
4
+ Summary: WAF++ PASS – API server for persisting and querying scan results
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: alembic>=1.13
8
+ Requires-Dist: asyncpg>=0.29
9
+ Requires-Dist: fastapi>=0.100
10
+ Requires-Dist: pydantic-settings>=2.0
11
+ Requires-Dist: pydantic>=2.0
12
+ Requires-Dist: python-dotenv>=1.0
13
+ Requires-Dist: sqlalchemy>=2.0
14
+ Requires-Dist: uvicorn[standard]>=0.23
15
+ Provides-Extra: dev
16
+ Requires-Dist: httpx>=0.26; extra == 'dev'
17
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
18
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
19
+ Requires-Dist: pytest>=7.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # wafpass-server
23
+
24
+ REST API for persisting and querying WAF++ PASS scan results.
25
+
26
+ Receives `wafpass-result.json` payloads from `wafpass check --output json`,
27
+ stores them in PostgreSQL, and exposes them to the dashboard and CI tooling.
28
+
29
+ ## API endpoints
30
+
31
+ | Method | Path | Description |
32
+ |--------|------|-------------|
33
+ | `POST` | `/runs` | Ingest a `wafpass-result.json` payload |
34
+ | `GET` | `/runs` | List runs (query: `limit`, `offset`, `project`) |
35
+ | `GET` | `/runs/{id}` | Single run with all findings |
36
+ | `GET` | `/runs/{id}/findings` | Findings only (query: `severity`, `pillar`, `status`) |
37
+ | `GET` | `/health` | Health check |
38
+ | `GET` | `/api/docs` | Swagger UI |
39
+
40
+ ## Setup
41
+
42
+ ### Environment variables
43
+
44
+ Copy `.env.example` from the repo root:
45
+
46
+ ```
47
+ DATABASE_URL=postgresql+asyncpg://wafpass:changeme@localhost:5432/wafpass
48
+ WAFPASS_ENV=local
49
+ CORS_ORIGINS=http://localhost:5173,http://localhost:3000
50
+ ```
51
+
52
+ ### Run locally
53
+
54
+ ```bash
55
+ pip install -e ".[dev]"
56
+ alembic upgrade head
57
+ uvicorn wafpass_server.main:app --reload --port 8000
58
+ ```
59
+
60
+ ### Run migrations
61
+
62
+ ```bash
63
+ alembic upgrade head # apply all migrations
64
+ alembic downgrade -1 # roll back one step
65
+ alembic revision --autogenerate -m "add column" # generate new migration
66
+ ```
67
+
68
+ ### Docker
69
+
70
+ ```bash
71
+ docker build -t wafpass-server .
72
+ docker run -e DATABASE_URL=... -p 8000:8000 wafpass-server
73
+ ```
74
+
75
+ ### docker-compose (full stack)
76
+
77
+ From the repo root:
78
+
79
+ ```bash
80
+ cp .env.example .env # fill in passwords
81
+ docker compose up
82
+ ```
83
+
84
+ ## Posting a scan result
85
+
86
+ ```bash
87
+ wafpass check infra/ --output json > result.json
88
+ curl -X POST http://localhost:8000/runs \
89
+ -H "Content-Type: application/json" \
90
+ -d @result.json
91
+ ```
92
+
93
+ Or set metadata fields before posting:
94
+
95
+ ```python
96
+ import json, httpx
97
+
98
+ result = json.load(open("result.json"))
99
+ result.update({"project": "my-infra", "branch": "main", "git_sha": "abc1234"})
100
+ httpx.post("http://localhost:8000/runs", json=result)
101
+ ```
102
+
103
+ ## Result schema
104
+
105
+ The payload shape is defined by `WafpassResultSchema` in `wafpass-core`
106
+ (`wafpass/schema.py`). `wafpass-server` mirrors that schema in
107
+ `wafpass_server/schemas.py` (`RunCreate`). Once `wafpass-core` is published
108
+ to PyPI, replace the local definition with a direct import.
109
+
110
+ Key fields stored per run:
111
+
112
+ | Column | Type | Description |
113
+ |--------|------|-------------|
114
+ | `id` | uuid | Auto-generated primary key |
115
+ | `project` | text | Repo / project name |
116
+ | `branch` | text | VCS branch |
117
+ | `git_sha` | text | Commit SHA |
118
+ | `triggered_by` | text | `local` \| `github-actions` \| `gitlab-ci` \| … |
119
+ | `iac_framework` | text | `terraform` \| `cdk` \| … |
120
+ | `score` | int | Overall compliance score (0–100) |
121
+ | `pillar_scores` | jsonb | Per-pillar scores `{"SEC": 90, …}` |
122
+ | `findings` | jsonb | Array of check results |
123
+ | `created_at` | timestamptz | Inserted at |
124
+
125
+ ## Development
126
+
127
+ ```bash
128
+ pip install -e ".[dev]"
129
+ pytest
130
+ ```
@@ -0,0 +1,109 @@
1
+ # wafpass-server
2
+
3
+ REST API for persisting and querying WAF++ PASS scan results.
4
+
5
+ Receives `wafpass-result.json` payloads from `wafpass check --output json`,
6
+ stores them in PostgreSQL, and exposes them to the dashboard and CI tooling.
7
+
8
+ ## API endpoints
9
+
10
+ | Method | Path | Description |
11
+ |--------|------|-------------|
12
+ | `POST` | `/runs` | Ingest a `wafpass-result.json` payload |
13
+ | `GET` | `/runs` | List runs (query: `limit`, `offset`, `project`) |
14
+ | `GET` | `/runs/{id}` | Single run with all findings |
15
+ | `GET` | `/runs/{id}/findings` | Findings only (query: `severity`, `pillar`, `status`) |
16
+ | `GET` | `/health` | Health check |
17
+ | `GET` | `/api/docs` | Swagger UI |
18
+
19
+ ## Setup
20
+
21
+ ### Environment variables
22
+
23
+ Copy `.env.example` from the repo root:
24
+
25
+ ```
26
+ DATABASE_URL=postgresql+asyncpg://wafpass:changeme@localhost:5432/wafpass
27
+ WAFPASS_ENV=local
28
+ CORS_ORIGINS=http://localhost:5173,http://localhost:3000
29
+ ```
30
+
31
+ ### Run locally
32
+
33
+ ```bash
34
+ pip install -e ".[dev]"
35
+ alembic upgrade head
36
+ uvicorn wafpass_server.main:app --reload --port 8000
37
+ ```
38
+
39
+ ### Run migrations
40
+
41
+ ```bash
42
+ alembic upgrade head # apply all migrations
43
+ alembic downgrade -1 # roll back one step
44
+ alembic revision --autogenerate -m "add column" # generate new migration
45
+ ```
46
+
47
+ ### Docker
48
+
49
+ ```bash
50
+ docker build -t wafpass-server .
51
+ docker run -e DATABASE_URL=... -p 8000:8000 wafpass-server
52
+ ```
53
+
54
+ ### docker-compose (full stack)
55
+
56
+ From the repo root:
57
+
58
+ ```bash
59
+ cp .env.example .env # fill in passwords
60
+ docker compose up
61
+ ```
62
+
63
+ ## Posting a scan result
64
+
65
+ ```bash
66
+ wafpass check infra/ --output json > result.json
67
+ curl -X POST http://localhost:8000/runs \
68
+ -H "Content-Type: application/json" \
69
+ -d @result.json
70
+ ```
71
+
72
+ Or set metadata fields before posting:
73
+
74
+ ```python
75
+ import json, httpx
76
+
77
+ result = json.load(open("result.json"))
78
+ result.update({"project": "my-infra", "branch": "main", "git_sha": "abc1234"})
79
+ httpx.post("http://localhost:8000/runs", json=result)
80
+ ```
81
+
82
+ ## Result schema
83
+
84
+ The payload shape is defined by `WafpassResultSchema` in `wafpass-core`
85
+ (`wafpass/schema.py`). `wafpass-server` mirrors that schema in
86
+ `wafpass_server/schemas.py` (`RunCreate`). Once `wafpass-core` is published
87
+ to PyPI, replace the local definition with a direct import.
88
+
89
+ Key fields stored per run:
90
+
91
+ | Column | Type | Description |
92
+ |--------|------|-------------|
93
+ | `id` | uuid | Auto-generated primary key |
94
+ | `project` | text | Repo / project name |
95
+ | `branch` | text | VCS branch |
96
+ | `git_sha` | text | Commit SHA |
97
+ | `triggered_by` | text | `local` \| `github-actions` \| `gitlab-ci` \| … |
98
+ | `iac_framework` | text | `terraform` \| `cdk` \| … |
99
+ | `score` | int | Overall compliance score (0–100) |
100
+ | `pillar_scores` | jsonb | Per-pillar scores `{"SEC": 90, …}` |
101
+ | `findings` | jsonb | Array of check results |
102
+ | `created_at` | timestamptz | Inserted at |
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ pip install -e ".[dev]"
108
+ pytest
109
+ ```
@@ -0,0 +1 @@
1
+ 0.3
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from logging.config import fileConfig
6
+
7
+ from alembic import context
8
+ from sqlalchemy import pool
9
+
10
+ from wafpass_server.database import Base
11
+ from wafpass_server import models as _models # noqa: F401 – ensure models are registered
12
+
13
+ config = context.config
14
+ if config.config_file_name is not None:
15
+ fileConfig(config.config_file_name)
16
+
17
+ # Override sqlalchemy.url from DATABASE_URL env var when running in Docker / CI.
18
+ # Falls back to the value in alembic.ini for local development.
19
+ if os.environ.get("DATABASE_URL"):
20
+ config.set_main_option("sqlalchemy.url", os.environ["DATABASE_URL"])
21
+
22
+ target_metadata = Base.metadata
23
+
24
+
25
+ def run_migrations_offline() -> None:
26
+ url = config.get_main_option("sqlalchemy.url")
27
+ context.configure(
28
+ url=url,
29
+ target_metadata=target_metadata,
30
+ literal_binds=True,
31
+ dialect_opts={"paramstyle": "named"},
32
+ )
33
+ with context.begin_transaction():
34
+ context.run_migrations()
35
+
36
+
37
+ def do_run_migrations(connection) -> None:
38
+ context.configure(connection=connection, target_metadata=target_metadata)
39
+ with context.begin_transaction():
40
+ context.run_migrations()
41
+
42
+
43
+ async def run_async_migrations() -> None:
44
+ from sqlalchemy.ext.asyncio import create_async_engine
45
+
46
+ url = config.get_main_option("sqlalchemy.url")
47
+ connectable = create_async_engine(url, poolclass=pool.NullPool)
48
+ async with connectable.connect() as connection:
49
+ await connection.run_sync(do_run_migrations)
50
+ await connectable.dispose()
51
+
52
+
53
+ def run_migrations_online() -> None:
54
+ asyncio.run(run_async_migrations())
55
+
56
+
57
+ if context.is_offline_mode():
58
+ run_migrations_offline()
59
+ else:
60
+ run_migrations_online()
@@ -0,0 +1,27 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ ${imports if imports else ""}
15
+
16
+ revision: str = ${repr(up_revision)}
17
+ down_revision: Union[str, None] = ${repr(down_revision)}
18
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20
+
21
+
22
+ def upgrade() -> None:
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,45 @@
1
+ """create runs table
2
+
3
+ Revision ID: 0001
4
+ Revises:
5
+ Create Date: 2026-01-01 00:00:00.000000
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ revision = "0001"
14
+ down_revision = None
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.create_table(
21
+ "runs",
22
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
23
+ sa.Column("project", sa.Text(), nullable=False, server_default=""),
24
+ sa.Column("branch", sa.Text(), nullable=False, server_default=""),
25
+ sa.Column("git_sha", sa.Text(), nullable=False, server_default=""),
26
+ sa.Column("triggered_by", sa.Text(), nullable=False, server_default="local"),
27
+ sa.Column("iac_framework", sa.Text(), nullable=False, server_default="terraform"),
28
+ sa.Column("score", sa.Integer(), nullable=False, server_default="0"),
29
+ sa.Column("pillar_scores", postgresql.JSONB(), nullable=False, server_default="{}"),
30
+ sa.Column("findings", postgresql.JSONB(), nullable=False, server_default="[]"),
31
+ sa.Column(
32
+ "created_at",
33
+ sa.DateTime(timezone=True),
34
+ nullable=False,
35
+ server_default=sa.text("now()"),
36
+ ),
37
+ )
38
+ op.create_index("ix_runs_project", "runs", ["project"])
39
+ op.create_index("ix_runs_created_at", "runs", ["created_at"])
40
+
41
+
42
+ def downgrade() -> None:
43
+ op.drop_index("ix_runs_created_at", "runs")
44
+ op.drop_index("ix_runs_project", "runs")
45
+ op.drop_table("runs")
@@ -0,0 +1,32 @@
1
+ """add run metadata columns
2
+
3
+ Revision ID: 0002
4
+ Revises: 0001
5
+ Create Date: 2026-01-02 00:00:00.000000
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ revision = "0002"
14
+ down_revision = "0001"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.add_column("runs", sa.Column("path", sa.Text(), nullable=False, server_default=""))
21
+ op.add_column("runs", sa.Column("controls_loaded", sa.Integer(), nullable=False, server_default="0"))
22
+ op.add_column("runs", sa.Column("controls_run", sa.Integer(), nullable=False, server_default="0"))
23
+ op.add_column("runs", sa.Column("detected_regions", postgresql.JSONB(), nullable=False, server_default="[]"))
24
+ op.add_column("runs", sa.Column("source_paths", postgresql.JSONB(), nullable=False, server_default="[]"))
25
+
26
+
27
+ def downgrade() -> None:
28
+ op.drop_column("runs", "source_paths")
29
+ op.drop_column("runs", "detected_regions")
30
+ op.drop_column("runs", "controls_run")
31
+ op.drop_column("runs", "controls_loaded")
32
+ op.drop_column("runs", "path")
@@ -0,0 +1,24 @@
1
+ """add controls_meta column
2
+
3
+ Revision ID: 0003
4
+ Revises: 0002
5
+ Create Date: 2026-03-28 00:00:00.000000
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ revision = "0003"
14
+ down_revision = "0002"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.add_column("runs", sa.Column("controls_meta", postgresql.JSONB(), nullable=False, server_default="[]"))
21
+
22
+
23
+ def downgrade() -> None:
24
+ op.drop_column("runs", "controls_meta")
@@ -0,0 +1,24 @@
1
+ """add plan_changes column
2
+
3
+ Revision ID: 0004
4
+ Revises: 0003
5
+ Create Date: 2026-03-28 00:00:00.000000
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from alembic import op
10
+ import sqlalchemy as sa
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ revision = "0004"
14
+ down_revision = "0003"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.add_column("runs", sa.Column("plan_changes", postgresql.JSONB(), nullable=True))
21
+
22
+
23
+ def downgrade() -> None:
24
+ op.drop_column("runs", "plan_changes")
@@ -0,0 +1,59 @@
1
+ """add controls table
2
+
3
+ Revision ID: 0005
4
+ Revises: 0004
5
+ Create Date: 2026-03-29 00:00:00.000000
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+ from sqlalchemy.dialects import postgresql
12
+
13
+ revision = "0005"
14
+ down_revision = "0004"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.create_table(
21
+ "controls",
22
+ sa.Column("id", sa.Text, primary_key=True),
23
+ sa.Column("pillar", sa.Text, nullable=False, server_default=""),
24
+ sa.Column("severity", sa.Text, nullable=False, server_default=""),
25
+ sa.Column(
26
+ "type",
27
+ postgresql.JSONB(astext_type=sa.Text()),
28
+ nullable=False,
29
+ server_default=sa.text("'[]'::jsonb"),
30
+ ),
31
+ sa.Column("description", sa.Text, nullable=False, server_default=""),
32
+ sa.Column(
33
+ "checks",
34
+ postgresql.JSONB(astext_type=sa.Text()),
35
+ nullable=False,
36
+ server_default=sa.text("'[]'::jsonb"),
37
+ ),
38
+ sa.Column("source", sa.Text, nullable=False, server_default="wafpass"),
39
+ sa.Column(
40
+ "created_at",
41
+ sa.DateTime(timezone=True),
42
+ nullable=False,
43
+ server_default=sa.text("now()"),
44
+ ),
45
+ sa.Column(
46
+ "updated_at",
47
+ sa.DateTime(timezone=True),
48
+ nullable=False,
49
+ server_default=sa.text("now()"),
50
+ ),
51
+ )
52
+ op.create_index("ix_controls_pillar", "controls", ["pillar"])
53
+ op.create_index("ix_controls_severity", "controls", ["severity"])
54
+
55
+
56
+ def downgrade() -> None:
57
+ op.drop_index("ix_controls_severity", table_name="controls")
58
+ op.drop_index("ix_controls_pillar", table_name="controls")
59
+ op.drop_table("controls")
@@ -0,0 +1,41 @@
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+ version_path_separator = os
5
+ sqlalchemy.url = postgresql+asyncpg://wafpass:wafpass@localhost:5432/wafpass
6
+
7
+ [post_write_hooks]
8
+
9
+ [loggers]
10
+ keys = root,sqlalchemy,alembic
11
+
12
+ [handlers]
13
+ keys = console
14
+
15
+ [formatters]
16
+ keys = generic
17
+
18
+ [logger_root]
19
+ level = WARN
20
+ handlers = console
21
+ qualname =
22
+
23
+ [logger_sqlalchemy]
24
+ level = WARN
25
+ handlers =
26
+ qualname = sqlalchemy.engine
27
+
28
+ [logger_alembic]
29
+ level = INFO
30
+ handlers =
31
+ qualname = alembic
32
+
33
+ [handler_console]
34
+ class = StreamHandler
35
+ args = (sys.stderr,)
36
+ level = NOTSET
37
+ formatter = generic
38
+
39
+ [formatter_generic]
40
+ format = %(levelname)-5.5s [%(name)s] %(message)s
41
+ datefmt = %H:%M:%S
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ echo "DATABASE_URL: $DATABASE_URL"
5
+
6
+ python - <<'EOF'
7
+ import os, sys
8
+ from alembic.config import Config
9
+ from alembic import command
10
+
11
+ url = os.environ.get("DATABASE_URL")
12
+ if not url:
13
+ print("ERROR: DATABASE_URL is not set", file=sys.stderr)
14
+ sys.exit(1)
15
+
16
+ cfg = Config("alembic.ini")
17
+ cfg.set_main_option("sqlalchemy.url", url)
18
+ command.upgrade(cfg, "head")
19
+ print("Migrations complete.")
20
+ EOF
21
+
22
+ exec uvicorn wafpass_server.main:app --host 0.0.0.0 --port 8000
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wafpass-server"
7
+ version = "0.3.4"
8
+ description = "WAF++ PASS – API server for persisting and querying scan results"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "fastapi>=0.100",
14
+ "uvicorn[standard]>=0.23",
15
+ "sqlalchemy>=2.0",
16
+ "asyncpg>=0.29",
17
+ "alembic>=1.13",
18
+ "pydantic>=2.0",
19
+ "pydantic-settings>=2.0",
20
+ "python-dotenv>=1.0",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = [
25
+ "pytest>=7.0",
26
+ "pytest-asyncio>=0.23",
27
+ "httpx>=0.26",
28
+ "pytest-cov>=4.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ wafpass-server = "wafpass_server.main:start"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["wafpass_server"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ addopts = "-v"
40
+ asyncio_mode = "auto"
File without changes
@@ -0,0 +1,14 @@
1
+ """Smoke tests for the /runs endpoints.
2
+
3
+ Integration tests require a running PostgreSQL instance. Set DATABASE_URL
4
+ in the environment or .env file before running.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import pytest
9
+
10
+
11
+ @pytest.mark.asyncio
12
+ async def test_placeholder() -> None:
13
+ """Placeholder — replace with real integration tests once DB is available."""
14
+ assert True
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
2
+
3
+ try:
4
+ __version__ = _pkg_version("wafpass-server")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.3.0-dev"
@@ -0,0 +1,21 @@
1
+ """Application configuration via environment variables."""
2
+ from __future__ import annotations
3
+
4
+ from pydantic_settings import BaseSettings, SettingsConfigDict
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
9
+
10
+ database_url: str = "postgresql+asyncpg://wafpass:wafpass@localhost:5432/wafpass"
11
+ wafpass_env: str = "local"
12
+
13
+ # CORS origins (comma-separated)
14
+ cors_origins: str = "http://localhost:5173,http://localhost:3000"
15
+
16
+ @property
17
+ def cors_origins_list(self) -> list[str]:
18
+ return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
19
+
20
+
21
+ settings = Settings()
@@ -0,0 +1,21 @@
1
+ """Async SQLAlchemy engine and session factory."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import AsyncGenerator
5
+
6
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
7
+ from sqlalchemy.orm import DeclarativeBase
8
+
9
+ from wafpass_server.config import settings
10
+
11
+ engine = create_async_engine(settings.database_url, echo=False, pool_pre_ping=True)
12
+ AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
13
+
14
+
15
+ class Base(DeclarativeBase):
16
+ pass
17
+
18
+
19
+ async def get_db() -> AsyncGenerator[AsyncSession, None]:
20
+ async with AsyncSessionLocal() as session:
21
+ yield session
@@ -0,0 +1,46 @@
1
+ """WAF++ PASS server entry point."""
2
+ from __future__ import annotations
3
+
4
+ import uvicorn
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ from wafpass_server.config import settings
9
+ from wafpass_server.routers.controls import router as controls_router
10
+ from wafpass_server.routers.runs import router as runs_router
11
+
12
+ app = FastAPI(
13
+ title="wafpass-server",
14
+ version="0.3.0",
15
+ description="REST API for persisting and querying WAF++ PASS scan results.",
16
+ docs_url="/api/docs",
17
+ redoc_url="/api/redoc",
18
+ openapi_tags=[
19
+ {"name": "runs", "description": "Scan run results ingestion and retrieval."},
20
+ {"name": "controls", "description": "WAF++ control catalogue management."},
21
+ ],
22
+ )
23
+
24
+ app.add_middleware(
25
+ CORSMiddleware,
26
+ allow_origins=settings.cors_origins_list,
27
+ allow_credentials=True,
28
+ allow_methods=["*"],
29
+ allow_headers=["*"],
30
+ )
31
+
32
+ app.include_router(runs_router)
33
+ app.include_router(controls_router)
34
+
35
+
36
+ @app.get("/health", tags=["health"])
37
+ async def health() -> dict[str, str]:
38
+ return {"status": "ok"}
39
+
40
+
41
+ def start() -> None:
42
+ uvicorn.run("wafpass_server.main:app", host="0.0.0.0", port=8000, reload=False)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ start()
@@ -0,0 +1,51 @@
1
+ """SQLAlchemy ORM models."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+
7
+ from sqlalchemy import DateTime, Integer, Text
8
+ from sqlalchemy.dialects.postgresql import JSONB, UUID
9
+ from sqlalchemy.orm import Mapped, mapped_column
10
+
11
+ from wafpass_server.database import Base
12
+
13
+
14
+ def _now() -> datetime:
15
+ return datetime.now(timezone.utc)
16
+
17
+
18
+ class Control(Base):
19
+ __tablename__ = "controls"
20
+
21
+ id: Mapped[str] = mapped_column(Text, primary_key=True)
22
+ pillar: Mapped[str] = mapped_column(Text, default="")
23
+ severity: Mapped[str] = mapped_column(Text, default="")
24
+ type: Mapped[list] = mapped_column(JSONB, default=list)
25
+ description: Mapped[str] = mapped_column(Text, default="")
26
+ checks: Mapped[list] = mapped_column(JSONB, default=list)
27
+ source: Mapped[str] = mapped_column(Text, default="wafpass")
28
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
29
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
30
+
31
+
32
+ class Run(Base):
33
+ __tablename__ = "runs"
34
+
35
+ id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
36
+ project: Mapped[str] = mapped_column(Text, default="")
37
+ branch: Mapped[str] = mapped_column(Text, default="")
38
+ git_sha: Mapped[str] = mapped_column(Text, default="")
39
+ triggered_by: Mapped[str] = mapped_column(Text, default="local")
40
+ iac_framework: Mapped[str] = mapped_column(Text, default="terraform")
41
+ score: Mapped[int] = mapped_column(Integer, default=0)
42
+ pillar_scores: Mapped[dict] = mapped_column(JSONB, default=dict)
43
+ findings: Mapped[list] = mapped_column(JSONB, default=list)
44
+ path: Mapped[str] = mapped_column(Text, default="")
45
+ controls_loaded: Mapped[int] = mapped_column(Integer, default=0)
46
+ controls_run: Mapped[int] = mapped_column(Integer, default=0)
47
+ detected_regions: Mapped[list] = mapped_column(JSONB, default=list)
48
+ source_paths: Mapped[list] = mapped_column(JSONB, default=list)
49
+ controls_meta: Mapped[list] = mapped_column(JSONB, default=list)
50
+ plan_changes: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
51
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -0,0 +1,119 @@
1
+ """POST/GET/DELETE /controls endpoints."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, Depends, HTTPException, Query
7
+ from sqlalchemy import func, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ from wafpass_server.database import get_db
11
+ from wafpass_server.models import Control, _now
12
+ from wafpass_server.schemas import ControlIn, ControlOut, Envelope, Meta
13
+
14
+ router = APIRouter(prefix="/controls", tags=["controls"])
15
+
16
+
17
+ # ── Helpers ───────────────────────────────────────────────────────────────────
18
+
19
+
20
+ def _to_out(ctrl: Control) -> ControlOut:
21
+ return ControlOut.model_validate(ctrl, from_attributes=True)
22
+
23
+
24
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
25
+
26
+
27
+ @router.post("", response_model=Envelope[ControlOut], status_code=200)
28
+ async def upsert_control(
29
+ payload: ControlIn,
30
+ db: Annotated[AsyncSession, Depends(get_db)],
31
+ ) -> Envelope[ControlOut]:
32
+ """Create or update a control (idempotent upsert on ``id``)."""
33
+ ctrl = await db.get(Control, payload.id)
34
+
35
+ if ctrl is None:
36
+ ctrl = Control(
37
+ id=payload.id,
38
+ pillar=payload.pillar,
39
+ severity=payload.severity,
40
+ type=list(payload.type),
41
+ description=payload.description,
42
+ checks=[c.model_dump() for c in payload.checks],
43
+ source=payload.source,
44
+ )
45
+ db.add(ctrl)
46
+ else:
47
+ ctrl.pillar = payload.pillar
48
+ ctrl.severity = payload.severity
49
+ ctrl.type = list(payload.type)
50
+ ctrl.description = payload.description
51
+ ctrl.checks = [c.model_dump() for c in payload.checks]
52
+ ctrl.source = payload.source
53
+ ctrl.updated_at = _now()
54
+
55
+ await db.commit()
56
+ await db.refresh(ctrl)
57
+ return Envelope(data=_to_out(ctrl))
58
+
59
+
60
+ @router.get("", response_model=Envelope[list[ControlOut]])
61
+ async def list_controls(
62
+ db: Annotated[AsyncSession, Depends(get_db)],
63
+ pillar: str | None = Query(default=None, description="Filter by pillar name."),
64
+ severity: str | None = Query(default=None, description="Filter by severity level."),
65
+ page: int = Query(default=1, ge=1, description="1-based page number."),
66
+ per_page: int = Query(default=50, ge=1, le=200, description="Results per page."),
67
+ ) -> Envelope[list[ControlOut]]:
68
+ """List controls, optionally filtered by pillar and/or severity."""
69
+ base = select(Control)
70
+ count_base = select(func.count()).select_from(Control)
71
+
72
+ if pillar:
73
+ base = base.where(Control.pillar == pillar.lower())
74
+ count_base = count_base.where(Control.pillar == pillar.lower())
75
+ if severity:
76
+ base = base.where(Control.severity == severity.lower())
77
+ count_base = count_base.where(Control.severity == severity.lower())
78
+
79
+ total: int = (await db.execute(count_base)).scalar() or 0
80
+
81
+ offset = (page - 1) * per_page
82
+ stmt = base.order_by(Control.created_at.desc()).limit(per_page).offset(offset)
83
+ result = await db.execute(stmt)
84
+ controls = list(result.scalars().all())
85
+
86
+ return Envelope(
87
+ data=[_to_out(c) for c in controls],
88
+ meta=Meta(total=total, page=page, per_page=per_page),
89
+ )
90
+
91
+
92
+ @router.get("/{control_id}", response_model=Envelope[ControlOut])
93
+ async def get_control(
94
+ control_id: str,
95
+ db: Annotated[AsyncSession, Depends(get_db)],
96
+ ) -> Envelope[ControlOut]:
97
+ """Return a single control by ID."""
98
+ ctrl = await db.get(Control, control_id.upper())
99
+ if ctrl is None:
100
+ # Also try as-provided (case-sensitive stored IDs)
101
+ ctrl = await db.get(Control, control_id)
102
+ if ctrl is None:
103
+ raise HTTPException(status_code=404, detail=f"Control '{control_id}' not found")
104
+ return Envelope(data=_to_out(ctrl))
105
+
106
+
107
+ @router.delete("/{control_id}", status_code=204)
108
+ async def delete_control(
109
+ control_id: str,
110
+ db: Annotated[AsyncSession, Depends(get_db)],
111
+ ) -> None:
112
+ """Remove a control by ID."""
113
+ ctrl = await db.get(Control, control_id.upper())
114
+ if ctrl is None:
115
+ ctrl = await db.get(Control, control_id)
116
+ if ctrl is None:
117
+ raise HTTPException(status_code=404, detail=f"Control '{control_id}' not found")
118
+ await db.delete(ctrl)
119
+ await db.commit()
@@ -0,0 +1,101 @@
1
+ """POST/GET /runs endpoints."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException, Query
8
+ from sqlalchemy import select
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
+
11
+ from wafpass_server.database import get_db
12
+ from wafpass_server.models import Run
13
+ from wafpass_server.schemas import ControlMetaSchema, FindingSchema, RunCreate, RunDetail, RunSummary
14
+
15
+ router = APIRouter(prefix="/runs", tags=["runs"])
16
+
17
+
18
+ @router.post("", response_model=RunSummary, status_code=201)
19
+ async def create_run(
20
+ payload: RunCreate,
21
+ db: Annotated[AsyncSession, Depends(get_db)],
22
+ ) -> Run:
23
+ run = Run(
24
+ project=payload.project,
25
+ branch=payload.branch,
26
+ git_sha=payload.git_sha,
27
+ triggered_by=payload.triggered_by,
28
+ iac_framework=payload.iac_framework,
29
+ score=payload.score,
30
+ pillar_scores=payload.pillar_scores,
31
+ findings=[f.model_dump() for f in payload.findings],
32
+ path=payload.path,
33
+ controls_loaded=payload.controls_loaded,
34
+ controls_run=payload.controls_run,
35
+ detected_regions=payload.detected_regions,
36
+ source_paths=payload.source_paths,
37
+ controls_meta=[c.model_dump() for c in payload.controls_meta],
38
+ plan_changes=payload.plan_changes,
39
+ )
40
+ db.add(run)
41
+ await db.commit()
42
+ await db.refresh(run)
43
+ return run
44
+
45
+
46
+ @router.get("", response_model=list[RunSummary])
47
+ async def list_runs(
48
+ db: Annotated[AsyncSession, Depends(get_db)],
49
+ limit: int = Query(default=50, ge=1, le=200),
50
+ offset: int = Query(default=0, ge=0),
51
+ project: str | None = Query(default=None),
52
+ ) -> list[Run]:
53
+ stmt = select(Run).order_by(Run.created_at.desc()).limit(limit).offset(offset)
54
+ if project:
55
+ stmt = stmt.where(Run.project == project)
56
+ result = await db.execute(stmt)
57
+ return list(result.scalars().all())
58
+
59
+
60
+ @router.get("/{run_id}", response_model=RunDetail)
61
+ async def get_run(
62
+ run_id: uuid.UUID,
63
+ db: Annotated[AsyncSession, Depends(get_db)],
64
+ ) -> Run:
65
+ run = await db.get(Run, run_id)
66
+ if run is None:
67
+ raise HTTPException(status_code=404, detail="Run not found")
68
+ return run
69
+
70
+
71
+ @router.get("/{run_id}/controls", response_model=list[ControlMetaSchema])
72
+ async def get_controls(
73
+ run_id: uuid.UUID,
74
+ db: Annotated[AsyncSession, Depends(get_db)],
75
+ ) -> list[dict]:
76
+ run = await db.get(Run, run_id)
77
+ if run is None:
78
+ raise HTTPException(status_code=404, detail="Run not found")
79
+ return run.controls_meta or []
80
+
81
+
82
+ @router.get("/{run_id}/findings", response_model=list[FindingSchema])
83
+ async def get_findings(
84
+ run_id: uuid.UUID,
85
+ db: Annotated[AsyncSession, Depends(get_db)],
86
+ severity: str | None = Query(default=None),
87
+ pillar: str | None = Query(default=None),
88
+ status: str | None = Query(default=None),
89
+ ) -> list[dict]:
90
+ run = await db.get(Run, run_id)
91
+ if run is None:
92
+ raise HTTPException(status_code=404, detail="Run not found")
93
+
94
+ findings: list[dict] = run.findings or []
95
+ if severity:
96
+ findings = [f for f in findings if f.get("severity", "").upper() == severity.upper()]
97
+ if pillar:
98
+ findings = [f for f in findings if f.get("pillar", "").upper() == pillar.upper()]
99
+ if status:
100
+ findings = [f for f in findings if f.get("status", "").upper() == status.upper()]
101
+ return findings
@@ -0,0 +1,136 @@
1
+ """Pydantic schemas for the API layer."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from datetime import datetime
6
+ from typing import Any, Generic, TypeVar
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+
10
+ # Re-export control schema types from wafpass-core so callers only need one import.
11
+ from wafpass.control_schema import WizardCheck, WizardControl # noqa: F401
12
+
13
+ # ── Generic response envelope ─────────────────────────────────────────────────
14
+
15
+ T = TypeVar("T")
16
+
17
+
18
+ class Meta(BaseModel):
19
+ total: int | None = None
20
+ page: int | None = None
21
+ per_page: int | None = None
22
+
23
+
24
+ class Envelope(BaseModel, Generic[T]):
25
+ """Consistent API response wrapper used by all /controls endpoints."""
26
+
27
+ data: T
28
+ meta: Meta = Field(default_factory=Meta)
29
+
30
+
31
+ class FindingSchema(BaseModel):
32
+ check_id: str
33
+ check_title: str
34
+ control_id: str
35
+ pillar: str = ""
36
+ severity: str
37
+ status: str
38
+ resource: str
39
+ message: str
40
+ remediation: str
41
+ example: dict[str, Any] | None = None
42
+
43
+
44
+ class ControlCheckMetaSchema(BaseModel):
45
+ id: str
46
+ title: str
47
+ severity: str
48
+ remediation: str = ""
49
+ example: dict[str, Any] | None = None
50
+
51
+
52
+ class ControlMetaSchema(BaseModel):
53
+ id: str
54
+ title: str
55
+ pillar: str
56
+ severity: str
57
+ category: str = ""
58
+ description: str = ""
59
+ rationale: str = ""
60
+ threat: list[str] = Field(default_factory=list)
61
+ regulatory_mapping: list[dict[str, Any]] = Field(default_factory=list)
62
+ checks: list[ControlCheckMetaSchema] = Field(default_factory=list)
63
+
64
+
65
+ class RunCreate(BaseModel):
66
+ """Payload accepted by POST /runs — matches wafpass-result.json schema."""
67
+ schema_version: str = "1.0"
68
+ project: str = ""
69
+ branch: str = ""
70
+ git_sha: str = ""
71
+ triggered_by: str = "local"
72
+ iac_framework: str = "terraform"
73
+ score: int = Field(default=0, ge=0, le=100)
74
+ pillar_scores: dict[str, int] = Field(default_factory=dict)
75
+ path: str = ""
76
+ controls_loaded: int = 0
77
+ controls_run: int = 0
78
+ detected_regions: list[list[str]] = Field(default_factory=list)
79
+ source_paths: list[str] = Field(default_factory=list)
80
+ controls_meta: list[ControlMetaSchema] = Field(default_factory=list)
81
+ findings: list[FindingSchema] = Field(default_factory=list)
82
+ plan_changes: dict[str, Any] | None = None
83
+
84
+
85
+ class RunSummary(BaseModel):
86
+ id: uuid.UUID
87
+ project: str
88
+ branch: str
89
+ git_sha: str
90
+ triggered_by: str
91
+ iac_framework: str
92
+ score: int
93
+ pillar_scores: dict[str, int]
94
+ path: str
95
+ controls_loaded: int
96
+ controls_run: int
97
+ created_at: datetime
98
+
99
+ model_config = {"from_attributes": True}
100
+
101
+
102
+ class RunDetail(RunSummary):
103
+ findings: list[dict[str, Any]]
104
+ detected_regions: list[list[str]]
105
+ source_paths: list[str]
106
+ controls_meta: list[dict[str, Any]]
107
+ plan_changes: dict[str, Any] | None = None
108
+
109
+ model_config = {"from_attributes": True}
110
+
111
+
112
+ # ── Control schemas ───────────────────────────────────────────────────────────
113
+
114
+
115
+ class ControlIn(WizardControl):
116
+ """Request body for POST /controls.
117
+
118
+ Extends WizardControl (from wafpass-core) with an optional ``source``
119
+ field indicating the authoring origin.
120
+ """
121
+
122
+ source: str = "wafpass"
123
+
124
+
125
+ class ControlOut(WizardControl):
126
+ """Response schema for /controls endpoints.
127
+
128
+ Extends WizardControl with server-managed timestamp fields.
129
+ ``from_attributes=True`` enables construction from SQLAlchemy ORM rows.
130
+ """
131
+
132
+ source: str
133
+ created_at: datetime
134
+ updated_at: datetime
135
+
136
+ model_config = ConfigDict(from_attributes=True)