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.
Files changed (143) hide show
  1. webvigil/__init__.py +10 -0
  2. webvigil/api/__init__.py +7 -0
  3. webvigil/api/app.py +85 -0
  4. webvigil/api/cli.py +88 -0
  5. webvigil/api/config.py +119 -0
  6. webvigil/api/db.py +258 -0
  7. webvigil/api/deps.py +114 -0
  8. webvigil/api/mapping.py +179 -0
  9. webvigil/api/migrations/env.py +72 -0
  10. webvigil/api/migrations/script.py.mako +29 -0
  11. webvigil/api/migrations/versions/0001_initial.py +100 -0
  12. webvigil/api/migrations/versions/0002_scan_technologies.py +27 -0
  13. webvigil/api/routes/__init__.py +7 -0
  14. webvigil/api/routes/auth.py +82 -0
  15. webvigil/api/routes/meta.py +53 -0
  16. webvigil/api/routes/reports.py +57 -0
  17. webvigil/api/routes/scans.py +198 -0
  18. webvigil/api/routes/setup.py +50 -0
  19. webvigil/api/runner.py +334 -0
  20. webvigil/api/schemas.py +333 -0
  21. webvigil/api/security.py +176 -0
  22. webvigil/api/throttle.py +87 -0
  23. webvigil/auth/__init__.py +9 -0
  24. webvigil/auth/login.py +498 -0
  25. webvigil/auth/scrub.py +162 -0
  26. webvigil/checks/__init__.py +49 -0
  27. webvigil/checks/base.py +116 -0
  28. webvigil/checks/content/__init__.py +12 -0
  29. webvigil/checks/content/checks.py +276 -0
  30. webvigil/checks/cookies/__init__.py +9 -0
  31. webvigil/checks/cookies/flags.py +181 -0
  32. webvigil/checks/cors/__init__.py +9 -0
  33. webvigil/checks/cors/check.py +126 -0
  34. webvigil/checks/csrf/__init__.py +20 -0
  35. webvigil/checks/csrf/checks.py +245 -0
  36. webvigil/checks/csrf/scanner.py +524 -0
  37. webvigil/checks/csrf/tokens.py +37 -0
  38. webvigil/checks/deps/__init__.py +18 -0
  39. webvigil/checks/deps/_data.py +65 -0
  40. webvigil/checks/deps/advisories.py +259 -0
  41. webvigil/checks/deps/check.py +275 -0
  42. webvigil/checks/deps/data/PROVENANCE.json +7 -0
  43. webvigil/checks/deps/data/__init__.py +7 -0
  44. webvigil/checks/deps/data/retirejs.json +12323 -0
  45. webvigil/checks/deps/fingerprint.py +221 -0
  46. webvigil/checks/deps/osv.py +509 -0
  47. webvigil/checks/deps/rules.py +346 -0
  48. webvigil/checks/deps/staleness.py +36 -0
  49. webvigil/checks/disclosure/__init__.py +18 -0
  50. webvigil/checks/disclosure/catalogue.py +351 -0
  51. webvigil/checks/disclosure/checks.py +167 -0
  52. webvigil/checks/disclosure/data/__init__.py +6 -0
  53. webvigil/checks/disclosure/data/paths.toml +382 -0
  54. webvigil/checks/disclosure/errors.py +80 -0
  55. webvigil/checks/disclosure/leakage.py +252 -0
  56. webvigil/checks/disclosure/listing.py +82 -0
  57. webvigil/checks/disclosure/probe.py +432 -0
  58. webvigil/checks/disclosure/redaction.py +111 -0
  59. webvigil/checks/disclosure/signatures.py +184 -0
  60. webvigil/checks/envelope/__init__.py +15 -0
  61. webvigil/checks/envelope/checks.py +111 -0
  62. webvigil/checks/envelope/scanner.py +374 -0
  63. webvigil/checks/headers/__init__.py +11 -0
  64. webvigil/checks/headers/_parsing.py +102 -0
  65. webvigil/checks/headers/csp.py +124 -0
  66. webvigil/checks/headers/hsts.py +106 -0
  67. webvigil/checks/headers/misc.py +239 -0
  68. webvigil/checks/headers/revealing.py +84 -0
  69. webvigil/checks/injection/__init__.py +13 -0
  70. webvigil/checks/injection/checks.py +511 -0
  71. webvigil/checks/injection/detect/__init__.py +90 -0
  72. webvigil/checks/injection/detect/_diff.py +74 -0
  73. webvigil/checks/injection/detect/cmdi.py +225 -0
  74. webvigil/checks/injection/detect/crlf.py +102 -0
  75. webvigil/checks/injection/detect/el.py +578 -0
  76. webvigil/checks/injection/detect/ldap.py +125 -0
  77. webvigil/checks/injection/detect/redirect.py +108 -0
  78. webvigil/checks/injection/detect/sqli.py +210 -0
  79. webvigil/checks/injection/detect/ssi.py +117 -0
  80. webvigil/checks/injection/detect/ssrf.py +312 -0
  81. webvigil/checks/injection/detect/ssti.py +157 -0
  82. webvigil/checks/injection/detect/traversal.py +74 -0
  83. webvigil/checks/injection/detect/xpath.py +121 -0
  84. webvigil/checks/injection/detect/xss.py +108 -0
  85. webvigil/checks/injection/detect/xxe.py +135 -0
  86. webvigil/checks/injection/engine.py +363 -0
  87. webvigil/checks/injection/models.py +252 -0
  88. webvigil/checks/injection/payloads.py +516 -0
  89. webvigil/checks/injection/points.py +645 -0
  90. webvigil/checks/injection/stored.py +266 -0
  91. webvigil/checks/registry.py +122 -0
  92. webvigil/checks/session/__init__.py +14 -0
  93. webvigil/checks/session/checks.py +215 -0
  94. webvigil/checks/session/cookies.py +81 -0
  95. webvigil/checks/session/ids.py +251 -0
  96. webvigil/checks/session/scanner.py +433 -0
  97. webvigil/checks/tls/__init__.py +9 -0
  98. webvigil/checks/tls/_cert.py +167 -0
  99. webvigil/checks/tls/check.py +172 -0
  100. webvigil/checks/upload/__init__.py +16 -0
  101. webvigil/checks/upload/checks.py +91 -0
  102. webvigil/checks/upload/scanner.py +584 -0
  103. webvigil/cli/__init__.py +7 -0
  104. webvigil/cli/_exit.py +51 -0
  105. webvigil/cli/_render.py +210 -0
  106. webvigil/cli/app.py +701 -0
  107. webvigil/core/__init__.py +96 -0
  108. webvigil/core/config.py +604 -0
  109. webvigil/core/context.py +290 -0
  110. webvigil/core/errors.py +94 -0
  111. webvigil/core/findings.py +257 -0
  112. webvigil/core/orchestrator.py +789 -0
  113. webvigil/core/result.py +166 -0
  114. webvigil/core/target.py +186 -0
  115. webvigil/core/technology.py +60 -0
  116. webvigil/crawler/__init__.py +14 -0
  117. webvigil/crawler/crawler.py +606 -0
  118. webvigil/crawler/forms.py +323 -0
  119. webvigil/crawler/jsapp.py +87 -0
  120. webvigil/crawler/openapi.py +529 -0
  121. webvigil/crawler/robots.py +76 -0
  122. webvigil/crawler/safety.py +198 -0
  123. webvigil/crawler/sitemap.py +58 -0
  124. webvigil/http/__init__.py +25 -0
  125. webvigil/http/client.py +736 -0
  126. webvigil/http/policy.py +62 -0
  127. webvigil/http/scope_guard.py +50 -0
  128. webvigil/http/session.py +398 -0
  129. webvigil/http/tls_probe.py +197 -0
  130. webvigil/py.typed +0 -0
  131. webvigil/reporting/__init__.py +81 -0
  132. webvigil/reporting/_ordering.py +23 -0
  133. webvigil/reporting/html.py +43 -0
  134. webvigil/reporting/json_report.py +25 -0
  135. webvigil/reporting/markdown.py +88 -0
  136. webvigil/reporting/sarif.py +165 -0
  137. webvigil/reporting/templates/report.html.j2 +128 -0
  138. webvigil-1.0.0.dist-info/METADATA +390 -0
  139. webvigil-1.0.0.dist-info/RECORD +143 -0
  140. webvigil-1.0.0.dist-info/WHEEL +4 -0
  141. webvigil-1.0.0.dist-info/entry_points.txt +3 -0
  142. webvigil-1.0.0.dist-info/licenses/LICENSE +201 -0
  143. 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__"]
@@ -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)]