evidentia-api 0.6.0__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.
- evidentia_api/__init__.py +21 -0
- evidentia_api/app.py +236 -0
- evidentia_api/cli.py +123 -0
- evidentia_api/deps.py +25 -0
- evidentia_api/routers/__init__.py +5 -0
- evidentia_api/routers/collectors.py +151 -0
- evidentia_api/routers/config.py +74 -0
- evidentia_api/routers/doctor.py +204 -0
- evidentia_api/routers/explain.py +99 -0
- evidentia_api/routers/frameworks.py +92 -0
- evidentia_api/routers/gaps.py +190 -0
- evidentia_api/routers/health.py +46 -0
- evidentia_api/routers/init_wizard.py +67 -0
- evidentia_api/routers/integrations.py +200 -0
- evidentia_api/routers/llm_status.py +62 -0
- evidentia_api/routers/risks.py +206 -0
- evidentia_api/schemas.py +192 -0
- evidentia_api/static/.gitkeep +6 -0
- evidentia_api/static/assets/index-BfU73xHx.js +126 -0
- evidentia_api/static/assets/index-BfU73xHx.js.map +1 -0
- evidentia_api/static/assets/index-CFl0YLQ6.css +1 -0
- evidentia_api/static/index.html +19 -0
- evidentia_api-0.6.0.dist-info/METADATA +95 -0
- evidentia_api-0.6.0.dist-info/RECORD +25 -0
- evidentia_api-0.6.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Evidentia API: FastAPI REST server + bundled React web UI.
|
|
2
|
+
|
|
3
|
+
The FastAPI application is exposed as :data:`evidentia_api.app.app` and
|
|
4
|
+
can be served directly with uvicorn:
|
|
5
|
+
|
|
6
|
+
uvicorn evidentia_api.app:app --host 127.0.0.1 --port 8000
|
|
7
|
+
|
|
8
|
+
Typical users reach it via the CLI:
|
|
9
|
+
|
|
10
|
+
evidentia serve [--host HOST] [--port PORT] [--offline]
|
|
11
|
+
|
|
12
|
+
which is a thin Typer wrapper around :func:`evidentia_api.cli.serve`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as _pkg_version
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
__version__ = _pkg_version("evidentia-api")
|
|
20
|
+
except PackageNotFoundError: # pragma: no cover — only hit in editable repos without install
|
|
21
|
+
__version__ = "0.0.0+unknown"
|
evidentia_api/app.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""FastAPI application factory.
|
|
2
|
+
|
|
3
|
+
The module-level ``app`` is the canonical instance that uvicorn references.
|
|
4
|
+
For testing (TestClient), prefer :func:`create_app` which accepts overrides.
|
|
5
|
+
|
|
6
|
+
All API routes live under ``/api/*``. Static assets (the bundled React SPA)
|
|
7
|
+
are mounted at ``/`` and serve ``index.html`` for every non-``/api/*`` path
|
|
8
|
+
to support client-side routing (React Router).
|
|
9
|
+
|
|
10
|
+
In v0.4.0 the static directory is populated at wheel-build time by the
|
|
11
|
+
frontend build hook in ``.github/workflows/release.yml`` (it runs
|
|
12
|
+
``npm run build`` in ``packages/evidentia-ui`` and copies ``dist/*``
|
|
13
|
+
into ``src/evidentia_api/static/``). When the directory is empty at
|
|
14
|
+
runtime (e.g. fresh dev install without a build), a placeholder JSON
|
|
15
|
+
response is returned instead of 404 so the error is self-explanatory.
|
|
16
|
+
|
|
17
|
+
Offline mode: the ``evidentia serve --offline`` CLI entry point sets
|
|
18
|
+
``EVIDENTIA_API_OFFLINE=1`` in the subprocess env. This module reads
|
|
19
|
+
that at import time to flip the process-wide air-gap guard before any
|
|
20
|
+
router handler runs.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from fastapi import FastAPI, Request
|
|
30
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
31
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
32
|
+
from fastapi.staticfiles import StaticFiles
|
|
33
|
+
|
|
34
|
+
from evidentia_api import __version__
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
39
|
+
"""Absolute path to the bundled SPA assets (populated at build time)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_app(
|
|
43
|
+
*,
|
|
44
|
+
offline: bool = False,
|
|
45
|
+
dev_mode: bool = False,
|
|
46
|
+
cors_origins: list[str] | None = None,
|
|
47
|
+
) -> FastAPI:
|
|
48
|
+
"""Build and return a FastAPI application.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
offline
|
|
53
|
+
When True, flips the process-wide air-gap guard at app creation.
|
|
54
|
+
Every LLM/network call then refuses non-loopback targets.
|
|
55
|
+
dev_mode
|
|
56
|
+
When True, CORS is permissive to support the Vite dev server
|
|
57
|
+
(http://127.0.0.1:5173) without a proxy.
|
|
58
|
+
cors_origins
|
|
59
|
+
Explicit allow-list override. Default: restrictive localhost-only.
|
|
60
|
+
"""
|
|
61
|
+
if offline:
|
|
62
|
+
from evidentia_core.network_guard import set_offline
|
|
63
|
+
|
|
64
|
+
set_offline(True)
|
|
65
|
+
|
|
66
|
+
app = FastAPI(
|
|
67
|
+
title="Evidentia API",
|
|
68
|
+
description=(
|
|
69
|
+
"REST API for the Evidentia GRC tool. All endpoints mirror "
|
|
70
|
+
"CLI capabilities. Binds to 127.0.0.1 by default for localhost "
|
|
71
|
+
"web-UI use."
|
|
72
|
+
),
|
|
73
|
+
version=__version__,
|
|
74
|
+
docs_url="/api/docs",
|
|
75
|
+
redoc_url="/api/redoc",
|
|
76
|
+
openapi_url="/api/openapi.json",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# CORS: dev_mode is permissive for Vite HMR; prod is localhost-only.
|
|
80
|
+
if cors_origins is None:
|
|
81
|
+
cors_origins = (
|
|
82
|
+
[
|
|
83
|
+
"http://127.0.0.1:5173",
|
|
84
|
+
"http://localhost:5173",
|
|
85
|
+
"http://127.0.0.1:8000",
|
|
86
|
+
"http://localhost:8000",
|
|
87
|
+
]
|
|
88
|
+
if dev_mode
|
|
89
|
+
else ["http://127.0.0.1:8000", "http://localhost:8000"]
|
|
90
|
+
)
|
|
91
|
+
app.add_middleware(
|
|
92
|
+
CORSMiddleware,
|
|
93
|
+
allow_origins=cors_origins,
|
|
94
|
+
allow_credentials=True,
|
|
95
|
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
|
96
|
+
allow_headers=["*"],
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Attach flags to app state so every router dep can consult them.
|
|
100
|
+
app.state.offline = offline
|
|
101
|
+
app.state.dev_mode = dev_mode
|
|
102
|
+
|
|
103
|
+
# Register routers. Each router is a focused module — see routers/*.py.
|
|
104
|
+
# Imports are deferred so module-load errors in one router don't take
|
|
105
|
+
# down the whole server.
|
|
106
|
+
from evidentia_api.routers import (
|
|
107
|
+
collectors as collectors_router,
|
|
108
|
+
)
|
|
109
|
+
from evidentia_api.routers import (
|
|
110
|
+
config as config_router,
|
|
111
|
+
)
|
|
112
|
+
from evidentia_api.routers import (
|
|
113
|
+
doctor as doctor_router,
|
|
114
|
+
)
|
|
115
|
+
from evidentia_api.routers import (
|
|
116
|
+
explain as explain_router,
|
|
117
|
+
)
|
|
118
|
+
from evidentia_api.routers import (
|
|
119
|
+
frameworks as frameworks_router,
|
|
120
|
+
)
|
|
121
|
+
from evidentia_api.routers import (
|
|
122
|
+
gaps as gaps_router,
|
|
123
|
+
)
|
|
124
|
+
from evidentia_api.routers import (
|
|
125
|
+
health as health_router,
|
|
126
|
+
)
|
|
127
|
+
from evidentia_api.routers import (
|
|
128
|
+
init_wizard as init_wizard_router,
|
|
129
|
+
)
|
|
130
|
+
from evidentia_api.routers import (
|
|
131
|
+
integrations as integrations_router,
|
|
132
|
+
)
|
|
133
|
+
from evidentia_api.routers import (
|
|
134
|
+
llm_status as llm_status_router,
|
|
135
|
+
)
|
|
136
|
+
from evidentia_api.routers import (
|
|
137
|
+
risks as risks_router,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
app.include_router(health_router.router, prefix="/api", tags=["health"])
|
|
141
|
+
app.include_router(config_router.router, prefix="/api", tags=["config"])
|
|
142
|
+
app.include_router(doctor_router.router, prefix="/api", tags=["doctor"])
|
|
143
|
+
app.include_router(llm_status_router.router, prefix="/api", tags=["llm"])
|
|
144
|
+
app.include_router(
|
|
145
|
+
frameworks_router.router, prefix="/api", tags=["frameworks"]
|
|
146
|
+
)
|
|
147
|
+
app.include_router(gaps_router.router, prefix="/api", tags=["gaps"])
|
|
148
|
+
app.include_router(risks_router.router, prefix="/api", tags=["risks"])
|
|
149
|
+
app.include_router(explain_router.router, prefix="/api", tags=["explain"])
|
|
150
|
+
app.include_router(
|
|
151
|
+
init_wizard_router.router, prefix="/api", tags=["init"]
|
|
152
|
+
)
|
|
153
|
+
app.include_router(
|
|
154
|
+
integrations_router.router, prefix="/api", tags=["integrations"]
|
|
155
|
+
)
|
|
156
|
+
app.include_router(
|
|
157
|
+
collectors_router.router, prefix="/api", tags=["collectors"]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Static SPA mount — everything that isn't /api/* falls through to index.html.
|
|
161
|
+
_mount_spa(app)
|
|
162
|
+
|
|
163
|
+
return app
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _mount_spa(app: FastAPI) -> None:
|
|
167
|
+
"""Mount the bundled React SPA at the root.
|
|
168
|
+
|
|
169
|
+
If the static directory is missing or empty (common in fresh dev checkouts
|
|
170
|
+
before ``npm run build`` has been executed), serve a helpful placeholder
|
|
171
|
+
instead of 404 so users can self-diagnose.
|
|
172
|
+
"""
|
|
173
|
+
if STATIC_DIR.is_dir() and (STATIC_DIR / "index.html").is_file():
|
|
174
|
+
# Mount static/assets first for JS/CSS/fonts; then SPA fallback.
|
|
175
|
+
assets_dir = STATIC_DIR / "assets"
|
|
176
|
+
if assets_dir.is_dir():
|
|
177
|
+
app.mount(
|
|
178
|
+
"/assets",
|
|
179
|
+
StaticFiles(directory=assets_dir),
|
|
180
|
+
name="spa-assets",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
184
|
+
async def _spa_fallback(full_path: str, request: Request) -> FileResponse:
|
|
185
|
+
"""Serve index.html for every non-API path so React Router owns routing."""
|
|
186
|
+
if full_path.startswith("api/"):
|
|
187
|
+
# Defensive — FastAPI routing should have caught these already.
|
|
188
|
+
return FileResponse(STATIC_DIR / "index.html", status_code=404)
|
|
189
|
+
target = STATIC_DIR / full_path
|
|
190
|
+
if target.is_file():
|
|
191
|
+
return FileResponse(target)
|
|
192
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
193
|
+
|
|
194
|
+
else:
|
|
195
|
+
logger.info(
|
|
196
|
+
"Static SPA directory is empty at %s — serving dev placeholder. "
|
|
197
|
+
"Run `npm run build` in packages/evidentia-ui/ to populate.",
|
|
198
|
+
STATIC_DIR,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
202
|
+
async def _dev_placeholder(full_path: str) -> JSONResponse:
|
|
203
|
+
if full_path.startswith("api/"):
|
|
204
|
+
return JSONResponse(status_code=404, content={"detail": "Not Found"})
|
|
205
|
+
return JSONResponse(
|
|
206
|
+
status_code=503,
|
|
207
|
+
content={
|
|
208
|
+
"error": "spa_not_built",
|
|
209
|
+
"message": (
|
|
210
|
+
"The Evidentia web UI is not bundled in this install. "
|
|
211
|
+
"If you are developing, run `npm install && npm run dev` "
|
|
212
|
+
"in packages/evidentia-ui/ and use `evidentia "
|
|
213
|
+
"serve --dev`. If you are an end user, please reinstall "
|
|
214
|
+
"with `pip install --force-reinstall evidentia[gui]`."
|
|
215
|
+
),
|
|
216
|
+
"api_docs": "/api/docs",
|
|
217
|
+
},
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# Read env-var flags set by ``evidentia serve`` subprocess launch.
|
|
222
|
+
# EVIDENTIA_API_OFFLINE=1 -> offline mode
|
|
223
|
+
# EVIDENTIA_API_DEV=1 -> permissive CORS for Vite dev server
|
|
224
|
+
_env_offline = os.environ.get("EVIDENTIA_API_OFFLINE", "").strip().lower() in {
|
|
225
|
+
"1",
|
|
226
|
+
"true",
|
|
227
|
+
"yes",
|
|
228
|
+
}
|
|
229
|
+
_env_dev = os.environ.get("EVIDENTIA_API_DEV", "").strip().lower() in {
|
|
230
|
+
"1",
|
|
231
|
+
"true",
|
|
232
|
+
"yes",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Default instance for `uvicorn evidentia_api.app:app` usage.
|
|
236
|
+
app = create_app(offline=_env_offline, dev_mode=_env_dev)
|
evidentia_api/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""`evidentia serve` implementation.
|
|
2
|
+
|
|
3
|
+
Invoked from :mod:`evidentia.cli.main` via the `serve` Typer command.
|
|
4
|
+
Uses ``subprocess.Popen`` against ``sys.executable -m uvicorn`` for
|
|
5
|
+
portability across Windows/macOS/Linux — per the Plan-agent pressure-test,
|
|
6
|
+
this pattern survives Ctrl+C on Windows consoles more reliably than
|
|
7
|
+
``uvicorn.run()`` in-process.
|
|
8
|
+
|
|
9
|
+
Dev mode (``--dev``) leaves frontend serving to the Vite dev server
|
|
10
|
+
(``npm run dev`` in ``packages/evidentia-ui/``) on port 5173; the
|
|
11
|
+
FastAPI server at 8000 enables permissive CORS so the two co-exist.
|
|
12
|
+
Production mode serves the bundled SPA from the wheel.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def serve(
|
|
27
|
+
*,
|
|
28
|
+
host: str = "127.0.0.1",
|
|
29
|
+
port: int = 8000,
|
|
30
|
+
offline: bool = False,
|
|
31
|
+
dev: bool = False,
|
|
32
|
+
open_browser: bool = True,
|
|
33
|
+
reload: bool = False,
|
|
34
|
+
) -> int:
|
|
35
|
+
"""Spawn uvicorn serving the Evidentia API + web UI.
|
|
36
|
+
|
|
37
|
+
Returns the child process exit code. Errors raised before spawn (e.g.
|
|
38
|
+
missing uvicorn install) surface as ``ImportError`` for the caller to
|
|
39
|
+
translate into a friendly message.
|
|
40
|
+
"""
|
|
41
|
+
# Verify uvicorn is importable before spawning — otherwise the
|
|
42
|
+
# subprocess error message is opaque on Windows.
|
|
43
|
+
try:
|
|
44
|
+
import uvicorn # noqa: F401
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"uvicorn is not installed. Install the [gui] extra: "
|
|
48
|
+
"`pip install 'evidentia[gui]'` or "
|
|
49
|
+
"`uv tool install 'evidentia[gui]'`."
|
|
50
|
+
) from e
|
|
51
|
+
|
|
52
|
+
# Host-binding security: warn if binding to a non-loopback address.
|
|
53
|
+
if host not in ("127.0.0.1", "localhost", "::1"):
|
|
54
|
+
logger.warning(
|
|
55
|
+
"SECURITY: binding to %s exposes the web UI on your network. "
|
|
56
|
+
"Evidentia has no auth in v0.4.0 — anyone who can reach this "
|
|
57
|
+
"address can view and modify your gap reports. Bind to 127.0.0.1 "
|
|
58
|
+
"unless you know what you're doing.",
|
|
59
|
+
host,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Environment plumbing: offline flag + dev mode are read by
|
|
63
|
+
# evidentia_api.app.create_app via module-level env vars so they
|
|
64
|
+
# survive the subprocess boundary.
|
|
65
|
+
env = os.environ.copy()
|
|
66
|
+
if offline:
|
|
67
|
+
env["EVIDENTIA_API_OFFLINE"] = "1"
|
|
68
|
+
if dev:
|
|
69
|
+
env["EVIDENTIA_API_DEV"] = "1"
|
|
70
|
+
|
|
71
|
+
cmd = [
|
|
72
|
+
sys.executable,
|
|
73
|
+
"-m",
|
|
74
|
+
"uvicorn",
|
|
75
|
+
"evidentia_api.app:app",
|
|
76
|
+
"--host",
|
|
77
|
+
host,
|
|
78
|
+
"--port",
|
|
79
|
+
str(port),
|
|
80
|
+
]
|
|
81
|
+
if reload:
|
|
82
|
+
cmd.append("--reload")
|
|
83
|
+
|
|
84
|
+
if open_browser and not reload:
|
|
85
|
+
# Open browser slightly after spawn so the server has time to bind.
|
|
86
|
+
import threading
|
|
87
|
+
import webbrowser
|
|
88
|
+
|
|
89
|
+
def _open() -> None:
|
|
90
|
+
import time
|
|
91
|
+
|
|
92
|
+
time.sleep(1.5)
|
|
93
|
+
webbrowser.open(f"http://{host}:{port}")
|
|
94
|
+
|
|
95
|
+
threading.Thread(target=_open, daemon=True).start()
|
|
96
|
+
|
|
97
|
+
logger.info("Spawning: %s", " ".join(cmd))
|
|
98
|
+
|
|
99
|
+
# Windows: CREATE_NEW_PROCESS_GROUP lets Ctrl+C in the parent terminate
|
|
100
|
+
# the child cleanly without leaving a zombie on the port.
|
|
101
|
+
popen_kwargs: dict = {"env": env}
|
|
102
|
+
if sys.platform == "win32":
|
|
103
|
+
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
104
|
+
|
|
105
|
+
proc = subprocess.Popen(cmd, **popen_kwargs)
|
|
106
|
+
try:
|
|
107
|
+
return proc.wait()
|
|
108
|
+
except KeyboardInterrupt:
|
|
109
|
+
logger.info("Received Ctrl+C; shutting down uvicorn...")
|
|
110
|
+
if sys.platform == "win32":
|
|
111
|
+
import signal
|
|
112
|
+
|
|
113
|
+
proc.send_signal(signal.CTRL_BREAK_EVENT)
|
|
114
|
+
else:
|
|
115
|
+
proc.terminate()
|
|
116
|
+
return proc.wait()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_static_dir() -> Path:
|
|
120
|
+
"""Return the static-asset directory, for diagnostics."""
|
|
121
|
+
from evidentia_api.app import STATIC_DIR
|
|
122
|
+
|
|
123
|
+
return STATIC_DIR
|
evidentia_api/deps.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""FastAPI dependency-injection helpers.
|
|
2
|
+
|
|
3
|
+
Each router consumes these via ``Depends(...)`` so tests can override them
|
|
4
|
+
via ``app.dependency_overrides`` for TestClient-based coverage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from evidentia_core.catalogs.registry import FrameworkRegistry
|
|
10
|
+
from fastapi import Request
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_registry() -> FrameworkRegistry:
|
|
14
|
+
"""Return the shared FrameworkRegistry singleton."""
|
|
15
|
+
return FrameworkRegistry.get_instance()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_offline_flag(request: Request) -> bool:
|
|
19
|
+
"""True if the server was started with --offline."""
|
|
20
|
+
return bool(getattr(request.app.state, "offline", False))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_dev_mode(request: Request) -> bool:
|
|
24
|
+
"""True if the server was started with --dev (Vite proxy mode)."""
|
|
25
|
+
return bool(getattr(request.app.state, "dev_mode", False))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Collectors router — AWS + GitHub evidence endpoints.
|
|
2
|
+
|
|
3
|
+
All endpoints are POST-only — running a collector has non-trivial
|
|
4
|
+
side-effects (AWS API calls, GitHub rate limits) so a GET shouldn't
|
|
5
|
+
trigger them. Response is a list of :class:`SecurityFinding` objects.
|
|
6
|
+
|
|
7
|
+
Credentials:
|
|
8
|
+
- AWS: boto3's standard chain (env, ~/.aws/credentials, instance profile)
|
|
9
|
+
- GitHub: $GITHUB_TOKEN environment variable on the server
|
|
10
|
+
|
|
11
|
+
No credential values ever flow through request/response bodies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from evidentia_core.models.finding import SecurityFinding
|
|
21
|
+
from fastapi import APIRouter, HTTPException
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.post("/collectors/aws/collect", response_model=list[SecurityFinding])
|
|
28
|
+
async def aws_collect(payload: dict[str, Any] | None = None) -> list[SecurityFinding]:
|
|
29
|
+
"""Run the AWS collector (Config + Security Hub).
|
|
30
|
+
|
|
31
|
+
Request body (optional):
|
|
32
|
+
|
|
33
|
+
- ``region``: override region
|
|
34
|
+
- ``profile``: optional AWS profile name
|
|
35
|
+
- ``include_config``: bool (default True)
|
|
36
|
+
- ``include_security_hub``: bool (default True)
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
from evidentia_collectors.aws import AwsCollector, AwsCollectorError
|
|
40
|
+
except ImportError as e:
|
|
41
|
+
raise HTTPException(
|
|
42
|
+
status_code=503,
|
|
43
|
+
detail=(
|
|
44
|
+
"AWS collector not installed. Run "
|
|
45
|
+
"`pip install 'evidentia-collectors[aws]'`."
|
|
46
|
+
),
|
|
47
|
+
) from e
|
|
48
|
+
|
|
49
|
+
body = payload or {}
|
|
50
|
+
region = body.get("region") if isinstance(body.get("region"), str) else None
|
|
51
|
+
profile = body.get("profile") if isinstance(body.get("profile"), str) else None
|
|
52
|
+
include_config = bool(body.get("include_config", True))
|
|
53
|
+
include_security_hub = bool(body.get("include_security_hub", True))
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
collector = AwsCollector(region=region, profile=profile)
|
|
57
|
+
collector.test_connection()
|
|
58
|
+
except AwsCollectorError as e:
|
|
59
|
+
raise HTTPException(status_code=503, detail=str(e)) from e
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
findings = collector.collect_all(
|
|
63
|
+
include_config=include_config,
|
|
64
|
+
include_security_hub=include_security_hub,
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.exception("AWS collector failed")
|
|
68
|
+
raise HTTPException(status_code=500, detail=f"AWS collector failed: {e}") from e
|
|
69
|
+
|
|
70
|
+
return findings
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.post("/collectors/github/collect", response_model=list[SecurityFinding])
|
|
74
|
+
async def github_collect(payload: dict[str, Any]) -> list[SecurityFinding]:
|
|
75
|
+
"""Run the GitHub collector.
|
|
76
|
+
|
|
77
|
+
Request body (required):
|
|
78
|
+
|
|
79
|
+
- ``repo``: repository in 'owner/repo' format
|
|
80
|
+
|
|
81
|
+
Credentials are sourced from the server's ``$GITHUB_TOKEN`` env var.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
from evidentia_collectors.github import (
|
|
85
|
+
GitHubApiError,
|
|
86
|
+
GitHubCollector,
|
|
87
|
+
GitHubCollectorError,
|
|
88
|
+
)
|
|
89
|
+
except ImportError as e:
|
|
90
|
+
raise HTTPException(
|
|
91
|
+
status_code=503,
|
|
92
|
+
detail=f"GitHub collector import failed: {e}",
|
|
93
|
+
) from e
|
|
94
|
+
|
|
95
|
+
repo = str(payload.get("repo") or "").strip()
|
|
96
|
+
if "/" not in repo:
|
|
97
|
+
raise HTTPException(
|
|
98
|
+
status_code=422,
|
|
99
|
+
detail="Request body must include 'repo' in 'owner/repo' format.",
|
|
100
|
+
)
|
|
101
|
+
owner, repo_name = repo.split("/", 1)
|
|
102
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
with GitHubCollector(
|
|
106
|
+
owner=owner, repo=repo_name, token=token
|
|
107
|
+
) as collector:
|
|
108
|
+
findings = collector.collect()
|
|
109
|
+
except GitHubCollectorError as e:
|
|
110
|
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
111
|
+
except GitHubApiError as e:
|
|
112
|
+
raise HTTPException(status_code=502, detail=str(e)) from e
|
|
113
|
+
|
|
114
|
+
return findings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get("/collectors/status")
|
|
118
|
+
async def collectors_status() -> dict[str, Any]:
|
|
119
|
+
"""Report which collectors are installed + which credentials are set.
|
|
120
|
+
|
|
121
|
+
Never returns token values — only ``configured: bool`` + the env var
|
|
122
|
+
name the token was sourced from.
|
|
123
|
+
"""
|
|
124
|
+
aws_installed = False
|
|
125
|
+
github_installed = False
|
|
126
|
+
try:
|
|
127
|
+
import evidentia_collectors.aws
|
|
128
|
+
|
|
129
|
+
aws_installed = True
|
|
130
|
+
except ImportError:
|
|
131
|
+
pass
|
|
132
|
+
try:
|
|
133
|
+
import evidentia_collectors.github # noqa: F401
|
|
134
|
+
|
|
135
|
+
github_installed = True
|
|
136
|
+
except ImportError:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
"aws": {
|
|
141
|
+
"installed": aws_installed,
|
|
142
|
+
"credentials_hint": (
|
|
143
|
+
"boto3 standard chain (env / ~/.aws / instance profile)"
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
"github": {
|
|
147
|
+
"installed": github_installed,
|
|
148
|
+
"token_configured": bool(os.environ.get("GITHUB_TOKEN")),
|
|
149
|
+
"token_source": "env:GITHUB_TOKEN" if os.environ.get("GITHUB_TOKEN") else None,
|
|
150
|
+
},
|
|
151
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Config router — read / write ``evidentia.yaml``.
|
|
2
|
+
|
|
3
|
+
GET returns the current config (walking CWD -> parents for the file).
|
|
4
|
+
PUT accepts a :class:`EvidentiaConfig` payload, validates it via
|
|
5
|
+
Pydantic, and writes the YAML back to the same path.
|
|
6
|
+
|
|
7
|
+
No secrets ever flow through this endpoint's request/response bodies —
|
|
8
|
+
the ``llm`` subsection is model + temperature only (no API keys). Keys
|
|
9
|
+
are handled separately via the ``/api/llm-status`` endpoint, which never
|
|
10
|
+
returns key values, only booleans + source identifiers.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from evidentia_core.config import (
|
|
21
|
+
CONFIG_FILENAME,
|
|
22
|
+
EvidentiaConfig,
|
|
23
|
+
find_config_file,
|
|
24
|
+
load_config,
|
|
25
|
+
)
|
|
26
|
+
from fastapi import APIRouter, HTTPException
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
router = APIRouter()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/config", response_model=EvidentiaConfig)
|
|
33
|
+
async def get_config() -> EvidentiaConfig:
|
|
34
|
+
"""Return the currently-loaded evidentia.yaml contents.
|
|
35
|
+
|
|
36
|
+
If no file exists in CWD or any parent, returns a default
|
|
37
|
+
(empty) :class:`EvidentiaConfig`.
|
|
38
|
+
"""
|
|
39
|
+
return load_config()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.put("/config", response_model=EvidentiaConfig)
|
|
43
|
+
async def put_config(payload: EvidentiaConfig) -> EvidentiaConfig:
|
|
44
|
+
"""Persist ``evidentia.yaml`` with the validated payload.
|
|
45
|
+
|
|
46
|
+
Writes to the discovered path (walking CWD -> parents), or to
|
|
47
|
+
``./evidentia.yaml`` if none exists yet. Returns the persisted
|
|
48
|
+
config (with ``source_path`` populated so callers can confirm where
|
|
49
|
+
the write landed).
|
|
50
|
+
"""
|
|
51
|
+
target = find_config_file() or (Path.cwd() / CONFIG_FILENAME)
|
|
52
|
+
try:
|
|
53
|
+
dumped: dict[str, Any] = payload.model_dump(
|
|
54
|
+
exclude={"source_path"}, exclude_defaults=False
|
|
55
|
+
)
|
|
56
|
+
yaml_text = yaml.safe_dump(
|
|
57
|
+
dumped, sort_keys=False, default_flow_style=False
|
|
58
|
+
)
|
|
59
|
+
target.write_text(yaml_text, encoding="utf-8")
|
|
60
|
+
except OSError as e:
|
|
61
|
+
logger.error("Failed to write %s: %s", target, e)
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=500,
|
|
64
|
+
detail=f"Could not write config to {target}: {e}",
|
|
65
|
+
) from e
|
|
66
|
+
|
|
67
|
+
# Clear the LRU cache so subsequent reads see the new contents.
|
|
68
|
+
from evidentia_core.config import _load_config_cached
|
|
69
|
+
|
|
70
|
+
_load_config_cached.cache_clear()
|
|
71
|
+
|
|
72
|
+
refreshed = load_config(target)
|
|
73
|
+
logger.info("Wrote %s (%d bytes)", target, len(yaml_text))
|
|
74
|
+
return refreshed
|