Compare commits

...
17 Commits
Author SHA1 Message Date
finnandClaude Opus 5.5 2b1160862a Fix several small security and robustness issues
Login:
- One message for unknown email, wrong password and deactivated
  account. Before, "This account is not enabled!" only appeared after
  the right password and confirmed valid credentials.
- Email and password must be non-empty strings. A missing password
  field became the string "None" and logged in a user with that
  password.

Forms:
- "Create user" no longer renders the password back into the HTML after
  a validation error.
- Password fields for a new password use autocomplete="new-password",
  so a password manager does not fill in the admin's own password. The
  email fields on the create pages use autocomplete="off".
- Email addresses are limited to 64 characters in the local part and
  254 in total (RFC 5321). Longer ones caused a database error and
  HTTP 500.
- alerts.html no longer translates flash messages a second time. Running
  str.format() again on an already formatted message was fragile and
  logged "Unknown translation" for every German message. All flashes
  now translate when flashing, "Invalid data" included.

Debug mode:
- The Docker image sets MAX_PRODUCTION=1, and the app refuses to start
  in debug mode then. FLASK_DEBUG=1 in production would otherwise enable
  the fixed secret key, cookies without Secure, no HSTS and query
  arguments (including password hashes) in the log.
- make run-dev and run-dev-as-prod bind to 127.0.0.1.

Other:
- A denied access is logged with the logger at debug level instead of
  print. PermissionDenied loses its unused message, it is deliberately
  answered with a 404.
- Fix the typo in /user/<id>/ser-toggle-can-use-different-alias-domain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:04:22 +00:00
finnandClaude Opus 5.5 58242117f6 Ask for the current password when changing the own password
A hijacked session was enough to change the own password, lock out the
owner and get permanent access, including the mailbox via Dovecot.

Changing the own password now requires the current password, for users
and admins alike. Admins changing the password of another user are not
asked, since an admin session can take over accounts in other ways
anyway. The new password fields are marked as new-password for password
managers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:38:14 +00:00
finnandClaude Opus 5.5 6e6ae3ccb7 Add the Python extension and interpreter path to the devcontainer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:24:37 +00:00
finnandClaude Opus 5.5 5b87d9ff03 Fix HTTP 500 on the create pages with ?return=detail
UserCreate and AliasCreate used BackToUsersMixin and BackToAliasesMixin,
which access self.user or self.alias for ?return=detail. Neither exists
on the create pages, so the request failed with an AttributeError.

Both views now define their own back link with the target they already
used without the parameter: the user list and the user's detail page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:24:37 +00:00
finnandClaude Opus 5.5 91eaef7f93 Pass only the return parameter to form actions
Five templates built their form action with url_for(..., **request.args),
so every query parameter ended up in the action. This included Flask's
special parameters (?_external=1 gave an absolute http:// action in
production) and the route parameters (?user_id=... or ?alias_id=...
caused a TypeError and HTTP 500).

The only parameter needed is return=detail, which sends the user back to
the detail page after the POST. ReturnToMixin reads it, passes on only
the known value and puts it into the template context as return_to.
BackToUsersMixin and BackToAliasesMixin build on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:24:37 +00:00
finnandClaude Opus 5.5 53081dd553 Pin all dependencies with hashes and add a CVE audit
Only the direct dependencies were pinned. Werkzeug, Jinja2, MarkupSafe,
itsdangerous and the other transitive packages were resolved at build
time, so builds of the same commit could differ, and pip did not verify
the downloaded files. There was also no vulnerability scan.

- requirements.in / requirements-dev.in list the direct dependencies.
  requirements.txt / requirements-dev.txt are lockfiles generated by
  pip-compile with exact versions and hashes for every package. The dev
  lockfile is constrained by the production one.
- Makefile targets: lock-deps, update-deps, sync-deps and audit
  (pip-audit against requirements.txt).
- The locked production versions match the previously installed ones.
  The first audit found no known vulnerabilities.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:30:08 +00:00
finnandClaude Opus 5.5 2fb357ae77 Set security headers on all responses
The app set no security headers at all, and the reverse proxy sets none
either.

- Content-Security-Policy: default-src 'none' with 'self' only for
  styles, images and fonts (MAX has no JavaScript), form-action 'self'
  (also blocks the http:// form action downgrade), frame-ancestors
  'none' and base-uri 'none'.
- X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
  Referrer-Policy: same-origin.
- Strict-Transport-Security with max-age of 7 days, not in debug mode.

The headers are set in after_request, so they also apply to static
files, 404 and error responses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:17:19 +00:00
finnandClaude Opus 5.5 931243dd60 Harden the container: non-root, no build tools, read-only, stdout logging
The app ran as root, and the production image contained gcc, vim, wget
and gnupg. Code execution in the container would have allowed changing
the app code and fetching or compiling further tools.

- Multi-stage build: the wheels (psycopg2) are built in a build stage.
  The runtime image only contains libpq5, postgresql-client and
  wait-for-it (724 MB -> 298 MB).
- The app runs as the system user max (uid/gid 10001). The code is
  owned by root and precompiled, the user has no writable directory.
- Log to stdout only; docker collects it. The log file under /app/log
  was lost on every container recreation anyway.
- docker-compose: read-only filesystem (tmpfs for /tmp), no
  capabilities, no-new-privileges and log rotation (5 x 10 MB).
- Add a .dockerignore and set MAX_ORIGIN in docker-compose.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:09:30 +00:00
finnandClaude Opus 5.5 7ac4e6c3a7 Handle sessions on password change correctly
remove_user_sessions() got the user id as except_id, so the exception
never matched. On an own password change the result was correct only
by accident. When an admin changed another user's password, the admin
session got a new id, but the old entry stayed valid in the store.

- New Session.regenerate(): keeps the data under a new id and removes
  the old store entry.
- change_password() handles the sessions: on an own password change it
  regenerates the current session and ends all other sessions of the
  user (except_id is now the session id). When an admin changes another
  user's password, the admin session stays untouched and all sessions
  of that user end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:48:03 +00:00
finnandClaude Opus 5.5 9aed6b5227 Set SameSite=Lax and use the __Host- prefix for the session cookie
The own SessionInterface ignored SESSION_COOKIE_SAMESITE, so the cookie
was sent with every cross-site request in Firefox and Safari (CSRF,
clickjacking in iframes, keeping the idle timeout alive). The cookie
name "session" could also be set by other subdomains or via plain HTTP,
the precondition for session fixation.

- SameSite=Lax. No GET changes state, so links from mails keep working.
- In production the cookie is named __Host-session, which browsers only
  accept with Secure, Path=/ and without Domain. The debug server keeps
  "session", since it runs without Secure.
- set_cookie and delete_cookie use the same attributes, since browsers
  ignore the deletion of a __Host- cookie without Secure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:40:54 +00:00
finnandClaude Opus 5.5 8e32727fcd Limit sessions per user instead of a global LRU cap
The session store held at most 100 sessions and evicted the least
recently used one. Anyone with an account could log in 101 times and
thereby log out every other user, including the admins.

- Each user has at most 10 sessions. A new login above the limit
  removes the user's own least recently used session (by _last_seen).
- There is no global limit anymore: the store only accepts sessions
  with a user_id (raises SessionWithoutUser otherwise), so it is
  bounded by 10 * number of users. Anonymous users still create no
  store entries.
- save_session clears an invalid session before re-raising, since
  Flask saves the session again for the error response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:27:44 +00:00
finnandClaude Opus 5.5 80757fa1d8 Fix HTTP 500 on missing email or domain when creating users and aliases
If the email or domain field was missing or invalid, the error was
flashed, but the address was still built from the unset attributes,
which raised an AttributeError. Only build and validate the address
if both are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:25:38 +00:00
finnandClaude Opus 5.5 4a03b5c1a1 Reserve privileged alias addresses for admins
Any user could create aliases like postmaster@, webmaster@ or abuse@ on
their domain. CAs use these addresses to validate domains, so a family
account (or a compromised one) could obtain TLS certificates or catch
abuse and system mails.

- Only admins may create aliases with a reserved local part (admin,
  administrator, webmaster, hostmaster, postmaster, abuse, security,
  noc, root, mailer-daemon). The acting user counts, so admins can
  still assign such aliases to other users.
- Escape the domains in the email regex, so "finn.st" no longer
  matches "finnXst".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 15:23:12 +00:00
finnandClaude Opus 5.5 fc24ba068d Prevent session fixation on login
login() kept the existing session id and CSRF token. Anyone who knew
the id before (e.g. via cookie injection) was logged in as the victim
afterwards and knew the CSRF token, too.

- login() clears the session after a successful check. Since the fix of
  Session.clear() this deletes the old store entry, drops the CSRF
  token, resets the timestamps and assigns a new id.
- open_session no longer adopts signed ids without a store entry, so an
  id chosen by the client never becomes a session id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:51:35 +00:00
finnandClaude Opus 5.5 cc3d064e64 Expire sessions after 30 min idle and 12 h absolute
Sessions were only bounded by a process restart or the LRU eviction, so
a stolen or fixed session id could stay valid for months. A cookie TTL
would not help, since an attacker with a copy of the id ignores it.

The store entry now carries _created_at and _last_seen (monotonic
time). They are kept out of the session dict as attributes of the
Session object, so is_empty() and the app code are unaffected.
open_session drops expired entries and returns a fresh, empty session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:46:44 +00:00
finnandClaude Opus 5.5 ca023e705c Invalidate sessions server-side on logout and deactivation
Session.clear() set the id to None before clearing, so on_update
assigned a new id and save_session deleted the store entry of that new
id. The entry of the old id with the user_id stayed valid: a copied
cookie survived the logout, and a session "logged out" by the auth
middleware for a disabled user was valid again after re-enabling.

- Session.clear() now removes the store entry of the current id.
- Deactivating a user removes all of their sessions immediately, like
  deleting a user does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:03:03 +00:00
finnandClaude Opus 5.5 dc85c578a2 Protect against login CSRF without cookies for anonymous users
Anonymous users must not get cookies or session store entries, so a
CSRF token for /login is not possible. Instead, every unsafe request is
checked statelessly via Sec-Fetch-Site, falling back to Origin. Requests
without both headers are not from a browser and are accepted. The
expected origin comes from the new MAX_ORIGIN setting, which is required
outside of debug mode.

Further CSRF changes:
- Do not delete the CSRF token on a failed check. It added no security,
  but invalidated forms in open tabs and could be triggered cross-site.
- Treat HEAD and OPTIONS as safe methods.

Keep the login page free of sessions:
- Show login messages via ?msg=login_required|logout (fixed keys only)
  and the login error via the template context instead of flash().
- Only send the session deletion cookie if the client sent a cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:03:03 +00:00
43 changed files with 1323 additions and 168 deletions

No files matched your search

+11 -1
View File
@@ -19,5 +19,15 @@
"postCreateCommand": "pip install -U pip && pip install -U -r requirements.txt -r requirements-dev.txt", "postCreateCommand": "pip install -U pip && pip install -U -r requirements.txt -r requirements-dev.txt",
"forwardPorts": [ "forwardPorts": [
8757 8757
] ],
"customizations": {
"vscode": {
"extensions": [
"ms-python.python"
],
"settings": {
"python.defaultInterpreterPath": "/opt/venv/bin/python"
}
}
}
} }
+8
View File
@@ -0,0 +1,8 @@
.git
.devcontainer
.vscode
.claude
docs
log
**/__pycache__
**/*.pyc
-1
View File
@@ -1,2 +1 @@
*.pyc *.pyc
log/
+28 -7
View File
@@ -1,20 +1,41 @@
# Build stage: compile the wheels (psycopg2 needs gcc and libpq-dev)
FROM python:3.15-slim-trixie AS build
RUN apt-get update && \
apt-get install --no-install-recommends -y gcc libc-dev libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# Runtime stage: no compiler and no tools besides what db-setup.sh needs
FROM python:3.15-slim-trixie FROM python:3.15-slim-trixie
RUN apt -y update && apt -y upgrade && \ RUN apt-get update && apt-get upgrade -y && \
apt install --no-install-recommends -y wait-for-it lsb-release wget gnupg2 gcc libpq-dev libc-dev vim postgresql-client && apt clean all apt-get install --no-install-recommends -y libpq5 postgresql-client wait-for-it && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
RUN groupadd --system --gid 10001 max && \
useradd --system --uid 10001 --gid max --no-create-home --shell /usr/sbin/nologin max
WORKDIR /app WORKDIR /app
COPY requirements.txt .
RUN pip install -U -r requirements.txt
RUN mkdir log
ENV PYTHONPATH=/app/ ENV PYTHONPATH=/app/
ENV FLASK_APP=max.app ENV FLASK_APP=max.app
# Refuses to start in debug mode, see max/app.py
ENV MAX_PRODUCTION=1
# Owned by root, so the app cannot modify its own code
COPY max/ ./max COPY max/ ./max
COPY scripts/prod/ . COPY scripts/prod/ .
# Precompile, since the app cannot write __pycache__ at runtime
RUN python -m compileall -q max
USER max
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"] CMD ["gunicorn", "-w", "1", "-b", "0.0.0.0:8000", "max.app:app"]
+19 -3
View File
@@ -6,15 +6,31 @@ clear-dev:
scripts/dev/clear-db.sh scripts/dev/clear-db.sh
run-dev: run-dev:
FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 0.0.0.0 --port 8757 FLASK_APP=max.app FLASK_DEBUG=1 flask run --host 127.0.0.1 --port 8757
run-dev-as-prod: run-dev-as-prod:
mkdir -p log FLASK_APP=max.app MAX_ORIGIN=http://localhost:8757 gunicorn -w 1 -b 127.0.0.1:8757 max.app:app
FLASK_APP=max.app LOG_DIR=log gunicorn -w 1 -b 0.0.0.0:8757 max.app:app
run-cleanup: run-cleanup:
scripts/dev/cleanup.sh scripts/dev/cleanup.sh
# DEPENDENCIES
PIP_COMPILE = pip-compile --quiet --generate-hashes --strip-extras --allow-unsafe
lock-deps:
$(PIP_COMPILE) requirements.in
$(PIP_COMPILE) requirements-dev.in
update-deps:
$(PIP_COMPILE) --upgrade requirements.in
$(PIP_COMPILE) --upgrade requirements-dev.in
sync-deps:
pip-sync requirements.txt requirements-dev.txt
audit:
pip-audit --strict -r requirements.txt
# PROD # PROD
build-prod: build-prod:
docker build -t max:$(shell cat VERSION) . docker build -t max:$(shell cat VERSION) .
+17
View File
@@ -9,3 +9,20 @@ The proxy should add X-Real-IP to get a better logging.
Requirements: Docker, Make Requirements: Docker, Make
Start with `make run-dev` Start with `make run-dev`
## Configuration
- `MAX_ORIGIN` (required in production): The public origin of MAX, e.g.
`https://max.finn.st`
- `MAX_PRODUCTION=1` is set in the Docker image and makes MAX refuse to start
in debug mode (`FLASK_DEBUG`). Do not override it.
## Dependencies
Direct dependencies are listed in `requirements.in` (production) and
`requirements-dev.in` (development tools). `requirements.txt` and
`requirements-dev.txt` are lockfiles with exact versions and hashes
- After changing a `.in` file: `make lock-deps` and `make sync-deps`
- Update all dependencies: `make update-deps` and `make sync-deps`
- Check for known vulnerabilities: `make audit` (before every release)
+13
View File
@@ -10,6 +10,19 @@ services:
- PGSQL_USER=max - PGSQL_USER=max
- PGSQL_PASSWORD=max - PGSQL_PASSWORD=max
- PGSQL_NAME=max - PGSQL_NAME=max
- MAX_ORIGIN=https://max.finn.st
read_only: true
tmpfs:
- /tmp # gunicorn worker heartbeat files
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks: networks:
- postgresql - postgresql
- uplink - uplink
+14 -1
View File
@@ -16,6 +16,7 @@ from .middlewares import (
add_middleware_stack, add_middleware_stack,
) )
from .routes import init_routes from .routes import init_routes
from .security_headers import init_security_headers
from .sessions import SessionInterface from .sessions import SessionInterface
from .translations import TranslationHandler from .translations import TranslationHandler
@@ -29,6 +30,10 @@ def main():
app = Flask(__name__) app = Flask(__name__)
init_logging(app) init_logging(app)
app.logger.info("Max started.") app.logger.info("Max started.")
if app.debug and os.environ.get("MAX_PRODUCTION") == "1":
raise RuntimeError(
"Debug mode is not allowed in production (MAX_PRODUCTION=1), unset FLASK_DEBUG"
)
if app.debug: if app.debug:
app.logger.info("!! App is in debug mode !!") app.logger.info("!! App is in debug mode !!")
else: else:
@@ -37,12 +42,19 @@ def main():
app.jinja_env.trim_blocks = True app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True app.jinja_env.lstrip_blocks = True
app.url_rule_class = Rule app.url_rule_class = Rule
max_origin = os.environ.get("MAX_ORIGIN")
if not max_origin and not app.debug:
raise RuntimeError(
"MAX_ORIGIN must be set to the public origin, e.g. https://max.example.com"
)
app.config.update( app.config.update(
MAX_ORIGIN=max_origin.rstrip("/") if max_origin else None,
SECRET_KEY=( SECRET_KEY=(
"123456" if app.debug else base64.b64encode(os.urandom(64)).decode("utf-8") "123456" if app.debug else base64.b64encode(os.urandom(64)).decode("utf-8")
), ),
SESSION_COOKIE_NAME="session" if app.debug else "__Host-session",
SESSION_COOKIE_SECURE=not app.debug, SESSION_COOKIE_SECURE=not app.debug,
SESSION_COOKIE_SAMESITE=False, SESSION_COOKIE_SAMESITE="Lax",
) )
add_middleware_stack( add_middleware_stack(
app, app,
@@ -57,6 +69,7 @@ def main():
register_cli(app) register_cli(app)
init_auth(app) init_auth(app)
init_routes(app) init_routes(app)
init_security_headers(app)
return app return app
+19 -4
View File
@@ -6,22 +6,31 @@ from flask import session
from max.crypto import constant_time_compare, force_bytes from max.crypto import constant_time_compare, force_bytes
from max.db import get_user_by_email, update_user from max.db import get_user_by_email, update_user
from max.sessions import session_storage
from max.translations import t from max.translations import t
SSHA512_PREFIX = "{SSHA512}" SSHA512_PREFIX = "{SSHA512}"
LOGIN_FAILED_MESSAGE = "Email or password is wrong, or the account is deactivated"
def login(email, password): def login(email, password):
if not isinstance(email, str) or not isinstance(password, str):
return t(LOGIN_FAILED_MESSAGE)
if not email or not password:
return t(LOGIN_FAILED_MESSAGE)
user = get_user_by_email(email) user = get_user_by_email(email)
if user is None: if user is None:
return t("Email or password was wrong!") return t(LOGIN_FAILED_MESSAGE)
if not check_password(password, user.passwordhash): if not check_password(password, user.passwordhash):
return t("Email or password was wrong!") return t(LOGIN_FAILED_MESSAGE)
if not user.enabled: if not user.enabled:
return t("This account is not enabled!") return t(LOGIN_FAILED_MESSAGE)
# Start a new session (new id, new CSRF token) against session fixation.
session.clear()
session["user_id"] = user.id session["user_id"] = user.id
return None return None
@@ -33,7 +42,13 @@ def logout():
def change_password(user, password): def change_password(user, password):
hash = hash_password(password) hash = hash_password(password)
update_user(user.id, passwordhash=hash) update_user(user.id, passwordhash=hash)
session.set_new_id() if session.get("user_id") == user.id:
# Own password: keep this session (with a new id), end all others
session.regenerate()
session_storage.remove_user_sessions(user.id, except_id=session.id)
else:
# An admin changes the password of another user: end all of their sessions
session_storage.remove_user_sessions(user.id)
def hash_password(password: str, salt: bytes = None): def hash_password(password: str, salt: bytes = None):
+2 -2
View File
@@ -23,12 +23,12 @@ class AuthMiddleware(BaseMiddleware):
if not getattr(self.handler, "no_auth", False) and not auth_user: if not getattr(self.handler, "no_auth", False) and not auth_user:
logout() # remove the session (if any) logout() # remove the session (if any)
# No usage of flash here to not create a session... # No usage of flash here to not create a session...
return redirect(url_for("login", a=1)) return redirect(url_for("login", msg="login_required"))
if auth_user: if auth_user:
if not auth_user.enabled: if not auth_user.enabled:
logout() logout()
return redirect(url_for("login", a=1)) return redirect(url_for("login", msg="login_required"))
kwargs["auth_user"] = auth_user kwargs["auth_user"] = auth_user
return self.handler(*args, **kwargs) return self.handler(*args, **kwargs)
+14 -4
View File
@@ -8,6 +8,12 @@ from max.views import BaseTemplateGetView, BaseView
from .auth import login, logout from .auth import login, logout
from .middlewares import no_authentication_required from .middlewares import no_authentication_required
# Messages for the login page (text, category), selected by ?msg=<key>.
LOGIN_MESSAGES = {
"login_required": ("Please login to visit the requested url", "error"),
"logout": ("Logout was sucessfull!", "success"),
}
def init_routes(app): def init_routes(app):
app.add_url_rule( app.add_url_rule(
@@ -26,7 +32,11 @@ class Login(AllowAnyAccess, BaseTemplateGetView):
def get_context(self): def get_context(self):
context = super().get_context() context = super().get_context()
context["alert"] = bool(self.request.args.get("a", False)) if "message" not in context:
message = LOGIN_MESSAGES.get(self.request.args.get("msg"))
if message is not None:
context["message"] = t(message[0])
context["message_category"] = message[1]
return context return context
def get_webpagetitle(self): def get_webpagetitle(self):
@@ -41,7 +51,8 @@ class Login(AllowAnyAccess, BaseTemplateGetView):
self.app.logger.info( self.app.logger.info(
"Failed login attempt from %s", self.request.real_remote_addr "Failed login attempt from %s", self.request.real_remote_addr
) )
flash(error, category="error") self.context["message"] = error
self.context["message_category"] = "error"
self.context["email"] = email self.context["email"] = email
else: else:
flash(t("Login was sucessfull!"), category="success") flash(t("Login was sucessfull!"), category="success")
@@ -51,5 +62,4 @@ class Login(AllowAnyAccess, BaseTemplateGetView):
class Logout(AllowAnyAccess, BaseView): class Logout(AllowAnyAccess, BaseView):
def post(self): def post(self):
logout() logout()
flash(t("Logout was sucessfull!"), category="success") return self.redirect(url_for("login", msg="logout"))
return self.redirect(url_for("login"))
+46 -15
View File
@@ -3,39 +3,70 @@ from functools import wraps
from flask import request, session from flask import request, session
from max.crypto import constant_time_compare, get_random_string from max.crypto import constant_time_compare, get_random_string
from max.middlewares import BaseMiddleware from max.middlewares import BaseMiddleware, get_real_remote_addr
CSRF_TOKEN_KEY = "csrf_token" CSRF_TOKEN_KEY = "csrf_token"
CSRF_TOKEN_LENGTH = 32 CSRF_TOKEN_LENGTH = 32
SAFE_METHODS = ("GET", "HEAD", "OPTIONS")
# "none": navigation started by the user (e.g. reload). "same-site" is rejected,
# since other services on a subdomain must not trigger requests.
ALLOWED_FETCH_SITES = ("same-origin", "none")
class CsrfMiddleware(BaseMiddleware): class CsrfMiddleware(BaseMiddleware):
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
error = self.process_request() error = self.process_request()
if error is not None: if error is not None:
# clear csrf from session return error
if CSRF_TOKEN_KEY in session:
del session[CSRF_TOKEN_KEY]
return error, 400
return self.handler(*args, **kwargs) return self.handler(*args, **kwargs)
def process_request(self): def process_request(self):
"""If a string is returned, it mist be used as a 400er error""" """If a (message, status) tuple is returned, it must be used as the response"""
if getattr(self.handler, "no_csrf", False): no_csrf = getattr(self.handler, "no_csrf", False)
if request.method in SAFE_METHODS:
if not no_csrf and CSRF_TOKEN_KEY not in session:
session[CSRF_TOKEN_KEY] = get_random_string(CSRF_TOKEN_LENGTH)
return return
session_has_csrf_token = CSRF_TOKEN_KEY in session # Stateless check, so it also protects routes without a session (login)
method_is_unsafe = request.method.lower() != "get" if not self.is_origin_allowed():
self.app.logger.debug(
'Blocked cross-origin request %s %s origin="%s" sec_fetch_site="%s" from %s',
request.method,
request.path,
request.headers.get("Origin"),
request.headers.get("Sec-Fetch-Site"),
get_real_remote_addr(self.app),
)
return "Cross-origin request blocked", 403
if not session_has_csrf_token and method_is_unsafe: if no_csrf:
return "CSRF token is missing" return
elif not session_has_csrf_token and not method_is_unsafe: if CSRF_TOKEN_KEY not in session:
session[CSRF_TOKEN_KEY] = get_random_string(CSRF_TOKEN_LENGTH) return "CSRF token is missing", 400
elif session_has_csrf_token and method_is_unsafe: error = self.check_token(session[CSRF_TOKEN_KEY], request.form)
return self.check_token(session[CSRF_TOKEN_KEY], request.form) if error is not None:
return error, 400
def is_origin_allowed(self):
fetch_site = request.headers.get("Sec-Fetch-Site")
if fetch_site is not None:
return fetch_site in ALLOWED_FETCH_SITES
origin = request.headers.get("Origin")
if origin is None:
return True # not a browser, so no CSRF possible
return origin == self.get_expected_origin()
def get_expected_origin(self):
origin = self.app.config["MAX_ORIGIN"]
if origin is None:
origin = request.host_url.rstrip("/")
return origin
def check_token(self, session_token, formdata): def check_token(self, session_token, formdata):
if CSRF_TOKEN_KEY not in formdata: if CSRF_TOKEN_KEY not in formdata:
+8 -3
View File
@@ -10,7 +10,12 @@ class NotFound(MaxException):
pass pass
class SessionWithoutUser(MaxException):
pass
class PermissionDenied(MaxException): class PermissionDenied(MaxException):
def __init__(self, message): """
super().__init__() Deliberately answered with a 404, like NotFound, so nobody can tell
self.message = message whether the object exists.
"""
+8 -22
View File
@@ -1,37 +1,23 @@
import logging import logging
import os
import sys import sys
from logging.config import dictConfig from logging.config import dictConfig
def init_logging(app): def init_logging(app):
handlers = {
"stdout": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "default",
},
}
active_handlers = ["stdout"]
if not app.debug:
handlers["file"] = {
"class": "logging.handlers.RotatingFileHandler",
"filename": os.path.join(os.environ.get("LOG_DIR", "/app/log"), "max.log"),
"formatter": "default",
"maxBytes": 1024 * 1024,
"backupCount": 5,
}
active_handlers.append("file")
dictConfig( dictConfig(
{ {
"version": 1, "version": 1,
"formatters": { "formatters": {
"default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"} "default": {"format": "[%(asctime)s] [%(levelname)s]: %(message)s"}
}, },
"handlers": handlers, "handlers": {
"root": {"level": "INFO", "handlers": active_handlers}, "stdout": {
"gunicorn": {"level": "INFO", "handlers": active_handlers}, "class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": "default",
},
},
"root": {"level": "INFO", "handlers": ["stdout"]},
} }
) )
logger = logging.getLogger(app.name) logger = logging.getLogger(app.name)
+8 -4
View File
@@ -20,12 +20,16 @@ class BaseMiddleware:
raise NotImplementedError() raise NotImplementedError()
def get_real_remote_addr(app):
real_ip = request.headers.get("X-Real-IP", None)
if real_ip is None and app.debug:
real_ip = request.remote_addr
return real_ip
class LoggingMiddleware(BaseMiddleware): class LoggingMiddleware(BaseMiddleware):
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
real_ip = request.headers.get("X-Real-IP", None) setattr(request, "real_remote_addr", get_real_remote_addr(self.app))
if real_ip is None and self.app.debug:
real_ip = request.remote_addr
setattr(request, "real_remote_addr", real_ip)
start = time.time() start = time.time()
+8 -3
View File
@@ -38,8 +38,7 @@
"Delete alias {alias}": "Lösche Alias {alias}", "Delete alias {alias}": "Lösche Alias {alias}",
"Alias {alias} for {user}": "Alias {alias} für {user}", "Alias {alias} for {user}": "Alias {alias} für {user}",
"Edit note for alias {alias}": "Notiz für Alias {alias} bearbeiten", "Edit note for alias {alias}": "Notiz für Alias {alias} bearbeiten",
"Email or password was wrong!": "Email oder Password ist falsch!", "Email or password is wrong, or the account is deactivated": "E-Mail oder Passwort falsch, oder der Account ist deaktiviert",
"This account is not enabled!": "Dieser Account ist nicht aktiviert!",
"Login was sucessfull!": "Erfolgreich angemeldet!", "Login was sucessfull!": "Erfolgreich angemeldet!",
"Logout was sucessfull!": "Erfolgreich abgemeldet!", "Logout was sucessfull!": "Erfolgreich abgemeldet!",
"The email is not valid": "Die Email ist nicht valide", "The email is not valid": "Die Email ist nicht valide",
@@ -51,6 +50,9 @@
"Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!", "Note changed successfully!": "Notiz wurde erfolgreich bearbeitet!",
"Both passwords must be given": "Beide Passwörter müssen gegeben sein", "Both passwords must be given": "Beide Passwörter müssen gegeben sein",
"Both passwords must be the same": "Beide Passwörter müssen übereinstimmen", "Both passwords must be the same": "Beide Passwörter müssen übereinstimmen",
"Current password": "Aktuelles Passwort",
"The current password must be given": "Das aktuelle Passwort muss gegeben sein",
"The current password is wrong": "Das aktuelle Passwort ist falsch",
"Password changed successfully!": "Passwort erfolgreich geändert!", "Password changed successfully!": "Passwort erfolgreich geändert!",
"Admin is not set": "Admin ist nicht gesetzt", "Admin is not set": "Admin ist nicht gesetzt",
"Creation of {email} was successful!": "{email} wurde erfolgreich erstellt!", "Creation of {email} was successful!": "{email} wurde erfolgreich erstellt!",
@@ -64,6 +66,9 @@
"Used as an user.": "Wird für einen Nutzer verwendet", "Used as an user.": "Wird für einen Nutzer verwendet",
"Used as an alias by {alias}.": "Word für den Alias {alias} verwendet", "Used as an alias by {alias}.": "Word für den Alias {alias} verwendet",
"Invalid domain": "Invalide Domain", "Invalid domain": "Invalide Domain",
"Invalid data": "Ungültige Daten",
"The email is too long": "Die E-Mail ist zu lang",
"Can use different alias domains": "Kann unterschiedliche Aliasdomains benutzen", "Can use different alias domains": "Kann unterschiedliche Aliasdomains benutzen",
"Can use different alias domains is not set": "Kann unterschiedliche Aliasdomains benutzen ist nicht gesetzt" "Can use different alias domains is not set": "Kann unterschiedliche Aliasdomains benutzen ist nicht gesetzt",
"This address is reserved and can only be created by an admin.": "Diese Adresse ist reserviert und kann nur von einem Admin angelegt werden."
} }
+1 -1
View File
@@ -46,7 +46,7 @@ def init_routes(app):
UserToggleAdmin.as_view(), UserToggleAdmin.as_view(),
) )
app.add_url_rule( app.add_url_rule(
"/user/<int:user_id>/ser-toggle-can-use-different-alias-domain", "/user/<int:user_id>/toggle-can-use-different-alias-domain",
"user-toggle-can-use-different-alias-domain", "user-toggle-can-use-different-alias-domain",
UserToggleCanUseDifferentAliasDomain.as_view(), UserToggleCanUseDifferentAliasDomain.as_view(),
) )
+15 -8
View File
@@ -1,22 +1,22 @@
from flask import flash from flask import flash, url_for
from max.db import EmailAlreadyExists, create_alias, get_domains from max.db import EmailAlreadyExists, create_alias, get_domains
from max.permissions import AllowAdminOrSelf from max.permissions import AllowAdminOrSelf
from max.translations import t from max.translations import t
from ..base_alias_views import BackToAliasesMixin
from ..base_user_views import FetchUserTemplateGetView from ..base_user_views import FetchUserTemplateGetView
from ..check_email_mixin import CheckEmailMixin from ..check_email_mixin import CheckEmailMixin
class AliasCreate( class AliasCreate(AllowAdminOrSelf, CheckEmailMixin, FetchUserTemplateGetView):
AllowAdminOrSelf, CheckEmailMixin, BackToAliasesMixin, FetchUserTemplateGetView
):
template_name = "alias/create.html" template_name = "alias/create.html"
def get_webpagetitle(self): def get_webpagetitle(self):
return f"Create new alias for {self.user.email}" return f"Create new alias for {self.user.email}"
def get_back_text_and_url(self):
return self.user.email, url_for("user-detail", user_id=self.user.id)
def post(self): def post(self):
error = False error = False
email = self.request.form.get("email") email = self.request.form.get("email")
@@ -39,9 +39,16 @@ class AliasCreate(
else: else:
self.domain = self.get_user_domain() self.domain = self.get_user_domain()
full_email = f"{self.email}@{self.domain}" if not error:
if not self.is_email_valid(full_email): full_email = f"{self.email}@{self.domain}"
error = True if not self.is_email_valid(full_email):
error = True
elif not self.auth_user.is_admin and self.is_reserved_email(full_email):
flash(
t("This address is reserved and can only be created by an admin."),
category="error",
)
error = True
enabled = self.request.form.get("enabled") enabled = self.request.form.get("enabled")
if enabled == "on": if enabled == "on":
+1 -1
View File
@@ -16,7 +16,7 @@ class AliasEditNote(AllowAdminOrSelf, BackToAliasesMixin, FetchAliasTemplateGetV
def post(self): def post(self):
note = self.request.form.get("note", "") note = self.request.form.get("note", "")
if not isinstance(note, str): if not isinstance(note, str):
flash("invalid data", category="error") flash(t("Invalid data"), category="error")
return return
update_alias(self.alias.id, note=note) update_alias(self.alias.id, note=note)
+4 -2
View File
@@ -4,6 +4,8 @@ from max.db import get_alias_with_user_by_id
from max.exceptions import NotFound from max.exceptions import NotFound
from max.views import BaseTemplateGetView from max.views import BaseTemplateGetView
from .return_to_mixin import RETURN_DETAIL, ReturnToMixin
class FetchAliasMixin: class FetchAliasMixin:
def before_handler(self): def before_handler(self):
@@ -21,9 +23,9 @@ class FetchAliasTemplateGetView(FetchAliasMixin, BaseTemplateGetView):
return context return context
class BackToAliasesMixin: class BackToAliasesMixin(ReturnToMixin):
def get_back_text_and_url(self): def get_back_text_and_url(self):
if self.request.args.get("return") == "detail": if self.get_return_to() == RETURN_DETAIL:
return self.user.email, url_for("alias-detail", alias_id=self.alias.id) return self.user.email, url_for("alias-detail", alias_id=self.alias.id)
else: else:
return self.user.email, url_for("user-detail", user_id=self.user.id) return self.user.email, url_for("user-detail", user_id=self.user.id)
+4 -2
View File
@@ -5,6 +5,8 @@ from max.exceptions import NotFound
from max.translations import t from max.translations import t
from max.views import BaseTemplateGetView from max.views import BaseTemplateGetView
from .return_to_mixin import RETURN_DETAIL, ReturnToMixin
class FetchUserMixin: class FetchUserMixin:
def before_handler(self): def before_handler(self):
@@ -20,9 +22,9 @@ class FetchUserTemplateGetView(FetchUserMixin, BaseTemplateGetView):
return context return context
class BackToUsersMixin: class BackToUsersMixin(ReturnToMixin):
def get_back_text_and_url(self): def get_back_text_and_url(self):
if self.request.args.get("return") == "detail": if self.get_return_to() == RETURN_DETAIL:
return self.user.email, url_for("user-detail", user_id=self.user.id) return self.user.email, url_for("user-detail", user_id=self.user.id)
else: else:
return t("User list"), url_for("user-list") return t("User list"), url_for("user-list")
+30 -1
View File
@@ -5,14 +5,43 @@ from flask import flash
from max.db import get_domains from max.db import get_domains
from max.translations import t from max.translations import t
# RFC 5321, both below the varchar(255) of emails.email
MAX_LOCAL_PART_LENGTH = 64
MAX_EMAIL_LENGTH = 254
RESERVED_LOCAL_PARTS = frozenset(
(
"admin",
"administrator",
"webmaster",
"hostmaster",
"postmaster",
"abuse",
"security",
"noc",
"root",
"mailer-daemon",
)
)
class CheckEmailMixin: class CheckEmailMixin:
def is_email_valid(self, email: str): def is_email_valid(self, email: str):
"""Checks for the email being valid and that it has a valid domain.""" """Checks for the email being valid and that it has a valid domain."""
regex = re.compile(f"^[a-zA-Z0-9+_-]+@({'|'.join(get_domains())})$") local_part = email.split("@")[0]
if len(local_part) > MAX_LOCAL_PART_LENGTH or len(email) > MAX_EMAIL_LENGTH:
flash(t("The email is too long"), category="error")
return False
domains = "|".join(map(re.escape, get_domains()))
regex = re.compile(f"^[a-zA-Z0-9+_-]+@({domains})$")
match = regex.match(email) match = regex.match(email)
if not match: if not match:
flash(t("The email is not valid"), category="error") flash(t("The email is not valid"), category="error")
return False return False
return True return True
def is_reserved_email(self, email: str):
local_part = email.split("@")[0]
return local_part.lower() in RESERVED_LOCAL_PARTS
+17
View File
@@ -0,0 +1,17 @@
RETURN_DETAIL = "detail"
class ReturnToMixin:
"""
Reads the `return` query parameter, which tells where to go back to
"""
def get_return_to(self):
if self.request.args.get("return") == RETURN_DETAIL:
return RETURN_DETAIL
return None
def get_context(self):
context = super().get_context()
context["return_to"] = self.get_return_to()
return context
+22 -5
View File
@@ -1,8 +1,7 @@
from flask import flash, session from flask import flash
from max.auth import change_password from max.auth import change_password, check_password
from max.permissions import AllowAdminOrSelf from max.permissions import AllowAdminOrSelf
from max.sessions import session_storage
from max.translations import t from max.translations import t
from ..base_user_views import BackToUsersMixin, FetchUserTemplateGetView from ..base_user_views import BackToUsersMixin, FetchUserTemplateGetView
@@ -14,20 +13,38 @@ class UserChangePassword(AllowAdminOrSelf, BackToUsersMixin, FetchUserTemplateGe
def get_webpagetitle(self): def get_webpagetitle(self):
return t("Change password for {user}", user=self.user.email) return t("Change password for {user}", user=self.user.email)
def is_own_password(self):
return self.user.id == self.auth_user.id
def get_context(self):
context = super().get_context()
context["is_own_password"] = self.is_own_password()
return context
def post(self): def post(self):
pw1 = self.request.form.get("password1") pw1 = self.request.form.get("password1")
pw2 = self.request.form.get("password2") pw2 = self.request.form.get("password2")
if not pw1 or not pw2: if not pw1 or not pw2:
flash(t("Both passwords must be given"), category="error") flash(t("Both passwords must be given"), category="error")
return return
if not isinstance(pw1, str) or not isinstance(pw2, str): if not isinstance(pw1, str) or not isinstance(pw2, str):
flash("Invalid data") flash(t("Invalid data"), category="error")
return return
if self.is_own_password():
current_password = self.request.form.get("current_password")
if not current_password or not isinstance(current_password, str):
flash(t("The current password must be given"), category="error")
return
if not check_password(current_password, self.user.passwordhash):
flash(t("The current password is wrong"), category="error")
return
if pw1 != pw2: if pw1 != pw2:
flash(t("Both passwords must be the same"), category="error") flash(t("Both passwords must be the same"), category="error")
return return
change_password(self.user, pw1) change_password(self.user, pw1)
session_storage.remove_user_sessions(self.user.id, except_id=session["user_id"])
flash(t("Password changed successfully!"), category="success") flash(t("Password changed successfully!"), category="success")
return self.redirect() return self.redirect()
+10 -8
View File
@@ -1,4 +1,4 @@
from flask import flash from flask import flash, url_for
from max.auth import hash_password from max.auth import hash_password
from max.db import EmailAlreadyExists, create_user, get_domains from max.db import EmailAlreadyExists, create_user, get_domains
@@ -6,11 +6,10 @@ from max.permissions import AllowAdmin
from max.translations import t from max.translations import t
from max.views import BaseTemplateGetView from max.views import BaseTemplateGetView
from ..base_user_views import BackToUsersMixin
from ..check_email_mixin import CheckEmailMixin from ..check_email_mixin import CheckEmailMixin
class UserCreate(AllowAdmin, CheckEmailMixin, BackToUsersMixin, BaseTemplateGetView): class UserCreate(AllowAdmin, CheckEmailMixin, BaseTemplateGetView):
template_name = "user/create.html" template_name = "user/create.html"
def post(self): def post(self):
@@ -32,9 +31,10 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BackToUsersMixin, BaseTemplateGetV
else: else:
self.domain = domain self.domain = domain
full_email = f"{self.email}@{self.domain}" if not error:
if not self.is_email_valid(full_email): full_email = f"{self.email}@{self.domain}"
error = True if not self.is_email_valid(full_email):
error = True
pw1 = self.request.form.get("password1") pw1 = self.request.form.get("password1")
pw2 = self.request.form.get("password2") pw2 = self.request.form.get("password2")
@@ -42,7 +42,7 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BackToUsersMixin, BaseTemplateGetV
flash(t("Both passwords must be given"), category="error") flash(t("Both passwords must be given"), category="error")
error = True error = True
elif not isinstance(pw1, str) or not isinstance(pw2, str): elif not isinstance(pw1, str) or not isinstance(pw2, str):
flash("Invalid data") flash(t("Invalid data"), category="error")
error = True error = True
elif pw1 != pw2: elif pw1 != pw2:
flash(t("Both passwords must be the same"), category="error") flash(t("Both passwords must be the same"), category="error")
@@ -120,7 +120,6 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BackToUsersMixin, BaseTemplateGetV
for key in ( for key in (
"email", "email",
"domain", "domain",
"password",
"enabled", "enabled",
"is_admin", "is_admin",
"can_use_different_alias_domain", "can_use_different_alias_domain",
@@ -133,3 +132,6 @@ class UserCreate(AllowAdmin, CheckEmailMixin, BackToUsersMixin, BaseTemplateGetV
def get_webpagetitle(self): def get_webpagetitle(self):
return t("Create new user") return t("Create new user")
def get_back_text_and_url(self):
return t("User list"), url_for("user-list")
+1 -1
View File
@@ -16,7 +16,7 @@ class UserEditNote(AllowAdmin, BackToUsersMixin, FetchUserTemplateGetView):
def post(self): def post(self):
note = self.request.form.get("note", "") note = self.request.form.get("note", "")
if not isinstance(note, str): if not isinstance(note, str):
flash("invalid data", category="error") flash(t("Invalid data"), category="error")
return return
update_user(self.user.id, note=note) update_user(self.user.id, note=note)
+3
View File
@@ -2,6 +2,7 @@ from flask import flash
from max.db import is_last_enabled_admin, update_user from max.db import is_last_enabled_admin, update_user
from max.permissions import AllowAdmin from max.permissions import AllowAdmin
from max.sessions import session_storage
from max.translations import t from max.translations import t
from max.views import BaseView from max.views import BaseView
@@ -20,6 +21,8 @@ class UserToggleEnabled(AllowAdmin, FetchUserMixin, BackToUsersMixin, BaseView):
) )
else: else:
update_user(self.user.id, enabled=(not self.user.enabled)) update_user(self.user.id, enabled=(not self.user.enabled))
if self.user.enabled: # the user was deactivated
session_storage.remove_user_sessions(self.user.id)
_, back_url = self.get_back_text_and_url() _, back_url = self.get_back_text_and_url()
return self.redirect(back_url) return self.redirect(back_url)
+29
View File
@@ -0,0 +1,29 @@
CONTENT_SECURITY_POLICY = "; ".join(
(
"default-src 'none'",
"style-src 'self'",
"img-src 'self'",
"font-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
)
)
HSTS_MAX_AGE = 7 * 24 * 60 * 60 # in seconds
def init_security_headers(app):
headers = {
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
}
if not app.debug:
headers["Strict-Transport-Security"] = f"max-age={HSTS_MAX_AGE}"
# after_request also covers static files, 404 and error responses
@app.after_request
def set_security_headers(response):
response.headers.update(headers)
return response
+95 -40
View File
@@ -1,78 +1,102 @@
import time
from flask import request
from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin from flask.sessions import SessionInterface as FlaskSessionInterface, SessionMixin
from itsdangerous import BadSignature, Signer, want_bytes from itsdangerous import BadSignature, Signer, want_bytes
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
from max.crypto import get_random_string from max.crypto import get_random_string
from max.exceptions import SessionWithoutUser
# in seconds
SESSION_IDLE_TIMEOUT = 30 * 60
SESSION_ABSOLUTE_TIMEOUT = 12 * 60 * 60
# Keys for the session metadata in the session storage
CREATED_AT_KEY = "_created_at"
LAST_SEEN_KEY = "_last_seen"
SESSION_MAX_PER_USER = 10
class SessionStorage: class SessionStorage:
def __init__(self, maxcap=100): def __init__(self):
self.maxcap = maxcap
self.lru_ids = [] # newest ids are on the end (so do append to add a fresh id)
self.data = {} self.data = {}
def __getitem__(self, id): def __getitem__(self, id):
if id not in self.data: return self.data.get(id)
return None
self._refresh_id(id)
return self.data[id]
def __delitem__(self, id): def __delitem__(self, id):
if id in self.data: self.data.pop(id, None)
del self.data[id]
self._remove_id(id)
def __setitem__(self, id, data): def __setitem__(self, id, data):
self._refresh_id(id) if data.get("user_id") is None:
raise SessionWithoutUser("Refusing to store a session without user_id")
if id not in self.data:
self._limit_user_sessions(data["user_id"])
self.data[id] = data self.data[id] = data
while len(self.lru_ids) > self.maxcap: def _limit_user_sessions(self, user_id):
removed_id = self.lru_ids.pop(0) # remove first element """Makes room for one new session by removing the least recently used ones"""
del self.data[removed_id] user_ids = [id for id, data in self.data.items() if data["user_id"] == user_id]
user_ids.sort(key=lambda id: self.data[id][LAST_SEEN_KEY])
def _remove_id(self, id): for id in user_ids[: max(0, len(user_ids) - SESSION_MAX_PER_USER + 1)]:
self.lru_ids = [_id for _id in self.lru_ids if _id != id] del self.data[id]
def _refresh_id(self, id):
self._remove_id(id)
self.lru_ids.append(id)
def remove_user_sessions(self, user_id, except_id=None): def remove_user_sessions(self, user_id, except_id=None):
ids_to_remove = [] ids_to_remove = []
for id, data in self.data.items(): for id, data in self.data.items():
if data.get("user_id", None) == user_id and id != except_id: if data["user_id"] == user_id and id != except_id:
ids_to_remove.append(id) ids_to_remove.append(id)
for id in ids_to_remove: for id in ids_to_remove:
del self.data[id] del self.data[id]
self._remove_id(id)
session_storage = SessionStorage() session_storage = SessionStorage()
class Session(CallbackDict, SessionMixin): class Session(CallbackDict, SessionMixin):
def __init__(self, id=None, data=None): def __init__(self, id=None, data=None, created_at=None, last_seen=None):
def on_update(self): def on_update(self):
self.modified = True self.modified = True
if not self.id: if not self.id:
self.set_new_id() self.set_new_id()
self.id = id self.id = id
self.created_at = created_at
self.last_seen = last_seen
self.modified = False self.modified = False
CallbackDict.__init__(self, data, on_update) CallbackDict.__init__(self, data, on_update)
def is_empty(self): def is_empty(self):
return len(self) == 0 return len(self) == 0
def is_expired(self, now):
return (
now - self.created_at > SESSION_ABSOLUTE_TIMEOUT
or now - self.last_seen > SESSION_IDLE_TIMEOUT
)
def clear(self): def clear(self):
"""Removes all data, including the entry in the session storage"""
del session_storage[self.id]
self.id = None self.id = None
self.created_at = None
self.last_seen = None
super().clear() super().clear()
def set_new_id(self): def set_new_id(self):
self.id = get_random_string(32) self.id = get_random_string(32)
def regenerate(self):
"""Keeps the data under a new id. The old id becomes invalid."""
del session_storage[self.id]
self.set_new_id()
self.modified = True
class SessionInterface(FlaskSessionInterface): class SessionInterface(FlaskSessionInterface):
def _get_signer(self, app): def _get_signer(self, app):
@@ -84,7 +108,14 @@ class SessionInterface(FlaskSessionInterface):
return app.config["SESSION_COOKIE_NAME"] return app.config["SESSION_COOKIE_NAME"]
def make_session_object(self, session_id=None, data=None): def make_session_object(self, session_id=None, data=None):
return Session(session_id, data=data) created_at = last_seen = None
if data is not None:
data = dict(data) # do not modify the entry in the session storage
created_at = data.pop(CREATED_AT_KEY)
last_seen = data.pop(LAST_SEEN_KEY)
return Session(
session_id, data=data, created_at=created_at, last_seen=last_seen
)
def open_session(self, app, request): def open_session(self, app, request):
session_id = request.cookies.get(self.get_cookie_name(app)) session_id = request.cookies.get(self.get_cookie_name(app))
@@ -104,30 +135,54 @@ class SessionInterface(FlaskSessionInterface):
session_id = session_id.decode("utf-8", "strict") session_id = session_id.decode("utf-8", "strict")
data = session_storage[session_id] data = session_storage[session_id]
return self.make_session_object(session_id, data=data) if data is None:
# Never adopt an id chosen by the client (session fixation)
return self.make_session_object()
session = self.make_session_object(session_id, data=data)
if session.is_expired(time.monotonic()):
del session_storage[session_id]
return self.make_session_object()
return session
def get_cookie_attributes(self, app):
# Attributes for setting and deleting the cookie must match, because
# browsers ignore a deletion of a __Host- cookie without Secure and Path.
return dict(
domain=self.get_cookie_domain(app),
path=self.get_cookie_path(app),
secure=self.get_cookie_secure(app),
httponly=self.get_cookie_httponly(app),
samesite=self.get_cookie_samesite(app),
)
def save_session(self, app, session, response): def save_session(self, app, session, response):
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
cookie_name = self.get_cookie_name(app) cookie_name = self.get_cookie_name(app)
cookie_attributes = self.get_cookie_attributes(app)
if session.is_empty(): if session.is_empty():
if session.modified: if session.modified:
del session_storage[session.id] del session_storage[session.id]
response.delete_cookie(cookie_name, domain=domain, path=path) # Only send the deletion header if the client sent a cookie
if cookie_name in request.cookies:
response.delete_cookie(cookie_name, **cookie_attributes)
else: else:
httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session) expires = self.get_expiration_time(app, session)
session_storage[session.id] = dict(session) session.last_seen = time.monotonic()
if session.created_at is None:
session.created_at = session.last_seen
try:
session_storage[session.id] = {
**session,
CREATED_AT_KEY: session.created_at,
LAST_SEEN_KEY: session.last_seen,
}
except SessionWithoutUser:
# Flask saves the session again for the error response
session.clear()
raise
session_id = ( session_id = (
self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8") self._get_signer(app).sign(want_bytes(session.id)).decode("utf-8")
) )
response.set_cookie( response.set_cookie(
cookie_name, cookie_name, session_id, expires=expires, **cookie_attributes
session_id,
expires=expires,
httponly=httponly,
domain=domain,
path=path,
secure=secure,
) )
+1 -1
View File
@@ -1,3 +1,3 @@
{% for category, message in get_flashed_messages(with_categories=true) %} {% for category, message in get_flashed_messages(with_categories=true) %}
<div class="msg-{{ category }}">{{ t(message) }}</div> <div class="msg-{{ category }}">{{ message }}</div>
{% endfor %} {% endfor %}
+1 -1
View File
@@ -7,7 +7,7 @@
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('alias-create', user_id=user.id) }}" method="post"> <form id="form" class="pure-form pure-form-aligned" action="{{ url_for('alias-create', user_id=user.id) }}" method="post">
<div class="pure-control-group"> <div class="pure-control-group">
<label for="email">{{ t("Email") }}</label> <label for="email">{{ t("Email") }}</label>
<input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} placeholder="{{ t("Email") }}"> <input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} autocomplete="off" placeholder="{{ t("Email") }}">
<div class="domain-selector"> <div class="domain-selector">
{% if can_use_different_alias_domain %} {% if can_use_different_alias_domain %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% include "alerts.html" %} {% include "alerts.html" %}
<form action="{{ url_for('alias-delete', alias_id=alias.id, **request.args) }}" method="post"> <form action="{{ url_for('alias-delete', alias_id=alias.id, return=return_to) }}" method="post">
<button type="submit" class="pure-button button-error">{{ t("Delete") }}</button> <button type="submit" class="pure-button button-error">{{ t("Delete") }}</button>
<a href="{{ back_url }}" class="pure-button">{{ t("Cancel") }}</a> <a href="{{ back_url }}" class="pure-button">{{ t("Cancel") }}</a>
{% include "csrf.html" %} {% include "csrf.html" %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% include "alerts.html" %} {% include "alerts.html" %}
<form id="form" class="pure-form pure-form-stacked" action="{{ url_for('alias-edit-note', alias_id=alias.id, **request.args) }}" method="post"> <form id="form" class="pure-form pure-form-stacked" action="{{ url_for('alias-edit-note', alias_id=alias.id, return=return_to) }}" method="post">
<label for="note">{{ t("Note") }}</label> <label for="note">{{ t("Note") }}</label>
<textarea id="note" name="note" form="form">{{ alias.note }}</textarea> <textarea id="note" name="note" form="form">{{ alias.note }}</textarea>
+2 -5
View File
@@ -2,11 +2,8 @@
{% block content %} {% block content %}
<h1>{{ t("Login") }}</h1> <h1>{{ t("Login") }}</h1>
{% for category, message in get_flashed_messages(with_categories=true) %} {% if message %}
<div class="msg-{{ category }}">{{ t(message) }}</div> <div class="msg-{{ message_category }}">{{ message }}</div>
{% endfor %}
{% if alert %}
<div class="msg-error">{{ t("Please login to visit the requested url") }}</div>
{% endif %} {% endif %}
<form class="pure-form" action="/login" method="post"> <form class="pure-form" action="/login" method="post">
+10 -3
View File
@@ -4,15 +4,22 @@
{% include "alerts.html" %} {% include "alerts.html" %}
<form class="pure-form pure-form-aligned" action="{{ url_for('user-change-password', user_id=user.id, **request.args) }}" method="post"> <form class="pure-form pure-form-aligned" action="{{ url_for('user-change-password', user_id=user.id, return=return_to) }}" method="post">
{% if is_own_password %}
<div class="pure-control-group">
<label for="current_password">{{ t("Current password") }}</label>
<input id="current_password" type="password" name="current_password" autocomplete="current-password" placeholder="{{ t("Current password") }}">
</div>
{% endif %}
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password1">{{ t("Password") }}</label> <label for="password1">{{ t("Password") }}</label>
<input id="password1" type="password" name="password1" placeholder="{{ t("New password") }}"> <input id="password1" type="password" name="password1" autocomplete="new-password" placeholder="{{ t("New password") }}">
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password2">{{ t("Reenter Password") }}</label> <label for="password2">{{ t("Reenter Password") }}</label>
<input id="password2" type="password" name="password2" placeholder="{{ t("New password") }}"> <input id="password2" type="password" name="password2" autocomplete="new-password" placeholder="{{ t("New password") }}">
</div> </div>
<div class="pure-controls"> <div class="pure-controls">
+3 -3
View File
@@ -7,7 +7,7 @@
<form id="form" class="pure-form pure-form-aligned" action="{{ url_for('user-create') }}" method="post"> <form id="form" class="pure-form pure-form-aligned" action="{{ url_for('user-create') }}" method="post">
<div class="pure-control-group"> <div class="pure-control-group">
<label for="email">{{ t("Email") }}</label> <label for="email">{{ t("Email") }}</label>
<input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} placeholder="{{ t("Email") }}"> <input id="email" type="text" name="email" {% if email is defined %}value="{{ email }}"{% endif %} autocomplete="off" placeholder="{{ t("Email") }}">
<div class="domain-selector"> <div class="domain-selector">
@ @
@@ -19,12 +19,12 @@
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password1">{{ t("Password") }}</label> <label for="password1">{{ t("Password") }}</label>
<input id="password1" type="password" name="password1" {% if password is defined %}value="{{ password }}"{% endif %} placeholder="{{ t("Password") }}"> <input id="password1" type="password" name="password1" autocomplete="new-password" placeholder="{{ t("Password") }}">
</div> </div>
<div class="pure-control-group"> <div class="pure-control-group">
<label for="password2">{{ t("Reenter Password") }}</label> <label for="password2">{{ t("Reenter Password") }}</label>
<input id="password2" type="password" name="password2" {% if password is defined %}value="{{ password }}"{% endif %} placeholder="{{ t("Reenter Password") }}"> <input id="password2" type="password" name="password2" autocomplete="new-password" placeholder="{{ t("Reenter Password") }}">
</div> </div>
<div class="pure-controls checkbox"> <div class="pure-controls checkbox">
+1 -1
View File
@@ -4,7 +4,7 @@
{% include "alerts.html" %} {% include "alerts.html" %}
<form action="{{ url_for('user-delete', user_id=user.id, **request.args) }}" method="post"> <form action="{{ url_for('user-delete', user_id=user.id, return=return_to) }}" method="post">
<button type="submit" class="pure-button button-error">{{ t("Delete") }}</button> <button type="submit" class="pure-button button-error">{{ t("Delete") }}</button>
<a href="{{ back_url }}" class="pure-button">{{ t("Cancel") }}</a> <a href="{{ back_url }}" class="pure-button">{{ t("Cancel") }}</a>
{% include "csrf.html" %} {% include "csrf.html" %}
+1 -1
View File
@@ -4,7 +4,7 @@
{% include "alerts.html" %} {% include "alerts.html" %}
<form id="form" class="pure-form pure-form-stacked" action="{{ url_for('user-edit-note', user_id=user.id, **request.args) }}" method="post"> <form id="form" class="pure-form pure-form-stacked" action="{{ url_for('user-edit-note', user_id=user.id, return=return_to) }}" method="post">
<label for="note">{{ t("Note") }}</label> <label for="note">{{ t("Note") }}</label>
<textarea id="note" name="note" form="form">{{ user.note }}</textarea> <textarea id="note" name="note" form="form">{{ user.note }}</textarea>
+14 -6
View File
@@ -1,6 +1,12 @@
from functools import update_wrapper from functools import update_wrapper
from flask import current_app, redirect as flask_redirect, render_template, request from flask import (
current_app,
redirect as flask_redirect,
render_template,
request,
session,
)
from werkzeug.exceptions import MethodNotAllowed from werkzeug.exceptions import MethodNotAllowed
from max.exceptions import ImproperlyConfigured, PermissionDenied from max.exceptions import ImproperlyConfigured, PermissionDenied
@@ -72,11 +78,13 @@ class BaseView:
def check_access(self): def check_access(self):
if not self.can_access(): if not self.can_access():
print("Cannot access for ", self.__class__.__name__) self.app.logger.debug(
raise PermissionDenied( "Access denied for user %s to %s (%s)",
"Not found" session.get("user_id"),
) # we do not want to pass PermissionDenied directly to the user. self.request.path,
# TODO: Better message. self.__class__.__name__,
)
raise PermissionDenied()
def can_access(self): def can_access(self):
return False return False
+7
View File
@@ -0,0 +1,7 @@
# Development tools. Generated into requirements-dev.txt by `make lock-deps`.
-c requirements.txt
black
flake8
isort
pip-audit
pip-tools
+622 -3
View File
@@ -1,3 +1,622 @@
black #
isort # This file is autogenerated by pip-compile with Python 3.15
flake8 # by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --strip-extras requirements-dev.in
#
black==26.10.1 \
--hash=sha256:03c0ddd93bb392e71209903a691767eb366fe1a76deb9509ccbaae9e1f14bb52 \
--hash=sha256:0ce08b367307b0fd91c9dd1d4084e62b05b3055475f951f0f34a46b6e2393b64 \
--hash=sha256:182f6c32be38074b16d378498c498b32cb51928178ee611485344972c35ec9c6 \
--hash=sha256:1935b32f5326028019856e18cb42b4da63db23765dc84464cec723e0de478a9b \
--hash=sha256:19fa8f5beb5e77c54c9c7e21d00cc93ed6c8b6228ee385616906d6befe081143 \
--hash=sha256:2520037aa62f8a1454d0811b8f5c88b444445b03a4bfba480d8d220893b64c34 \
--hash=sha256:28842f9a8207cc1df6eb983a35a14c5a0dfcd603d214fe82d84bef552afd2e3a \
--hash=sha256:289282aa2e09d3162312a3be1788ff21b08e9ea9cc4a81e656024728b32428fb \
--hash=sha256:2ffbc023a12d0c729408823b8f10514490bd0baa301d0d4e21a7240249f9507f \
--hash=sha256:3414a0c52901964dceabd98c7c56beac0f964115a116ecedcce7247359b14017 \
--hash=sha256:4d9a90516db1d99c25dbb20cc0998e0e01531dd903466c7744e56d66f864220a \
--hash=sha256:51d5e417e700fe6ec0b0ecdc408c6f6cb5def80328f31f724993d82c6486b746 \
--hash=sha256:5cd88fd7b444ca51f3fc883b6f6657ea53a258b0b2eef6d9f2dfcfa17ce0e27b \
--hash=sha256:5f9f83beae62437e060dafd53d7f1fc327e3d3494f74d72ee5c2b73eb90fc4e7 \
--hash=sha256:70ccbd175b7f6be29d2b727ee7ca6b4c54053df59da653a6df80b175d20a94fa \
--hash=sha256:7bdade400bfe24d78a7762896acc2f9a8e1a17fb0fd0536bf6b7c7097cf3eec7 \
--hash=sha256:8375962579d537364cc0efa19b1474481915d3a793f9fc0774901814c5e5b5f4 \
--hash=sha256:978113a40223a6aaefc17364176a809a320e6b288683841427fff04c6d7b4130 \
--hash=sha256:9a0219b29cd70e49f920acb7081e6ce5025c719008447c521d0200dcad93206a \
--hash=sha256:b5347d760f0c02bb00dd249384cab71c3bf828b4f68d5b401eb116e0390f147d \
--hash=sha256:b6272cfd7e1e8e271f5b0e0207259fe2834687e5cb9b5f620b34a44db9754993 \
--hash=sha256:d42dd2fac7c342ae67e64ee99c9532e20b2a84e92c79ed3317fa2ef54c801d93 \
--hash=sha256:d5bd3518d8e97138fef295230b1e9804076d69fa4e3594071494a8c68abe6266 \
--hash=sha256:d8b3a9074a680b3c5749633714e9ae3992a1e5a23343a97ad61cd9b119b444d2 \
--hash=sha256:f6dba8138cdc99061ef07b958ac082d2aa057b6961d1936f9717c350f02bab5f \
--hash=sha256:fe85fc4019bee59bc495c0f2a8ee76c5cd02c7015508d94a967ba2376f39a52c \
--hash=sha256:ff57f63029aa1353fa8b1b0c8971fd88a6c92dc766608d2eee33ad2deb23270e
# via -r requirements-dev.in
boolean-py==5.0 \
--hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \
--hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9
# via license-expression
build==1.6.1 \
--hash=sha256:51cc11666391ab6f092070437ac747002ff46f3e4113a3622177ee6b488bfc53 \
--hash=sha256:ecd351a4be9d35a9eaaba244a7687143c9c7d4aea6ac964e7e7ddab20cbcf4e7
# via pip-tools
cachecontrol==0.14.4 \
--hash=sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b \
--hash=sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1
# via
# cachecontrol
# pip-audit
certifi==2026.10.19 \
--hash=sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae \
--hash=sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b
# via requests
charset-normalizer==3.5.2 \
--hash=sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e \
--hash=sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf \
--hash=sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5 \
--hash=sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56 \
--hash=sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26 \
--hash=sha256:0891b9d3903c5571c03771ca669a4b0ec5618ca722a5c957d3d29cd4e5062848 \
--hash=sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718 \
--hash=sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93 \
--hash=sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640 \
--hash=sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3 \
--hash=sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875 \
--hash=sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e \
--hash=sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275 \
--hash=sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204 \
--hash=sha256:15bb4005af6320d259dc7593ca84a38d7fe06a421dbcf7b910ae23979101e787 \
--hash=sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234 \
--hash=sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3 \
--hash=sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98 \
--hash=sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3 \
--hash=sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187 \
--hash=sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d \
--hash=sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f \
--hash=sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7 \
--hash=sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011 \
--hash=sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f \
--hash=sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869 \
--hash=sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1 \
--hash=sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d \
--hash=sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847 \
--hash=sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320 \
--hash=sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9 \
--hash=sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93 \
--hash=sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd \
--hash=sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00 \
--hash=sha256:2cc961b171b3f3440f410489ab3573e86aea8736134ebbb40ea1338b7f0831bc \
--hash=sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0 \
--hash=sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09 \
--hash=sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac \
--hash=sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621 \
--hash=sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c \
--hash=sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8 \
--hash=sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a \
--hash=sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51 \
--hash=sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0 \
--hash=sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef \
--hash=sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa \
--hash=sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6 \
--hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649 \
--hash=sha256:3ddacd27458c45bdacd6bd6db644bfb730efbf9e830310186e3045c9c5be8fb2 \
--hash=sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229 \
--hash=sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e \
--hash=sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd \
--hash=sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115 \
--hash=sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9 \
--hash=sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c \
--hash=sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c \
--hash=sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab \
--hash=sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253 \
--hash=sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995 \
--hash=sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438 \
--hash=sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0 \
--hash=sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be \
--hash=sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b \
--hash=sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7 \
--hash=sha256:55ea99acb17b9325618de155a0cd6a2e8f5d10be008113e1d433bbb58db543b2 \
--hash=sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a \
--hash=sha256:588461c2e8384d309bd63e5826019b6977bc66d629b99ac8737bb795d7b2cb5a \
--hash=sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a \
--hash=sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c \
--hash=sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5 \
--hash=sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37 \
--hash=sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e \
--hash=sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4 \
--hash=sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800 \
--hash=sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055 \
--hash=sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e \
--hash=sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5 \
--hash=sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c \
--hash=sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b \
--hash=sha256:68eb192d85ab8e5f6ec69c2bc6ac0179fbf04a5ac1569d12fbef74883fe102d0 \
--hash=sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80 \
--hash=sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a \
--hash=sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4 \
--hash=sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2 \
--hash=sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58 \
--hash=sha256:75a3ceed0724d625d64b86ca20aba182e4df462e04c2414fc941c0f523f06aac \
--hash=sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc \
--hash=sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639 \
--hash=sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf \
--hash=sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d \
--hash=sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f \
--hash=sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c \
--hash=sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc \
--hash=sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4 \
--hash=sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253 \
--hash=sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade \
--hash=sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858 \
--hash=sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26 \
--hash=sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96 \
--hash=sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8 \
--hash=sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249 \
--hash=sha256:8b2bfab86aa71ae13aa41a6a26aab338e0db2b8bc75434b05aea89e011ff35a4 \
--hash=sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13 \
--hash=sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1 \
--hash=sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03 \
--hash=sha256:93223adc95033dd47133a46ccfc316a0139176fd79085762e27202ec56018f03 \
--hash=sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e \
--hash=sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364 \
--hash=sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4 \
--hash=sha256:9bde855991b7e362c146535e3136a50bfaffc0487d38b33ca7e5edefc6e23849 \
--hash=sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0 \
--hash=sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a \
--hash=sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036 \
--hash=sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3 \
--hash=sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21 \
--hash=sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3 \
--hash=sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e \
--hash=sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413 \
--hash=sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21 \
--hash=sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346 \
--hash=sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429 \
--hash=sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685 \
--hash=sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45 \
--hash=sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f \
--hash=sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c \
--hash=sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d \
--hash=sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad \
--hash=sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400 \
--hash=sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb \
--hash=sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c \
--hash=sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc \
--hash=sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c \
--hash=sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74 \
--hash=sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf \
--hash=sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604 \
--hash=sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f \
--hash=sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105 \
--hash=sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a \
--hash=sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d \
--hash=sha256:d4a7319f304a774bed22115bc891618e45f85065ab44ea6acd07d274e750519a \
--hash=sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1 \
--hash=sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5 \
--hash=sha256:d913de495d90407cd859d263bee2e5d1a4ed3eb6573c04e70d9ec619a7cbed7f \
--hash=sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e \
--hash=sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709 \
--hash=sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874 \
--hash=sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5 \
--hash=sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc \
--hash=sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95 \
--hash=sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd \
--hash=sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0 \
--hash=sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d \
--hash=sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3 \
--hash=sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c \
--hash=sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3 \
--hash=sha256:e80e6c2f55656b4824d72065abb4ddd6a525c74bd78a0aab5d9fc2cf4fb5af50 \
--hash=sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491 \
--hash=sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5 \
--hash=sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5 \
--hash=sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655 \
--hash=sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288 \
--hash=sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd \
--hash=sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084 \
--hash=sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d \
--hash=sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4 \
--hash=sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915 \
--hash=sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1 \
--hash=sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd \
--hash=sha256:fc14a032f813bf5fe624d991960ea83e9715adc27e4c1830a2361eb1d02ac341 \
--hash=sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424 \
--hash=sha256:fd1fbe0f116b6e55da77aca2c6ddcddcfac2186cbf78bdebf40fc156efca389d \
--hash=sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f
# via requests
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via
# -c requirements.txt
# black
# pip-tools
cyclonedx-python-lib==11.12.0 \
--hash=sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54 \
--hash=sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf
# via pip-audit
defusedxml==0.7.1 \
--hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
--hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
# via py-serializable
filelock==4.2.4 \
--hash=sha256:301241438c57936ef93f4243a94f2559022268bfcf9b375a9a72eb0f8db8358e \
--hash=sha256:c6d8663535e1650ee430507b4ffbac7b464dd64b7647ea0112a9f406499d163d
# via cachecontrol
flake8==7.4.1 \
--hash=sha256:78480274a6d7289d9cb8eafeda241fac57d4ea687d26e32dfdca37b72cdeddad \
--hash=sha256:84ea5afcaf344487b0ea5baaebb8100f4cfaebc01f755998f75876664029f587
# via -r requirements-dev.in
idna==3.20 \
--hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \
--hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
# via requests
isort==9.0.2 \
--hash=sha256:11da67a30f5a88383c71db075488ca3d081f427f53368f90bb1d74e958a9b040 \
--hash=sha256:16436aefeebe3aa2d5d7ae1ca895b2278f770fc4a41d95c22569a30f7413ec45 \
--hash=sha256:1c134ef9d94943eae14bf31c634db1904dd875e6e7280a60baee10ca06132db6 \
--hash=sha256:288a320e6d52ba2d3447345390c8a8400591e4033ffbe4ce6bc3e50e5b4818e1 \
--hash=sha256:29669ea6c410528ffe3b632a41835757f08282257e4ddac892a5e6d01bd35201 \
--hash=sha256:2a960e4252ac5b00f78adc0f731529e122657ee642e650896b36e1ff83028023 \
--hash=sha256:3cd67d39c3501d7227e8b229476da1d8679c03e0af97bd295876cf7070e5b709 \
--hash=sha256:3fe693c1e56781de387a6c206306e9e5e560cfeb4acdfd85f0c46122afd48792 \
--hash=sha256:4315e23e701bb1fcdfd364da59da61d78c3332c554318b7eb635ea3924d24c5e \
--hash=sha256:5c929e8ec9d9fb83f034d5f50895503f40c624605f552b97ad090a37e62407ca \
--hash=sha256:5f448510ef0a92fa626a975759d76bdbe3b721c3d615da6d1010cc451de5610d \
--hash=sha256:67b12d9504e5bc6359bb3bb4493f36cf1093d15477c61c349f52f7d04209fb5d \
--hash=sha256:6c29deeb39698a8717823b7f75b2ac58c5e8ab8dcf6cf31205a72a6617fb454e \
--hash=sha256:6eb3e714d64de6eba78ee29051f7fc80613c74e90c6f54f84082f59c429c0a0b \
--hash=sha256:71870ac3b1afdf3c259b8404c05076d3ab874122fec6f78339f1c92d2c29b012 \
--hash=sha256:810561edf6f1f5f3600f02aa709603a4360d5290c5fff2ae4b370090dd1a5445 \
--hash=sha256:85e859fd72e50c27306d05185f9472ed97fae9e1cce91c0e891260d16f2ecece \
--hash=sha256:8dde4e2d9cfb35390437353f0861ec41378f91ff958d8cd3051fb95cae59315a \
--hash=sha256:91b60ce3d96fcb0730d61fc5ab84ee5b56d676fbb92550f7ea333f58778f2f20 \
--hash=sha256:a05dc63cb6ae2a8e62ec4184153f424b1650593e00a24e6138184c46193891e9 \
--hash=sha256:a36f30b6b85d9726f79c7623d35f3e966d5d7d9d0a005af91ba19988fccd038b \
--hash=sha256:aa810daf72ff5d8ade462b2190dad9c0e16d6d428a3f9aea210f14cca2487d58 \
--hash=sha256:af8be0b5cac101202c8255360e5de832ebbb84b2e863dc0f65dbb1a3d63dd40a \
--hash=sha256:b34a165cd4e25726930ed2eed8cf2fe46fb1a5ebacd9b28eaf566b343a6457ca \
--hash=sha256:b3e81cae981a52f94d5b31a474e1cbb033ea9cc850bc4c922117c0534a1864dd \
--hash=sha256:bd8c4fb9829a5e7117d9f71f540ff1e8caafb471e574012057ce6dc35fda2d7b \
--hash=sha256:bf3ef0a91974f29f406e25eef0e04781fd5c2254b8ab55e7655b20d8cd7c5514 \
--hash=sha256:cd1e0e5e61497e95a4e5be269088e6a1013f530aeccf6ebd6134f403285ecd63 \
--hash=sha256:d03c68e9d0a83b51ed381d04b0919f2d918fb66c1ca1766761157ff44149366f \
--hash=sha256:d2298980ce44350f11d9d24c8150eaef1883431ec203dddbb4e9b5c3ceb54c70 \
--hash=sha256:d4da51a99dfd00e5c51e507ed91ebad6aafd44dc65135c17e2ef37355cd9fa98 \
--hash=sha256:e2636222848a48cadbd712280058b5da19fa147c501132e04a486a5bddcc9e28 \
--hash=sha256:e4a54aed1bb731d7cf80ef5dfbae5b960f777cea70523b751ee6049bcb604371 \
--hash=sha256:e5f11c7ccd5f079ac0431fe52c7b38ea5d9f4e31a1889746de81dac0e7b0a766 \
--hash=sha256:f65ff614632ddc3306c40f619717b3b3ca69938ffee21d97110056d52472c79a \
--hash=sha256:f7a9efeb3689c7327a0d637eb4e12691e8d5ab1297caee997b144dc595ccb93f \
--hash=sha256:f7c2fa33e1c9fbcf9fd639997e4550515c0b712b52ed70a059124a5247825480
# via -r requirements-dev.in
license-expression==30.4.4 \
--hash=sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4 \
--hash=sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd
# via cyclonedx-python-lib
markdown-it-py==4.2.0 \
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
# via rich
mccabe==0.7.0 \
--hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \
--hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e
# via flake8
mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
msgpack==1.2.3 \
--hash=sha256:07c9733089d1b176c3dd2f7fa268452f9d5d784d076473499d754a58e8d1fbbb \
--hash=sha256:0955b9000725573d1457c1676944b370dd9643c8d18f25bda5ac72913f850949 \
--hash=sha256:0c91762c48cd686dc9cf2b142c0bc544083952de32f5853d6624c956e54b85e5 \
--hash=sha256:0ed5823c4efc20fe87d3530665f40ec18a002be003114814c21235cc8d256207 \
--hash=sha256:13221a6c81ebb8e43ea63a7251c35d54e4175cea37ebf3a62e911bdf42562a3c \
--hash=sha256:186e6c602b8a9968b8e864c67d622a69279f7d1e55ae25f40e3bff7e815b2b62 \
--hash=sha256:18a6ed513023001b28dcd3ba54966f6bb90a38274ba8d2640464bcab3a1b81d4 \
--hash=sha256:1d6bcec3dbbdb89ca385d3a73e63ceae7b841fa0d7ca7c676f1a7bfe7fb2cdb8 \
--hash=sha256:1f4ae8bd4ad9ba085fde95e95d055a896d19210238a4199a771a3cf36dceed49 \
--hash=sha256:1f585407f740a9eac04a3bb82c61d68a0ea78f90e29e670bfb086b9ce3a518dd \
--hash=sha256:21bfa4d2aa0b04c1806ef778a1199e9e53ea2441bcbf284420a32083896320b8 \
--hash=sha256:2487453ca1b6104442c6442f9a1a8fee1fe8f428a70d99d4cba799108b304150 \
--hash=sha256:2574ef81c1c8c38b10e330f3f9406fd09198a776b002030fafcf8e7647e9e06e \
--hash=sha256:30e1522e4173230dca4d9ad896f038f73c0da6c1edd42f4dbad88ac583cf5d46 \
--hash=sha256:32edb81a2b5eb7cd7c9d941b2bfbbb082fd2cd09e0e725930316af6b708db186 \
--hash=sha256:3372475211a9ce1a23acefe512cb3e121d18c95dc74ed56cb1819ef40836ebf4 \
--hash=sha256:382b219de3d436de3baba0f4b0c6d4336e8f5858d0eb047918b13b69a71c6c55 \
--hash=sha256:382bc88fe90f29f5ac8a0b65c7046ff255356f2f2f3186c30e370215736fa1dc \
--hash=sha256:39b6986c19e1f2dfa549d185dba6ccf1de2e4c0ba10d8cfc0048935b1c5f9109 \
--hash=sha256:3a31905206722103a84c1f72633fe30692cff6732c9d262e09a27dbc468797c8 \
--hash=sha256:3d4c807ed050fe3ddbea5ba7e9f63d7136871ce42861be1f50ff739f0e91047a \
--hash=sha256:3ec409b0d6aa8e9eec6eaf881b893caa215dbe68c5319ca96e8a271d81bb111d \
--hash=sha256:471e12a6a42498a31490c206e0069e343b6a7c35db540be73a879eb06f5be047 \
--hash=sha256:4c0780095871ecc49a58b2ff6b1b43b25214704da67646557ca287a3f49fb2dd \
--hash=sha256:59612b4ed48a04cf024584218e813562f3b30a3bafa5f55abe300b15da314751 \
--hash=sha256:5bd5f91ea75c45cafcc5433ba8fae59b708b736ec178d2441c40c499e9e079db \
--hash=sha256:5bf390259cb25a6a1cd197c65810999b811f64cd38683251538bcc5a1e41f7d3 \
--hash=sha256:5c1efdd9181cb1b719ee46865f368a927f1c0c65d577798340b1194545b7515a \
--hash=sha256:5e0d7950ca3c1bbae291d0552dd3bb2792fc680629c4c0d44e47e5bab969f3ca \
--hash=sha256:5f304123b90e8b2e49867981b7f6061612c39f50cca51ee88de007c084cf68d3 \
--hash=sha256:62cc1a4ef0e553bac32c8342e1f04834aca7de276b92744eb7307db77759b890 \
--hash=sha256:63bb7448a1e9111319ae2430c09a5596140c160422830d6271bc75730ff2ff9a \
--hash=sha256:6576f348ed6cc4f31db6fd915a8e94245f042f50eae08d48732425e70638ea37 \
--hash=sha256:666ef5601ab0e6e345e47febc96aa81143cc932201543480cbb9499164f05ffb \
--hash=sha256:6707d2fa2aa1bb5424ea0b05f44ffc989b15ab41a73ff5855bff4944fec7c8ac \
--hash=sha256:69ad12cedb674c73527bed869cddb42b742cac79a207a614202a4abaa24ea173 \
--hash=sha256:6a834097144aabe948b8ca9020a833e8026f7d0abbd0ec54bc7e50f45a8ce012 \
--hash=sha256:6df430419f2338cb71e4a34d6e64f83c88ccd321f91f40ba4513400b36d864ec \
--hash=sha256:700bc0fc9e968a292b9137ee70e7a012f7e115bf0107ce45e3a88202788dfc1e \
--hash=sha256:7013534a7163aa4f213c4d9864f1a8a7555daac6fcd48f699a198e29b436bfab \
--hash=sha256:7995a7c6a62a1d6e7df211b4a16de513bd99fd053525050a319f80f44fb8015e \
--hash=sha256:79dfa38faf92f804aa61beec140d70b18418e1dde1778dbb77a87a4cce85aa8a \
--hash=sha256:7a003b02c6ee2eea6dfe0bb08818631e3597e69f0131f2a8250488a1cc553290 \
--hash=sha256:7c047250096f9fc19dba26e3d1639b5e7a84114003605c94def667149a70ced1 \
--hash=sha256:84a6616d396ec1bc18a1e83e67c96a393ec35dfe5e17434a5be7b9aa0fe988ab \
--hash=sha256:87cf2ef05ff2f2493ba29fcdaef27e960ca64dacfd13460ae29e6f92e0ed05bb \
--hash=sha256:89c930aece4e972b208ba589c8410b4167b05e411a5ea2cb25fd96f8bc47ee43 \
--hash=sha256:8ca67f77938ea6a3663aa9bd22b3e031f6da84d665be850abab910ee90728dfd \
--hash=sha256:8e51eca14fbb65c4e0a5a9657346962bd3dca78c08e04e3d4dee70ef48687d30 \
--hash=sha256:8ec7a1d49ca6c2569d722ab5ec86e90089b0713900aa31905b47b4c4d9e78ce0 \
--hash=sha256:902f3490db0e07a7d40b48536a85c9b28fbf1397e7e1658a45a55f958e303620 \
--hash=sha256:905a189853d6bdb204c7ae5f4ab77fb857448abfff574d3d93c62e2815b24b4f \
--hash=sha256:9276ba88891338f2617044429dfd080ae008c9868a25f6f1a7d004a35dc9ac0a \
--hash=sha256:9324c54995641c3d1f92a9d55093c8cde0ffa2fbc87a467a688ef60428393220 \
--hash=sha256:968583e956d0427878050b371308c5f8647088732ef3e66a117dbe1192ec91e0 \
--hash=sha256:9d7e9cbb0998bbfd363fd9a09c330520d5e9cb323c05b5a1a05865d23ccf2226 \
--hash=sha256:a393e428f6ffb0dcb73308c1fff5593041c16ff42da66e5bac8a83a6107a54b0 \
--hash=sha256:a6b63917d60d6df451f328bd6afba8565e33c4afe1f62ec4ad758b78731c827b \
--hash=sha256:b1631e12fe572e181cd77e831f69335d6cd5278eac22e3db3f33cf264ac2ac18 \
--hash=sha256:b774ff994d844e541439ac5d2d49a14def4104830c3465e9394c153f86200ffb \
--hash=sha256:b949cc25e4a09252cbcc54e66e507de914d0e94a3a7039bd54c299bf7037c098 \
--hash=sha256:bb89b5dc30469c84bbf8684826eb851d82412ca95690e111b9ac5e8fb343961a \
--hash=sha256:bfe7d5b62cbe7aa664f0b3e2c49077f10fcdd06183d3014f8271ff3c5edbfbf9 \
--hash=sha256:c309a7abae1d14ba29a8bd0ddbd704a5e469d8e9bd9c3dee0e4ff53d7ae01d56 \
--hash=sha256:c77e27790ad72989db783d5303825fba0b71550f00a490efba35cde7dc4b719f \
--hash=sha256:c942c21a93f36b3a69e828c8945bb72c94dc2ffe488a2086950c812f3edf046c \
--hash=sha256:ccea05b5542f6d283fef3f0a8e93a7f0be90af0ddeeef84c25c0216ba76dcae1 \
--hash=sha256:cd5a9f9f86a52c24713679aa2631956835f3842512964ff93f736ff76f1f530d \
--hash=sha256:d0238cd05dec9ffbe0de1071df685ba63e30a36ac155285b1a094e727c38cbe9 \
--hash=sha256:d1c1e8989a855b7f1f2a64ec4a80b23a631822903952770813857b2e4f460471 \
--hash=sha256:d2f9c4f85e47a44d26d5baf3b041eef23436e224d44eed273f01bd8a12048d9f \
--hash=sha256:d31864ba3933a589b6a00249f89c0eb422197f49128fc10da550e57e9cb0f377 \
--hash=sha256:d8ef3a66e4b52d2d7fdd90df2984670124b2ff7546d76bb25dcf68ef47f7df58 \
--hash=sha256:db84203b13aecc222f465061397fdd5b53b7ae73d2c95ffc1c8dc5be0153a709 \
--hash=sha256:db9fb67a3a2e75247bae569d34ebb5ff61c0448a4f0d6dbf991dae68af39b007 \
--hash=sha256:e0bd394e999949c814f7912284243298de1b5a17b6a3dcb6cc8a79b156ffc4fa \
--hash=sha256:e15f70588f4db8cd10df0930145b186de70feb9db51710cd378b1399009655bd \
--hash=sha256:e54394b7dbe2e12ab032d9d21feef7bb61a90a150a2623633ba3781ba69dcb1f \
--hash=sha256:eaf7e82249837e3aa97297b34a0bb9ff562027381631e057cea6e1367f10b438 \
--hash=sha256:ec0030361cc861ac699b2ef1c695b741fa145c88f8667fa3d7e3f73deeb648a3 \
--hash=sha256:ec90a9ae3e1169fa1171147340f0e97d941aa19fcd3b34e8339a55933ed042af \
--hash=sha256:ed899d73a22f286a72bd9528d63f2ab3030dbad8bf1527fc249319a50d61fb9d \
--hash=sha256:ede33b2892ceb976283e009ad12fa1834cfdf1f9c43ee9c97849fc588d00a618 \
--hash=sha256:f24a43b3560e20f825b807fe1e874bd73d53abaf8bbdcf258a6eb152cddbc1f5 \
--hash=sha256:f3d7b3d0018746b5997dd6b14a1870b07cc4c327d9101145d94a1fc264a51a06 \
--hash=sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e \
--hash=sha256:f42f146752eedb6765f07dcc04d72dab0a25779ec8d4a88c0085263ce114f22c \
--hash=sha256:f56fba61b2516be7917cb00151f0d060b5b21184e3499bb57f0f7d9259bea124 \
--hash=sha256:f9ddd28d3e9bbc602a9dced1591882c7fb9ab776eef8837da2c326fde19e2853 \
--hash=sha256:fafc3b8898b432b841d30a61082c599fa7f4d06885f9dc58ad72259e12059fa6 \
--hash=sha256:fcc6800daac4922960f6eeb7a0dda3dd4105e0bf7bce0e83ebc465a78cb7bdba
# via cachecontrol
mypy-extensions==1.1.0 \
--hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \
--hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558
# via
# black
# isort
packageurl-python==0.17.6 \
--hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \
--hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9
# via cyclonedx-python-lib
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
--hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
# via
# black
# build
# pip-audit
# pip-requirements-parser
# wheel
pathspec==1.1.1 \
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
# via black
pip-api==0.0.35 \
--hash=sha256:14dea05d7b3b68f4c7582213e3ae8932bbba8e1f754bf06b95263d5fa6ab4363 \
--hash=sha256:66990d0da25378555cbb538db79384bab92d4617c6350dfd7a2126ba2a3a9894
# via pip-audit
pip-audit==2.10.1 \
--hash=sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc \
--hash=sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a
# via -r requirements-dev.in
pip-requirements-parser==32.0.1 \
--hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \
--hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3
# via pip-audit
pip-tools==7.6.2 \
--hash=sha256:04dbe0e25f3b60fbdeee17d0590dac7fe20039876aa410a946fc54dc926c09ba \
--hash=sha256:f37eb4b736859c2c30230e50977312c61918b7ef931071ff7811fd067715c25f
# via -r requirements-dev.in
platformdirs==4.13.5 \
--hash=sha256:05831adf87d392760f4b5c3a0a95579ab5a10b18b9b9bd43680dcb47544788a0 \
--hash=sha256:53d3ec4280b815c1e0b02edad7f9ee8fda1ba9c0865dd9c2e6e63849ce82fc1e
# via
# black
# pip-audit
py-serializable==2.1.0 \
--hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \
--hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304
# via cyclonedx-python-lib
pycodestyle==2.15.0 \
--hash=sha256:12fd2f73c7b8ee8845a0431111df8faf4c1a07d6e64e2ee7f0c74014dab14181 \
--hash=sha256:318f5db083869b4c4dad922d0b11124fb27ab181b6730b93371da671e31bd50e
# via flake8
pyflakes==4.0.3 \
--hash=sha256:330ba92b8c1db2eb0b8f4068f6c58674e2649a99e334769aa50e3e9c5b11c23a \
--hash=sha256:94762a3a5a343a79b28754f96c554bce057a592a4896907d73f0369fe824e053
# via flake8
pygments==2.21.0 \
--hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \
--hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c
# via rich
pyparsing==3.3.3 \
--hash=sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36 \
--hash=sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4
# via pip-requirements-parser
pyproject-hooks==1.3.3 \
--hash=sha256:5fc53fdac9f7bd63fbcdc868fb5f90b4784d78a53a3d3388cd738b807441a20b \
--hash=sha256:defda19b854fa0d3bd4f76ea4ddcba8abd7dcfcdd585a6690ade050744fc5f43
# via
# build
# pip-tools
pytokens==0.4.1 \
--hash=sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1 \
--hash=sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009 \
--hash=sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083 \
--hash=sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1 \
--hash=sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de \
--hash=sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2 \
--hash=sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a \
--hash=sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1 \
--hash=sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5 \
--hash=sha256:30f51edd9bb7f85c748979384165601d028b84f7bd13fe14d3e065304093916a \
--hash=sha256:34bcc734bd2f2d5fe3b34e7b3c0116bfb2397f2d9666139988e7a3eb5f7400e3 \
--hash=sha256:3ad72b851e781478366288743198101e5eb34a414f1d5627cdd585ca3b25f1db \
--hash=sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68 \
--hash=sha256:42f144f3aafa5d92bad964d471a581651e28b24434d184871bd02e3a0d956037 \
--hash=sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321 \
--hash=sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc \
--hash=sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7 \
--hash=sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f \
--hash=sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918 \
--hash=sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9 \
--hash=sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c \
--hash=sha256:682fa37ff4d8e95f7df6fe6fe6a431e8ed8e788023c6bcc0f0880a12eab80ad1 \
--hash=sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1 \
--hash=sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3 \
--hash=sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b \
--hash=sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb \
--hash=sha256:941d4343bf27b605e9213b26bfa1c4bf197c9c599a9627eb7305b0defcfe40c1 \
--hash=sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a \
--hash=sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4 \
--hash=sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa \
--hash=sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78 \
--hash=sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe \
--hash=sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9 \
--hash=sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d \
--hash=sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975 \
--hash=sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440 \
--hash=sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16 \
--hash=sha256:da5baeaf7116dced9c6bb76dc31ba04a2dc3695f3d9f74741d7910122b456edc \
--hash=sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d \
--hash=sha256:dcafc12c30dbaf1e2af0490978352e0c4041a7cde31f4f81435c2a5e8b9cabb6 \
--hash=sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6 \
--hash=sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324
# via black
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
# cachecontrol
# pip-audit
rich==15.0.0 \
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
# via pip-audit
sortedcontainers==2.4.0 \
--hash=sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88 \
--hash=sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0
# via cyclonedx-python-lib
tomli==2.5.0 \
--hash=sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea \
--hash=sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd \
--hash=sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0 \
--hash=sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391 \
--hash=sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df \
--hash=sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9 \
--hash=sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066 \
--hash=sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f \
--hash=sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57 \
--hash=sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6 \
--hash=sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b \
--hash=sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3 \
--hash=sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043 \
--hash=sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01 \
--hash=sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646 \
--hash=sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859 \
--hash=sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b \
--hash=sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e \
--hash=sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc \
--hash=sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5 \
--hash=sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0 \
--hash=sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb \
--hash=sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84 \
--hash=sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6 \
--hash=sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b \
--hash=sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b \
--hash=sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52 \
--hash=sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd \
--hash=sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75 \
--hash=sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1 \
--hash=sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b \
--hash=sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142 \
--hash=sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03 \
--hash=sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea \
--hash=sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885 \
--hash=sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374 \
--hash=sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3 \
--hash=sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276 \
--hash=sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b \
--hash=sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc \
--hash=sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68 \
--hash=sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a \
--hash=sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f \
--hash=sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b \
--hash=sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7 \
--hash=sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0 \
--hash=sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb \
--hash=sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7 \
--hash=sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545 \
--hash=sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8 \
--hash=sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980 \
--hash=sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7 \
--hash=sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105 \
--hash=sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5 \
--hash=sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56 \
--hash=sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d \
--hash=sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2 \
--hash=sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4 \
--hash=sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7 \
--hash=sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef \
--hash=sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1 \
--hash=sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571 \
--hash=sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a \
--hash=sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442 \
--hash=sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc
# via pip-audit
tomli-w==1.2.0 \
--hash=sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90 \
--hash=sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021
# via pip-audit
urllib3==2.8.0 \
--hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3 \
--hash=sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63
# via requests
wheel==0.48.0 \
--hash=sha256:3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab \
--hash=sha256:94800765601e9171bf5d58d066e640662842bcedcbab982b2c90787a2c987322
# via pip-tools
# The following packages are considered to be unsafe in a requirements file:
pip==26.2.2 \
--hash=sha256:3a8e5c800492787f33a4114ef54444546c340df428974ebba17e0fd47e11af2f \
--hash=sha256:fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70
# via
# pip-api
# pip-tools
setuptools==84.2.1 \
--hash=sha256:3ff8b3b5492a0aa66960f4419365a86b84e013070d4313e4d4a6894f5d64d6bc \
--hash=sha256:ca140b45d5325a9478f880e351484f62fef15f336462e449ca0c03c873d589c2
# via pip-tools
+5
View File
@@ -0,0 +1,5 @@
# Direct dependencies of MAX. Pinned versions with hashes are in requirements.txt,
# generated by `make lock-deps` (see README).
Flask>=3.1,<4
gunicorn>=26,<27
psycopg2>=2.9,<3
+199 -3
View File
@@ -1,3 +1,199 @@
Flask==3.1.3 #
psycopg2==2.9.13 # This file is autogenerated by pip-compile with Python 3.15
gunicorn==26.2.0 # by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --strip-extras requirements.in
#
blinker==1.9.0 \
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
# via flask
click==8.5.0 \
--hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \
--hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34
# via flask
flask==3.1.3 \
--hash=sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb \
--hash=sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c
# via -r requirements.in
gunicorn==26.2.0 \
--hash=sha256:62b864895d9ebff0b2f9867ba04fe811c93121596540830c9c916d0769668447 \
--hash=sha256:bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3
# via -r requirements.in
itsdangerous==2.2.0 \
--hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \
--hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173
# via flask
jinja2==3.1.6 \
--hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
--hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
# via flask
markupsafe==3.0.4 \
--hash=sha256:007e1ffd9bf65bb6ee96df7b258fc632a4868dd5566037986c64781f35a36e98 \
--hash=sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002 \
--hash=sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b \
--hash=sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653 \
--hash=sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c \
--hash=sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e \
--hash=sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc \
--hash=sha256:0764a13d34cae40db7bbf3a09b7e9b491bf4603e20b263a7a9d6b8e324975d0a \
--hash=sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92 \
--hash=sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f \
--hash=sha256:0cee7cb0f9a1b6892ea482237d9403b3d1b4603aee057d0ff01f0fac2d019a97 \
--hash=sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4 \
--hash=sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7 \
--hash=sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691 \
--hash=sha256:14bd2d845d62ab678eaf81da89d7b621b51756c72346745c1a594c09d49207a2 \
--hash=sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc \
--hash=sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde \
--hash=sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99 \
--hash=sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9 \
--hash=sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df \
--hash=sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5 \
--hash=sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17 \
--hash=sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8 \
--hash=sha256:2a6ef68ae94aed8721934072b27a3b654ea2100b97e4ab864cf1489c90926fbc \
--hash=sha256:2b2b1e18af909b448bb3cf9e3433366f7a8726271fc214e8b10e0f62a78c724b \
--hash=sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea \
--hash=sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248 \
--hash=sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741 \
--hash=sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5 \
--hash=sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6 \
--hash=sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7 \
--hash=sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1 \
--hash=sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67 \
--hash=sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f \
--hash=sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9 \
--hash=sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c \
--hash=sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc \
--hash=sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba \
--hash=sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17 \
--hash=sha256:3d23795802fc8bd72534836d64489bbf0f67c088959091bdb22e10735a5107bf \
--hash=sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6 \
--hash=sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2 \
--hash=sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163 \
--hash=sha256:4a540e2d3192792fc84eced57bef37851ccb2b41f73291bb17408eea77bcd278 \
--hash=sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d \
--hash=sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b \
--hash=sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634 \
--hash=sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38 \
--hash=sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed \
--hash=sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c \
--hash=sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148 \
--hash=sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a \
--hash=sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7 \
--hash=sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f \
--hash=sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811 \
--hash=sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e \
--hash=sha256:57f9947a7e57a081c1e3e0a2dd0d2dcf290a4531450e6f611e30084c222a7295 \
--hash=sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2 \
--hash=sha256:5c22873ad1f0532ba40fa1727f3c0fc1bbbaab6d373d4cbe3f0dc74b2e2521c7 \
--hash=sha256:5e8b3d0b18fd623afa12ecb2ce8d8becef69f9b5440c6330c7972200e0bb84b0 \
--hash=sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6 \
--hash=sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed \
--hash=sha256:6669c1bf34080161ce49c589cc512ef24d4c704ac9d2b2d3667f519c60418378 \
--hash=sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0 \
--hash=sha256:6768d67d1bce64270e0fdc2e69309d68b9b18ae56ddf6c711d168e9d051c2cac \
--hash=sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b \
--hash=sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96 \
--hash=sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59 \
--hash=sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808 \
--hash=sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2 \
--hash=sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb \
--hash=sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65 \
--hash=sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72 \
--hash=sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8 \
--hash=sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e \
--hash=sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91 \
--hash=sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a \
--hash=sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2 \
--hash=sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e \
--hash=sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707 \
--hash=sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21 \
--hash=sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef \
--hash=sha256:8698d70a8081ee8c090dbb394768b5789a1da8b131b5499f89d071dd3cfaf6be \
--hash=sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453 \
--hash=sha256:88d59b473bfb03259722600839af9bbd7fa13a2eb514beefeedb95997882f69a \
--hash=sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6 \
--hash=sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977 \
--hash=sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978 \
--hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581 \
--hash=sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692 \
--hash=sha256:9240187afb63d2f9ddc3e032c670356fe941f6e20662ea168a5dc3f1f317e1b3 \
--hash=sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369 \
--hash=sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a \
--hash=sha256:9388003072b95f2f1e3fd908604194d653ba21330d811961a78b7da1a77e9e36 \
--hash=sha256:9438a2648b2195980cb2dd8e53ed7b8df91319e2d0b70ae61a9e1d1bc8d3bec9 \
--hash=sha256:94e4c421742086aeee4c32a506eec8859d7634aad943f7e6aacf70f813478768 \
--hash=sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916 \
--hash=sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b \
--hash=sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f \
--hash=sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346 \
--hash=sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c \
--hash=sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464 \
--hash=sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9 \
--hash=sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee \
--hash=sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300 \
--hash=sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6 \
--hash=sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d \
--hash=sha256:ac0c7c9f1609b0c4c114feb1d7a3409564c7fb77e360bed9e97e5d25dfeaf868 \
--hash=sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46 \
--hash=sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97 \
--hash=sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733 \
--hash=sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe \
--hash=sha256:b61687d0828e72bf5cda24a2690188f37170bd31c9359ac97e4e66569f120a16 \
--hash=sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429 \
--hash=sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39 \
--hash=sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894 \
--hash=sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c \
--hash=sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c \
--hash=sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169 \
--hash=sha256:bf053da3c97a4bc5ecfbb218cdd2983febd91c617be8367d139882aa11e490aa \
--hash=sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77 \
--hash=sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe \
--hash=sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad \
--hash=sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85 \
--hash=sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e \
--hash=sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34 \
--hash=sha256:cf63c214fe879a65e69a386f915e36104fc84254ab141240f8854602d8e0be2a \
--hash=sha256:d1aca03ede943eb80ab3d63bb082c84b7aab85ea83bd0fd0c200260945fb49d9 \
--hash=sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c \
--hash=sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749 \
--hash=sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214 \
--hash=sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932 \
--hash=sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494 \
--hash=sha256:dd8ea6ebee7aedbf7c749fa80521d9ccf1ba473e0d1e14805caafbaad281c889 \
--hash=sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1 \
--hash=sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0 \
--hash=sha256:dff05cb7016dff1e9fd68f4122c127b65dfc59de5306cfb7ad92f956f230bee2 \
--hash=sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786 \
--hash=sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78 \
--hash=sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e \
--hash=sha256:e841068dc0be4cb6dfb5c890eb88cbdcff2f4a332393c7ec94e8e618bd32c1a8 \
--hash=sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289 \
--hash=sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c \
--hash=sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe \
--hash=sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237 \
--hash=sha256:f291bcf42ae98eb5107edb162c3c998b4a89648fd8e99ed4cbd12705292788cd \
--hash=sha256:f61efe1d2fe0de16158a5fe1d1cf3c14bdb6aecd54d8938fd26512c525c1f624 \
--hash=sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19 \
--hash=sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977 \
--hash=sha256:fd9f8797427910198f95bced71ddfed61130d7e349213bfb8466c9c99e2c46a8 \
--hash=sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3
# via
# flask
# jinja2
# werkzeug
psycopg2==2.9.13 \
--hash=sha256:0d2fc7eedfaca0586dcf1476454598428d0d8471d3b5cb55f92015a5f9d0af40 \
--hash=sha256:10f7408b34412e8c0d4f8b1565541f1d651b1d00447857e5d8561b38f5c1a738 \
--hash=sha256:165e25c1b0e616a1f28080c5c68bd2dc015051d83c90240b2171d3e76ca2b5ff \
--hash=sha256:7d48416f6a4823ada9b33771085331b842b553df88435701bff5ddb4469905de \
--hash=sha256:a6f54fd8e0024f35240866b5dfff9ead2a0dbd33b8096eec438bc6093412842d \
--hash=sha256:d16e7a5f5e400ac51ca953d42255804eff6c8a9650b1a2074f6ca6261d740382 \
--hash=sha256:d36784fc2dae69523ba4b79c7d1d1b4d6e83e87836874f111262f4db940b16a6
# via -r requirements.in
werkzeug==3.1.9 \
--hash=sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060 \
--hash=sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab
# via flask