wafpass-server 0.3.4__py3-none-any.whl
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.
- wafpass_server/__init__.py +6 -0
- wafpass_server/config.py +21 -0
- wafpass_server/database.py +21 -0
- wafpass_server/main.py +46 -0
- wafpass_server/models.py +51 -0
- wafpass_server/routers/__init__.py +0 -0
- wafpass_server/routers/controls.py +119 -0
- wafpass_server/routers/runs.py +101 -0
- wafpass_server/schemas.py +136 -0
- wafpass_server-0.3.4.dist-info/METADATA +130 -0
- wafpass_server-0.3.4.dist-info/RECORD +13 -0
- wafpass_server-0.3.4.dist-info/WHEEL +4 -0
- wafpass_server-0.3.4.dist-info/entry_points.txt +2 -0
wafpass_server/config.py
ADDED
|
@@ -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
|
wafpass_server/main.py
ADDED
|
@@ -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()
|
wafpass_server/models.py
ADDED
|
@@ -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)
|
|
File without changes
|
|
@@ -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)
|
|
@@ -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,13 @@
|
|
|
1
|
+
wafpass_server/__init__.py,sha256=9EoKGeTrY5JnBKcERuTMzJtP34xpSQ0RqD2dOJv52es,191
|
|
2
|
+
wafpass_server/config.py,sha256=XU8ea65sgb-oQIPO-kBBb_hfzJAWouvesmKjCYaOwyQ,650
|
|
3
|
+
wafpass_server/database.py,sha256=uecjB4WgDW7ujKMpcpcHdozlDTO7HqX4vjyGK--xPd0,632
|
|
4
|
+
wafpass_server/main.py,sha256=pMtQqFhVu5-hKpH8ywTWJE45wOCOwsTYdfNVITVCPH0,1230
|
|
5
|
+
wafpass_server/models.py,sha256=mUYvO8cZ6Dn9gDjJjQPQDB8mH1hfThCrFoNHwFR1v1g,2270
|
|
6
|
+
wafpass_server/schemas.py,sha256=to39HnfYpNi7dxElrOHwZCneT8Ffzk4aMdy6G2vdoAI,3784
|
|
7
|
+
wafpass_server/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
wafpass_server/routers/controls.py,sha256=O2CoIGWWNeT81Qw4pO5HnUxIvG1brk-Th-wu7wbH6ys,4491
|
|
9
|
+
wafpass_server/routers/runs.py,sha256=y_etZa7h7tbmKJobY8KHlVAI6FLQrzB-uk8Z4WD76cU,3386
|
|
10
|
+
wafpass_server-0.3.4.dist-info/METADATA,sha256=4UT9JCSCp646NYyWaJ4Lh_9FLpcq19OMt2AVg6tggIA,3549
|
|
11
|
+
wafpass_server-0.3.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
wafpass_server-0.3.4.dist-info/entry_points.txt,sha256=1PNS1zNpHTnOPK6zZ8AwIBMXndvXpPVnXieBRVPRlNE,61
|
|
13
|
+
wafpass_server-0.3.4.dist-info/RECORD,,
|