webvigil 1.0.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.
- webvigil/__init__.py +10 -0
- webvigil/api/__init__.py +7 -0
- webvigil/api/app.py +85 -0
- webvigil/api/cli.py +88 -0
- webvigil/api/config.py +119 -0
- webvigil/api/db.py +258 -0
- webvigil/api/deps.py +114 -0
- webvigil/api/mapping.py +179 -0
- webvigil/api/migrations/env.py +72 -0
- webvigil/api/migrations/script.py.mako +29 -0
- webvigil/api/migrations/versions/0001_initial.py +100 -0
- webvigil/api/migrations/versions/0002_scan_technologies.py +27 -0
- webvigil/api/routes/__init__.py +7 -0
- webvigil/api/routes/auth.py +82 -0
- webvigil/api/routes/meta.py +53 -0
- webvigil/api/routes/reports.py +57 -0
- webvigil/api/routes/scans.py +198 -0
- webvigil/api/routes/setup.py +50 -0
- webvigil/api/runner.py +334 -0
- webvigil/api/schemas.py +333 -0
- webvigil/api/security.py +176 -0
- webvigil/api/throttle.py +87 -0
- webvigil/auth/__init__.py +9 -0
- webvigil/auth/login.py +498 -0
- webvigil/auth/scrub.py +162 -0
- webvigil/checks/__init__.py +49 -0
- webvigil/checks/base.py +116 -0
- webvigil/checks/content/__init__.py +12 -0
- webvigil/checks/content/checks.py +276 -0
- webvigil/checks/cookies/__init__.py +9 -0
- webvigil/checks/cookies/flags.py +181 -0
- webvigil/checks/cors/__init__.py +9 -0
- webvigil/checks/cors/check.py +126 -0
- webvigil/checks/csrf/__init__.py +20 -0
- webvigil/checks/csrf/checks.py +245 -0
- webvigil/checks/csrf/scanner.py +524 -0
- webvigil/checks/csrf/tokens.py +37 -0
- webvigil/checks/deps/__init__.py +18 -0
- webvigil/checks/deps/_data.py +65 -0
- webvigil/checks/deps/advisories.py +259 -0
- webvigil/checks/deps/check.py +275 -0
- webvigil/checks/deps/data/PROVENANCE.json +7 -0
- webvigil/checks/deps/data/__init__.py +7 -0
- webvigil/checks/deps/data/retirejs.json +12323 -0
- webvigil/checks/deps/fingerprint.py +221 -0
- webvigil/checks/deps/osv.py +509 -0
- webvigil/checks/deps/rules.py +346 -0
- webvigil/checks/deps/staleness.py +36 -0
- webvigil/checks/disclosure/__init__.py +18 -0
- webvigil/checks/disclosure/catalogue.py +351 -0
- webvigil/checks/disclosure/checks.py +167 -0
- webvigil/checks/disclosure/data/__init__.py +6 -0
- webvigil/checks/disclosure/data/paths.toml +382 -0
- webvigil/checks/disclosure/errors.py +80 -0
- webvigil/checks/disclosure/leakage.py +252 -0
- webvigil/checks/disclosure/listing.py +82 -0
- webvigil/checks/disclosure/probe.py +432 -0
- webvigil/checks/disclosure/redaction.py +111 -0
- webvigil/checks/disclosure/signatures.py +184 -0
- webvigil/checks/envelope/__init__.py +15 -0
- webvigil/checks/envelope/checks.py +111 -0
- webvigil/checks/envelope/scanner.py +374 -0
- webvigil/checks/headers/__init__.py +11 -0
- webvigil/checks/headers/_parsing.py +102 -0
- webvigil/checks/headers/csp.py +124 -0
- webvigil/checks/headers/hsts.py +106 -0
- webvigil/checks/headers/misc.py +239 -0
- webvigil/checks/headers/revealing.py +84 -0
- webvigil/checks/injection/__init__.py +13 -0
- webvigil/checks/injection/checks.py +511 -0
- webvigil/checks/injection/detect/__init__.py +90 -0
- webvigil/checks/injection/detect/_diff.py +74 -0
- webvigil/checks/injection/detect/cmdi.py +225 -0
- webvigil/checks/injection/detect/crlf.py +102 -0
- webvigil/checks/injection/detect/el.py +578 -0
- webvigil/checks/injection/detect/ldap.py +125 -0
- webvigil/checks/injection/detect/redirect.py +108 -0
- webvigil/checks/injection/detect/sqli.py +210 -0
- webvigil/checks/injection/detect/ssi.py +117 -0
- webvigil/checks/injection/detect/ssrf.py +312 -0
- webvigil/checks/injection/detect/ssti.py +157 -0
- webvigil/checks/injection/detect/traversal.py +74 -0
- webvigil/checks/injection/detect/xpath.py +121 -0
- webvigil/checks/injection/detect/xss.py +108 -0
- webvigil/checks/injection/detect/xxe.py +135 -0
- webvigil/checks/injection/engine.py +363 -0
- webvigil/checks/injection/models.py +252 -0
- webvigil/checks/injection/payloads.py +516 -0
- webvigil/checks/injection/points.py +645 -0
- webvigil/checks/injection/stored.py +266 -0
- webvigil/checks/registry.py +122 -0
- webvigil/checks/session/__init__.py +14 -0
- webvigil/checks/session/checks.py +215 -0
- webvigil/checks/session/cookies.py +81 -0
- webvigil/checks/session/ids.py +251 -0
- webvigil/checks/session/scanner.py +433 -0
- webvigil/checks/tls/__init__.py +9 -0
- webvigil/checks/tls/_cert.py +167 -0
- webvigil/checks/tls/check.py +172 -0
- webvigil/checks/upload/__init__.py +16 -0
- webvigil/checks/upload/checks.py +91 -0
- webvigil/checks/upload/scanner.py +584 -0
- webvigil/cli/__init__.py +7 -0
- webvigil/cli/_exit.py +51 -0
- webvigil/cli/_render.py +210 -0
- webvigil/cli/app.py +701 -0
- webvigil/core/__init__.py +96 -0
- webvigil/core/config.py +604 -0
- webvigil/core/context.py +290 -0
- webvigil/core/errors.py +94 -0
- webvigil/core/findings.py +257 -0
- webvigil/core/orchestrator.py +789 -0
- webvigil/core/result.py +166 -0
- webvigil/core/target.py +186 -0
- webvigil/core/technology.py +60 -0
- webvigil/crawler/__init__.py +14 -0
- webvigil/crawler/crawler.py +606 -0
- webvigil/crawler/forms.py +323 -0
- webvigil/crawler/jsapp.py +87 -0
- webvigil/crawler/openapi.py +529 -0
- webvigil/crawler/robots.py +76 -0
- webvigil/crawler/safety.py +198 -0
- webvigil/crawler/sitemap.py +58 -0
- webvigil/http/__init__.py +25 -0
- webvigil/http/client.py +736 -0
- webvigil/http/policy.py +62 -0
- webvigil/http/scope_guard.py +50 -0
- webvigil/http/session.py +398 -0
- webvigil/http/tls_probe.py +197 -0
- webvigil/py.typed +0 -0
- webvigil/reporting/__init__.py +81 -0
- webvigil/reporting/_ordering.py +23 -0
- webvigil/reporting/html.py +43 -0
- webvigil/reporting/json_report.py +25 -0
- webvigil/reporting/markdown.py +88 -0
- webvigil/reporting/sarif.py +165 -0
- webvigil/reporting/templates/report.html.j2 +128 -0
- webvigil-1.0.0.dist-info/METADATA +390 -0
- webvigil-1.0.0.dist-info/RECORD +143 -0
- webvigil-1.0.0.dist-info/WHEEL +4 -0
- webvigil-1.0.0.dist-info/entry_points.txt +3 -0
- webvigil-1.0.0.dist-info/licenses/LICENSE +201 -0
- webvigil-1.0.0.dist-info/licenses/NOTICE +17 -0
webvigil/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""WebVigil — a web application vulnerability scanner for developers."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("webvigil")
|
|
7
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
8
|
+
__version__ = "0.0.0.dev0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
webvigil/api/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Web API: a FastAPI wrapper around the scan engine, with SQLite persistence.
|
|
3
|
+
|
|
4
|
+
Only available with the ``web`` extra (``pip install "webvigil[web]"``). This
|
|
5
|
+
package may import the engine and the reporters; the engine imports nothing from
|
|
6
|
+
here — the boundary is enforced by import-linter.
|
|
7
|
+
"""
|
webvigil/api/app.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The FastAPI application factory and its lifespan.
|
|
3
|
+
|
|
4
|
+
The route-handler docstrings double as the OpenAPI endpoint descriptions, so
|
|
5
|
+
they stay to a sentence or two; the private helpers carry the full
|
|
6
|
+
Args/Returns sections.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
|
|
17
|
+
from webvigil import __version__
|
|
18
|
+
from webvigil.api.config import WebConfig
|
|
19
|
+
from webvigil.api.db import make_engine, run_alembic_upgrade
|
|
20
|
+
from webvigil.api.routes import auth, meta, reports, scans, setup
|
|
21
|
+
from webvigil.api.runner import OrchestratorFactory, ScanRunner, recover_interrupted_scans
|
|
22
|
+
from webvigil.api.security import resolve_session_secret
|
|
23
|
+
from webvigil.api.throttle import LoginThrottle
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def create_app(
|
|
27
|
+
config: WebConfig | None = None,
|
|
28
|
+
*,
|
|
29
|
+
orchestrator_factory: OrchestratorFactory | None = None,
|
|
30
|
+
) -> FastAPI:
|
|
31
|
+
"""
|
|
32
|
+
Build the FastAPI app: wire the lifespan, optional CORS, and the routers.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
config (WebConfig | None): The Web-API config; loaded from file / env
|
|
36
|
+
when ``None``.
|
|
37
|
+
orchestrator_factory (OrchestratorFactory | None): Test seam passed
|
|
38
|
+
through to the :class:`~webvigil.api.runner.ScanRunner`; ``None``
|
|
39
|
+
uses the real orchestrator.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
FastAPI: The configured application.
|
|
43
|
+
"""
|
|
44
|
+
cfg = config or WebConfig.load()
|
|
45
|
+
|
|
46
|
+
@asynccontextmanager
|
|
47
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
48
|
+
"""Startup: migrate, open the engine, resolve the secret, recover, start the runner."""
|
|
49
|
+
if cfg.auto_migrate:
|
|
50
|
+
run_alembic_upgrade(cfg)
|
|
51
|
+
engine = make_engine(cfg)
|
|
52
|
+
app.state.config = cfg
|
|
53
|
+
app.state.engine = engine
|
|
54
|
+
app.state.session_secret = resolve_session_secret(engine, cfg)
|
|
55
|
+
recover_interrupted_scans(engine)
|
|
56
|
+
runner = (
|
|
57
|
+
ScanRunner(engine, orchestrator_factory=orchestrator_factory)
|
|
58
|
+
if orchestrator_factory is not None
|
|
59
|
+
else ScanRunner(engine)
|
|
60
|
+
)
|
|
61
|
+
app.state.runner = runner
|
|
62
|
+
await runner.start()
|
|
63
|
+
runner.wake()
|
|
64
|
+
try:
|
|
65
|
+
yield
|
|
66
|
+
finally:
|
|
67
|
+
await runner.stop()
|
|
68
|
+
engine.dispose()
|
|
69
|
+
|
|
70
|
+
app = FastAPI(title="WebVigil API", version=__version__, lifespan=lifespan)
|
|
71
|
+
app.state.login_throttle = LoginThrottle()
|
|
72
|
+
if cfg.cors_origins:
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
CORSMiddleware,
|
|
75
|
+
allow_origins=cfg.cors_origins,
|
|
76
|
+
allow_credentials=True,
|
|
77
|
+
allow_methods=["*"],
|
|
78
|
+
allow_headers=["*"],
|
|
79
|
+
)
|
|
80
|
+
for router in (meta.router, setup.router, auth.router, scans.router, reports.router):
|
|
81
|
+
app.include_router(router, prefix="/api")
|
|
82
|
+
return app
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
app = create_app()
|
webvigil/api/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The ``webvigil-web`` command: serve the API, run migrations, reset the password (RF-29).
|
|
3
|
+
|
|
4
|
+
The command docstrings double as Typer ``--help`` text, so they stay terse.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from sqlmodel import Session, select
|
|
15
|
+
|
|
16
|
+
from webvigil.api.config import WebConfig
|
|
17
|
+
from webvigil.api.db import User, make_engine, run_alembic_upgrade, utcnow
|
|
18
|
+
from webvigil.api.security import hash_password
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(
|
|
21
|
+
name="webvigil-web",
|
|
22
|
+
help="Run the WebVigil Web API and manage its database.",
|
|
23
|
+
no_args_is_help=True,
|
|
24
|
+
add_completion=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_ConfigOpt = Annotated[Path | None, typer.Option("--config", help="Path to webvigil.toml.")]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command()
|
|
31
|
+
def serve(
|
|
32
|
+
config: _ConfigOpt = None,
|
|
33
|
+
host: Annotated[str | None, typer.Option(help="Bind host.")] = None,
|
|
34
|
+
port: Annotated[int | None, typer.Option(help="Bind port.")] = None,
|
|
35
|
+
reload: Annotated[bool, typer.Option(help="Auto-reload on code changes (dev only).")] = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Serve the API with uvicorn."""
|
|
38
|
+
import uvicorn
|
|
39
|
+
|
|
40
|
+
if config is not None:
|
|
41
|
+
os.environ["WEBVIGIL_CONFIG"] = str(config)
|
|
42
|
+
cfg = WebConfig.load(config)
|
|
43
|
+
uvicorn.run(
|
|
44
|
+
"webvigil.api.app:app",
|
|
45
|
+
host=host or cfg.host,
|
|
46
|
+
port=port or cfg.port,
|
|
47
|
+
reload=reload,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command()
|
|
52
|
+
def migrate(config: _ConfigOpt = None) -> None:
|
|
53
|
+
"""Run ``alembic upgrade head`` against the configured database."""
|
|
54
|
+
cfg = WebConfig.load(config)
|
|
55
|
+
run_alembic_upgrade(cfg)
|
|
56
|
+
typer.echo(f"database migrated: {cfg.database_path}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command(name="reset-password")
|
|
60
|
+
def reset_password(
|
|
61
|
+
config: _ConfigOpt = None,
|
|
62
|
+
username: Annotated[
|
|
63
|
+
str | None, typer.Option(help="Which user (default: the only one).")
|
|
64
|
+
] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Set a new password for the single user."""
|
|
67
|
+
cfg = WebConfig.load(config)
|
|
68
|
+
run_alembic_upgrade(cfg)
|
|
69
|
+
engine = make_engine(cfg)
|
|
70
|
+
with Session(engine) as session:
|
|
71
|
+
statement = select(User)
|
|
72
|
+
if username is not None:
|
|
73
|
+
statement = statement.where(User.username == username)
|
|
74
|
+
user = session.exec(statement).first()
|
|
75
|
+
if user is None:
|
|
76
|
+
typer.echo("no user found — complete first-run setup in the browser first", err=True)
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
new_password = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
|
|
79
|
+
user.password_hash = hash_password(new_password)
|
|
80
|
+
user.updated_at = utcnow()
|
|
81
|
+
session.add(user)
|
|
82
|
+
session.commit()
|
|
83
|
+
typer.echo(f"password updated for {user.username}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
"""Console-script entry point for ``webvigil-web`` (see ``project.scripts``)."""
|
|
88
|
+
app()
|
webvigil/api/config.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Web API configuration: the ``[web]`` table of ``webvigil.toml`` plus env overrides."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
11
|
+
|
|
12
|
+
from webvigil.core.errors import ConfigError
|
|
13
|
+
|
|
14
|
+
_ENV_PREFIX = "WEBVIGIL_"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WebConfig(BaseModel):
|
|
18
|
+
"""
|
|
19
|
+
Everything the Web API needs to run. API-only — the engine never reads this.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
database_path (Path): SQLite file path. Defaults to ``webvigil.db``.
|
|
23
|
+
host (str): Bind host. Defaults to ``127.0.0.1``.
|
|
24
|
+
port (int): Bind port. Defaults to 8000.
|
|
25
|
+
session_secret (str | None): Pinned JWT signing secret; when ``None`` a
|
|
26
|
+
secret is generated and stored in the database on first use.
|
|
27
|
+
session_ttl_hours (int): Session lifetime, in hours. Defaults to 12.
|
|
28
|
+
cookie_secure (bool): Set the ``Secure`` flag on the session cookie.
|
|
29
|
+
Defaults to ``False`` (development over HTTP).
|
|
30
|
+
auto_migrate (bool): Run ``alembic upgrade head`` on startup. Defaults
|
|
31
|
+
to ``True``.
|
|
32
|
+
cors_origins (list[str]): Allowed CORS origins. Defaults to empty (the
|
|
33
|
+
bundled UI is same-origin).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
database_path: Path = Path("webvigil.db")
|
|
39
|
+
host: str = "127.0.0.1"
|
|
40
|
+
port: int = 8000
|
|
41
|
+
session_secret: str | None = None
|
|
42
|
+
session_ttl_hours: int = 12
|
|
43
|
+
cookie_secure: bool = False
|
|
44
|
+
auto_migrate: bool = True
|
|
45
|
+
cors_origins: list[str] = []
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def load(cls, path: str | Path | None = None) -> WebConfig:
|
|
49
|
+
"""
|
|
50
|
+
Build a config from the ``[web]`` table of ``path``, then env vars.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
path (str | Path | None): Path to a ``webvigil.toml``. When omitted,
|
|
54
|
+
``WEBVIGIL_CONFIG`` is used if set; when that is also unset, only
|
|
55
|
+
defaults and env vars apply.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
WebConfig: The validated configuration.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ConfigError: If the file is missing, is not valid TOML, has a
|
|
62
|
+
non-table ``[web]``, or fails model validation.
|
|
63
|
+
"""
|
|
64
|
+
path = path if path is not None else os.environ.get("WEBVIGIL_CONFIG")
|
|
65
|
+
data: dict[str, Any] = {}
|
|
66
|
+
if path is not None:
|
|
67
|
+
file = Path(path)
|
|
68
|
+
if not file.is_file():
|
|
69
|
+
raise ConfigError(f"config file not found: {file}")
|
|
70
|
+
try:
|
|
71
|
+
raw = tomllib.loads(file.read_text("utf-8"))
|
|
72
|
+
except tomllib.TOMLDecodeError as exc:
|
|
73
|
+
raise ConfigError(f"invalid TOML in {file}: {exc}") from exc
|
|
74
|
+
section = raw.get("web")
|
|
75
|
+
if section is not None:
|
|
76
|
+
if not isinstance(section, dict):
|
|
77
|
+
raise ConfigError("[web] must be a table")
|
|
78
|
+
data.update(section)
|
|
79
|
+
|
|
80
|
+
data.update(_env_overrides())
|
|
81
|
+
try:
|
|
82
|
+
return cls.model_validate(data)
|
|
83
|
+
except ValidationError as exc:
|
|
84
|
+
raise ConfigError(str(exc)) from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _env_overrides() -> dict[str, Any]:
|
|
88
|
+
"""
|
|
89
|
+
Read ``WEBVIGIL_*`` environment variables into a config-field mapping.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
dict[str, Any]: The overrides, with ``port`` / ``session_ttl_hours``
|
|
93
|
+
coerced to ``int``, the boolean flags parsed loosely, and
|
|
94
|
+
``cors_origins`` split on commas.
|
|
95
|
+
"""
|
|
96
|
+
out: dict[str, Any] = {}
|
|
97
|
+
mapping = {
|
|
98
|
+
"DATABASE_PATH": "database_path",
|
|
99
|
+
"WEB_HOST": "host",
|
|
100
|
+
"WEB_PORT": "port",
|
|
101
|
+
"SESSION_SECRET": "session_secret",
|
|
102
|
+
"SESSION_TTL_HOURS": "session_ttl_hours",
|
|
103
|
+
"COOKIE_SECURE": "cookie_secure",
|
|
104
|
+
"AUTO_MIGRATE": "auto_migrate",
|
|
105
|
+
"CORS_ORIGINS": "cors_origins",
|
|
106
|
+
}
|
|
107
|
+
for env_suffix, field in mapping.items():
|
|
108
|
+
value = os.environ.get(f"{_ENV_PREFIX}{env_suffix}")
|
|
109
|
+
if value is None:
|
|
110
|
+
continue
|
|
111
|
+
if field in ("port", "session_ttl_hours"):
|
|
112
|
+
out[field] = int(value)
|
|
113
|
+
elif field in ("cookie_secure", "auto_migrate"):
|
|
114
|
+
out[field] = value.strip().lower() in ("1", "true", "yes", "on")
|
|
115
|
+
elif field == "cors_origins":
|
|
116
|
+
out[field] = [origin.strip() for origin in value.split(",") if origin.strip()]
|
|
117
|
+
else:
|
|
118
|
+
out[field] = value
|
|
119
|
+
return out
|
webvigil/api/db.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLModel tables, the engine factory, and the Alembic upgrade wrapper (RF-23, RF-24, RF-25).
|
|
3
|
+
|
|
4
|
+
This module is API-only. The engine never imports it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterator
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import JSON, Column, Engine, String, event
|
|
17
|
+
from sqlmodel import Field, Session, SQLModel, create_engine
|
|
18
|
+
|
|
19
|
+
from webvigil.api.config import WebConfig
|
|
20
|
+
|
|
21
|
+
_MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def utcnow() -> datetime:
|
|
25
|
+
"""
|
|
26
|
+
Returns:
|
|
27
|
+
datetime: The current time, timezone-aware in UTC.
|
|
28
|
+
"""
|
|
29
|
+
return datetime.now(UTC)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScanStatus(StrEnum):
|
|
33
|
+
"""
|
|
34
|
+
Lifecycle state of a stored scan.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
QUEUED (str): Waiting for the runner's execution slot.
|
|
38
|
+
RUNNING (str): Currently executing.
|
|
39
|
+
COMPLETED (str): Finished; findings are stored.
|
|
40
|
+
FAILED (str): Ended with a controlled engine error or a crash.
|
|
41
|
+
CANCELLED (str): Cancelled by the user.
|
|
42
|
+
INTERRUPTED (str): Left ``RUNNING`` by a process that died; recovered on
|
|
43
|
+
the next startup.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
QUEUED = "queued"
|
|
47
|
+
RUNNING = "running"
|
|
48
|
+
COMPLETED = "completed"
|
|
49
|
+
FAILED = "failed"
|
|
50
|
+
CANCELLED = "cancelled"
|
|
51
|
+
INTERRUPTED = "interrupted"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
TERMINAL_STATUSES: frozenset[ScanStatus] = frozenset(
|
|
55
|
+
{
|
|
56
|
+
ScanStatus.COMPLETED,
|
|
57
|
+
ScanStatus.FAILED,
|
|
58
|
+
ScanStatus.CANCELLED,
|
|
59
|
+
ScanStatus.INTERRUPTED,
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class User(SQLModel, table=True):
|
|
65
|
+
"""
|
|
66
|
+
The single account (first-run setup creates it).
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
id (int | None): Primary key.
|
|
70
|
+
username (str): Unique, indexed login name.
|
|
71
|
+
password_hash (str): Argon2 hash.
|
|
72
|
+
created_at (datetime): When the account was created (UTC).
|
|
73
|
+
updated_at (datetime): When it was last changed, e.g. a password reset.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
__tablename__ = "user"
|
|
77
|
+
|
|
78
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
79
|
+
username: str = Field(unique=True, index=True)
|
|
80
|
+
password_hash: str
|
|
81
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
82
|
+
updated_at: datetime = Field(default_factory=utcnow)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Scan(SQLModel, table=True):
|
|
86
|
+
"""
|
|
87
|
+
One scan request and its result metadata.
|
|
88
|
+
|
|
89
|
+
Attributes:
|
|
90
|
+
id (int | None): Primary key.
|
|
91
|
+
target (str): The requested target URL.
|
|
92
|
+
mode (str): ``"passive"`` or ``"active"``.
|
|
93
|
+
scope (str): ``"host"`` or ``"subdomains"``.
|
|
94
|
+
options (dict[str, Any]): The extra overrides from the request
|
|
95
|
+
(``max_pages``, ``delay_ms``, ``follow_robots``, ``fail_on``,
|
|
96
|
+
``disabled_checks``).
|
|
97
|
+
status (ScanStatus): Lifecycle state; stored as its string value in a
|
|
98
|
+
plain, indexed ``String`` column.
|
|
99
|
+
authorized_by (str | None): Active-Mode attestation.
|
|
100
|
+
tool_version (str | None): WebVigil version that ran the scan.
|
|
101
|
+
error (str | None): Failure message, when ``status`` is ``FAILED`` /
|
|
102
|
+
``INTERRUPTED``.
|
|
103
|
+
created_at (datetime): When the request was queued (indexed).
|
|
104
|
+
started_at (datetime | None): When execution began.
|
|
105
|
+
finished_at (datetime | None): When execution ended.
|
|
106
|
+
pages_scanned (int): Pages the crawler fetched.
|
|
107
|
+
counts (dict[str, int]): Finding count per severity name.
|
|
108
|
+
check_errors (list[dict[str, Any]]): Serialised
|
|
109
|
+
:class:`~webvigil.core.result.CheckError` entries.
|
|
110
|
+
warnings (list[str]): Scan warnings.
|
|
111
|
+
technologies (list[dict[str, Any]]): Detected-technology inventory
|
|
112
|
+
(spec 004, RF-17); empty for pre-004 scans.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
__tablename__ = "scan"
|
|
116
|
+
|
|
117
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
118
|
+
target: str
|
|
119
|
+
mode: str
|
|
120
|
+
scope: str
|
|
121
|
+
options: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
|
122
|
+
status: ScanStatus = Field(
|
|
123
|
+
default=ScanStatus.QUEUED,
|
|
124
|
+
sa_column=Column("status", String, index=True, nullable=False),
|
|
125
|
+
)
|
|
126
|
+
authorized_by: str | None = None
|
|
127
|
+
tool_version: str | None = None
|
|
128
|
+
error: str | None = None
|
|
129
|
+
created_at: datetime = Field(default_factory=utcnow, index=True)
|
|
130
|
+
started_at: datetime | None = None
|
|
131
|
+
finished_at: datetime | None = None
|
|
132
|
+
pages_scanned: int = 0
|
|
133
|
+
counts: dict[str, int] = Field(default_factory=dict, sa_column=Column(JSON))
|
|
134
|
+
check_errors: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON))
|
|
135
|
+
warnings: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
|
136
|
+
technologies: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Finding(SQLModel, table=True):
|
|
140
|
+
"""
|
|
141
|
+
One stored finding, belonging to a :class:`Scan`.
|
|
142
|
+
|
|
143
|
+
Attributes:
|
|
144
|
+
id (int | None): Primary key.
|
|
145
|
+
scan_id (int): Owning scan; ``ON DELETE CASCADE``.
|
|
146
|
+
check_id (str): The check that produced it.
|
|
147
|
+
severity (int): The :class:`~webvigil.core.findings.Severity` integer
|
|
148
|
+
value.
|
|
149
|
+
confidence (int): The :class:`~webvigil.core.findings.Confidence`
|
|
150
|
+
integer value.
|
|
151
|
+
title (str): One-line summary.
|
|
152
|
+
description (str): Full explanation.
|
|
153
|
+
location (dict[str, Any]): Serialised
|
|
154
|
+
:class:`~webvigil.core.findings.Location`.
|
|
155
|
+
remediation (str): How to fix it.
|
|
156
|
+
evidence (list[dict[str, Any]]): Serialised evidence items.
|
|
157
|
+
cwe (list[int]): CWE ids.
|
|
158
|
+
references (list[str]): Further-reading URLs.
|
|
159
|
+
fingerprint (str): Stable dedup identity.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
__tablename__ = "finding"
|
|
163
|
+
|
|
164
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
165
|
+
scan_id: int = Field(foreign_key="scan.id", index=True, ondelete="CASCADE")
|
|
166
|
+
check_id: str
|
|
167
|
+
severity: int
|
|
168
|
+
confidence: int
|
|
169
|
+
title: str
|
|
170
|
+
description: str
|
|
171
|
+
location: dict[str, Any] = Field(sa_column=Column(JSON))
|
|
172
|
+
remediation: str
|
|
173
|
+
evidence: list[dict[str, Any]] = Field(default_factory=list, sa_column=Column(JSON))
|
|
174
|
+
cwe: list[int] = Field(default_factory=list, sa_column=Column(JSON))
|
|
175
|
+
references: list[str] = Field(default_factory=list, sa_column=Column(JSON))
|
|
176
|
+
fingerprint: str
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Setting(SQLModel, table=True):
|
|
180
|
+
"""
|
|
181
|
+
A single key/value row for process-wide settings (currently just the session secret).
|
|
182
|
+
|
|
183
|
+
Attributes:
|
|
184
|
+
key (str): Setting name (primary key).
|
|
185
|
+
value (str): Its string value.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
__tablename__ = "setting"
|
|
189
|
+
|
|
190
|
+
key: str = Field(primary_key=True)
|
|
191
|
+
value: str
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def make_engine(config: WebConfig) -> Engine:
|
|
195
|
+
"""
|
|
196
|
+
Create the SQLite engine, enabling WAL and foreign-key enforcement per connection.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
config (WebConfig): Supplies ``database_path``; the parent directory is
|
|
200
|
+
created if needed.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Engine: The configured SQLAlchemy engine.
|
|
204
|
+
"""
|
|
205
|
+
path = config.database_path
|
|
206
|
+
if path.parent and not path.parent.exists():
|
|
207
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
engine = create_engine(
|
|
209
|
+
f"sqlite:///{path}",
|
|
210
|
+
connect_args={"check_same_thread": False},
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
@event.listens_for(engine, "connect")
|
|
214
|
+
def _set_pragmas(dbapi_connection: Any, _record: Any) -> None:
|
|
215
|
+
"""Enable WAL journalling and foreign-key enforcement on every new connection."""
|
|
216
|
+
cursor = dbapi_connection.cursor()
|
|
217
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
218
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
219
|
+
cursor.close()
|
|
220
|
+
|
|
221
|
+
return engine
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@contextmanager
|
|
225
|
+
def session_scope(engine: Engine) -> Iterator[Session]:
|
|
226
|
+
"""
|
|
227
|
+
A transactional session context.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
engine (Engine): The DB engine.
|
|
231
|
+
|
|
232
|
+
Yields:
|
|
233
|
+
Session: A session that commits on a clean exit and rolls back on any
|
|
234
|
+
exception.
|
|
235
|
+
"""
|
|
236
|
+
with Session(engine) as session:
|
|
237
|
+
try:
|
|
238
|
+
yield session
|
|
239
|
+
session.commit()
|
|
240
|
+
except Exception:
|
|
241
|
+
session.rollback()
|
|
242
|
+
raise
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run_alembic_upgrade(config: WebConfig) -> None:
|
|
246
|
+
"""
|
|
247
|
+
Run ``alembic upgrade head`` against the configured database.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
config (WebConfig): Supplies ``database_path``.
|
|
251
|
+
"""
|
|
252
|
+
from alembic import command
|
|
253
|
+
from alembic.config import Config as AlembicConfig
|
|
254
|
+
|
|
255
|
+
alembic_cfg = AlembicConfig()
|
|
256
|
+
alembic_cfg.set_main_option("script_location", str(_MIGRATIONS_DIR))
|
|
257
|
+
alembic_cfg.set_main_option("sqlalchemy.url", f"sqlite:///{config.database_path}")
|
|
258
|
+
command.upgrade(alembic_cfg, "head")
|
webvigil/api/deps.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""FastAPI dependencies: the DB engine, a request-scoped session, and the current user."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
9
|
+
from sqlalchemy import Engine
|
|
10
|
+
from sqlmodel import Session
|
|
11
|
+
|
|
12
|
+
from webvigil.api.db import User
|
|
13
|
+
from webvigil.api.runner import ScanRunner
|
|
14
|
+
from webvigil.api.security import SESSION_COOKIE, credentials_changed_at, decode_token
|
|
15
|
+
|
|
16
|
+
_UNAUTHENTICATED = HTTPException(status.HTTP_401_UNAUTHORIZED, "not authenticated")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_engine(request: Request) -> Engine:
|
|
20
|
+
"""
|
|
21
|
+
Args:
|
|
22
|
+
request (Request): The current request.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Engine: The process-wide DB engine, stored on ``app.state`` by the
|
|
26
|
+
lifespan.
|
|
27
|
+
"""
|
|
28
|
+
engine: Engine = request.app.state.engine
|
|
29
|
+
return engine
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_session(request: Request) -> Iterator[Session]:
|
|
33
|
+
"""
|
|
34
|
+
Args:
|
|
35
|
+
request (Request): The current request.
|
|
36
|
+
|
|
37
|
+
Yields:
|
|
38
|
+
Session: A request-scoped session, closed when the request ends.
|
|
39
|
+
"""
|
|
40
|
+
with Session(request.app.state.engine) as session:
|
|
41
|
+
yield session
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SessionDep = Annotated[Session, Depends(get_session)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def optional_user(request: Request, session: SessionDep) -> User | None:
|
|
48
|
+
"""
|
|
49
|
+
Resolve the user from the session cookie, or ``None`` when there is none.
|
|
50
|
+
|
|
51
|
+
For the one endpoint that has to answer "am I signed in?" without turning
|
|
52
|
+
the answer into a 401 (a 401 shows up as a console error in the browser).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
request (Request): The current request; the session cookie is read
|
|
56
|
+
here.
|
|
57
|
+
session (SessionDep): The request-scoped session.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
User | None: The authenticated user, or ``None`` when the cookie is
|
|
61
|
+
absent, invalid, expired, names a user that no longer exists, or was issued before
|
|
62
|
+
the user's credentials last changed (a password change signs the other sessions out).
|
|
63
|
+
"""
|
|
64
|
+
token = request.cookies.get(SESSION_COOKIE)
|
|
65
|
+
if not token:
|
|
66
|
+
return None
|
|
67
|
+
claims = decode_token(token, request.app.state.session_secret)
|
|
68
|
+
if claims is None:
|
|
69
|
+
return None
|
|
70
|
+
user = session.get(User, claims.user_id)
|
|
71
|
+
if user is None or claims.issued_at < credentials_changed_at(user):
|
|
72
|
+
return None
|
|
73
|
+
return user
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
OptionalUser = Annotated[User | None, Depends(optional_user)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def current_user(user: OptionalUser) -> User:
|
|
80
|
+
"""
|
|
81
|
+
Require an authenticated user.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
user (OptionalUser): The user resolved from the session cookie, if any.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
User: The authenticated user.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
HTTPException: 401 when there is no valid session (see
|
|
91
|
+
:func:`optional_user` for what counts as one).
|
|
92
|
+
"""
|
|
93
|
+
if user is None:
|
|
94
|
+
raise _UNAUTHENTICATED
|
|
95
|
+
return user
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
CurrentUser = Annotated[User, Depends(current_user)]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_runner(request: Request) -> ScanRunner:
|
|
102
|
+
"""
|
|
103
|
+
Args:
|
|
104
|
+
request (Request): The current request.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
ScanRunner: The process-wide scan runner, stored on ``app.state`` by
|
|
108
|
+
the lifespan.
|
|
109
|
+
"""
|
|
110
|
+
runner: ScanRunner = request.app.state.runner
|
|
111
|
+
return runner
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
RunnerDep = Annotated[ScanRunner, Depends(get_runner)]
|