Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Coverage.py configuration for the unit test suite.
# Measures only first-party code: third-party venvs, the native standalone
# builds, screenshots and the test tree itself are excluded so the percentage
# reflects the application, not its dependencies.
[run]
branch = True
source = .
omit =
test/*
.venv/*
.venv-windows/*
native-build/*
screenshot/*
dist/*
query/*
*/__pycache__/*

[report]
show_missing = True
skip_covered = False
precision = 1
exclude_also =
if __name__ == .__main__.:
raise NotImplementedError
if TYPE_CHECKING:
34 changes: 34 additions & 0 deletions .github/workflows/lint-mypy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Type Check (mypy)

# Static type-checking scoped (via mypy.ini) to small, self-contained core
# modules so the gate passes today and can be widened as modules gain hints.
# Independent of the test/build workflows.

on:
pull_request:
branches: [ main ]
push:
branches: [ main ]

permissions:
contents: read

jobs:
mypy:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install mypy
run: |
python -m pip install --upgrade pip
pip install mypy

- name: Run mypy (scoped to core modules via mypy.ini)
run: mypy --config-file mypy.ini
28 changes: 28 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,31 @@ jobs:
requirements/cpu.txt
requirements/gpu.txt
ignore-vulns: ${{ steps.cve_allowlist.outputs.ids }}

# Image / supply-chain layer: Trivy scans the container DEFINITION (Dockerfile
# + deployment manifests) for misconfigurations and the working tree for leaked
# secrets -- coverage pip-audit (Python CVEs) and Bandit (Python SAST) do not
# provide. Report-only (exit-code 0): findings appear in the run log/summary but
# do NOT block merge, matching the rest of this workflow. A full built-image OS
# scan can later be hooked into the image-publish pipeline.
trivy:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4

- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin

- name: Trivy config scan (Dockerfiles + deployment manifests)
run: |
trivy config --severity HIGH,CRITICAL --exit-code 0 .

- name: Trivy filesystem scan (dependency CVEs + leaked secrets)
run: |
trivy fs --scanners vuln,secret --severity HIGH,CRITICAL --exit-code 0 \
--skip-dirs .venv,.venv-windows,native-build,test/songs,model,dist .
29 changes: 27 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@ jobs:
python -m pip install --upgrade pip
pip install -r test/requirements.txt

- name: Run unit tests
- name: Run unit tests with coverage
run: |
pytest test/unit/ -v --tb=short
pytest test/unit/ -v --tb=short \
--cov --cov-report=term-missing --cov-report=xml

- name: Coverage summary
if: always()
run: |
python - <<'PY'
import xml.etree.ElementTree as ET, os
try:
rate = float(ET.parse("coverage.xml").getroot().get("line-rate", 0)) * 100
except Exception:
rate = 0.0
summary = os.environ.get("GITHUB_STEP_SUMMARY")
line = f"## Unit test coverage: {rate:.1f}% of lines\n"
if summary:
open(summary, "a").write(line)
print(line)
PY

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage.xml
retention-days: 7
7 changes: 7 additions & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@
from error.error_manager import AudioMuseError
from error.error_dictionary import UNKNOWN_ERROR_CODE

# Shared rate limiter.
from rate_limit import limiter

# NOTE: Annoy Manager import is moved to be local where used to prevent circular imports.

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -109,6 +112,10 @@ def inject_globals():
# Register the auth barrier + auth routes (/login, /auth, /logout, /api/users).
init_auth(app, setup_manager, _get_jwt_secret)

# Bind the shared rate limiter to the app; the limited endpoints are declared
# with limiter.limit() inside app_auth.init_app above.
limiter.init_app(app)

@app.before_request
def log_api_request():
if request.path.startswith('/api/') and not request.path.startswith('/static/'):
Expand Down
20 changes: 16 additions & 4 deletions app_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@

from tz_helper import UTC_NOW_SQL, to_local_str

import config
from rate_limit import limiter

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -1034,10 +1037,19 @@ def init_app(app, setup_manager, jwt_secret_getter):
_jwt_secret_getter = jwt_secret_getter

app.before_request(auth_setup_barrier)

# Rate-limit the brute-forceable surfaces: login and user administration.
# Limits are applied here (not as module decorators) so unit tests that
# register routes without calling init_app stay unthrottled.
auth_view = limiter.limit(config.RATE_LIMIT_AUTH)(auth_endpoint)
create_user_view = limiter.limit(config.RATE_LIMIT_USER_ADMIN)(create_user_endpoint)
delete_user_view = limiter.limit(config.RATE_LIMIT_USER_ADMIN)(delete_user_endpoint)
update_password_view = limiter.limit(config.RATE_LIMIT_USER_ADMIN)(update_user_password_endpoint)
Comment thread
NeptuneHub marked this conversation as resolved.
Outdated

app.add_url_rule('/login', endpoint='login_page', view_func=login_page, methods=['GET'])
app.add_url_rule('/auth', endpoint='auth_endpoint', view_func=auth_endpoint, methods=['POST'])
app.add_url_rule('/auth', endpoint='auth_endpoint', view_func=auth_view, methods=['POST'])
app.add_url_rule('/logout', endpoint='logout_endpoint', view_func=logout_endpoint, methods=['POST'])
app.add_url_rule('/api/users', endpoint='list_users_endpoint', view_func=list_users_endpoint, methods=['GET'])
app.add_url_rule('/api/users', endpoint='create_user_endpoint', view_func=create_user_endpoint, methods=['POST'])
app.add_url_rule('/api/users/<int:user_id>', endpoint='delete_user_endpoint', view_func=delete_user_endpoint, methods=['DELETE'])
app.add_url_rule('/api/users/<int:user_id>/password', endpoint='update_user_password_endpoint', view_func=update_user_password_endpoint, methods=['PUT'])
app.add_url_rule('/api/users', endpoint='create_user_endpoint', view_func=create_user_view, methods=['POST'])
app.add_url_rule('/api/users/<int:user_id>', endpoint='delete_user_endpoint', view_func=delete_user_view, methods=['DELETE'])
app.add_url_rule('/api/users/<int:user_id>/password', endpoint='update_user_password_endpoint', view_func=update_password_view, methods=['PUT'])
14 changes: 14 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,20 @@ def _compute_headers():
# Default is True to preserve the current secure behavior.
AUTH_ENABLED = os.environ.get("AUTH_ENABLED", "True").lower() == "true"

# --- Rate Limiting (flask-limiter) ---
# Protects auth and user-administration endpoints from brute force. Only the
# endpoints decorated in app_auth.init_app are limited; everything else is
# unthrottled (rate_limit.py sets no default limits), so high-frequency polling
# like /api/status is never affected.
RATE_LIMIT_ENABLED = os.environ.get("RATE_LIMIT_ENABLED", "True").lower() == "true"
# Shared counter store. Defaults to the Redis the app already requires so limits
# hold across gunicorn workers; falls back to in-process memory if unreachable.
RATE_LIMIT_STORAGE_URI = os.environ.get("RATE_LIMIT_STORAGE_URI", REDIS_URL)
# Login (POST /auth) limit. flask-limiter syntax; ';' separates windows.
RATE_LIMIT_AUTH = os.environ.get("RATE_LIMIT_AUTH", "10 per minute;100 per hour")
# User create / delete / password-change limit.
RATE_LIMIT_USER_ADMIN = os.environ.get("RATE_LIMIT_USER_ADMIN", "30 per minute")

def _apply_db_overrides():
global HEADERS, refresh_config
try:
Expand Down
9 changes: 8 additions & 1 deletion database.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
# adds no depth to the eager import graph, so there is no need to duplicate it.
from tz_helper import UTC_NOW_SQL

# Versioned, ordered schema migrations applied on top of init_db()'s base schema.
from db_migrations import run_schema_migrations

# Shared input sanitizer (leaf module) for cleaning string columns before writes.
from sanitization import sanitize_db_field

Expand Down Expand Up @@ -1028,7 +1031,11 @@ def init_db():
""", (query, 1.0, rank))

logger.info(f"Inserted {len(default_queries)} default DCLAP search queries")


# Apply versioned, ordered migrations on top of the base schema, while
# still holding the advisory lock so concurrent workers serialize here.
run_schema_migrations(cur)

db.commit()
# Release the advisory lock acquired at the top of init_db().
finally:
Expand Down
74 changes: 74 additions & 0 deletions db_migrations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Versioned, ordered schema migrations.
#
# The base schema is created idempotently by database.init_db(). This module
# layers run-once, ordered migrations on top and records applied versions in the
# schema_version table, giving a real upgrade path between releases. To add a
# migration, append (version, name, fn) to MIGRATIONS with the next integer;
# never edit or renumber a migration that has already shipped.

import logging

logger = logging.getLogger(__name__)

# Everything created by init_db() is recorded as the baseline.
BASELINE_VERSION = 0


def _ensure_version_table(cur):
cur.execute(
"CREATE TABLE IF NOT EXISTS schema_version ("
"version INTEGER PRIMARY KEY, "
"name TEXT NOT NULL, "
"applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
)


def _applied_versions(cur):
cur.execute("SELECT version FROM schema_version")
return {row[0] for row in cur.fetchall()}


# Ordered migrations. Each fn receives a live DB cursor and applies one change.
# Example:
# def _m1_add_score_bpm(cur):
# cur.execute("ALTER TABLE score ADD COLUMN IF NOT EXISTS bpm REAL")
# MIGRATIONS = [(1, "add score.bpm", _m1_add_score_bpm)]
MIGRATIONS: list = []


def run_schema_migrations(cur, migrations=None):
# Apply every not-yet-applied migration in version order; returns the count
# newly applied. Idempotent: a migration already in schema_version is skipped.
migrations = MIGRATIONS if migrations is None else migrations
_ensure_version_table(cur)
done = _applied_versions(cur)

if BASELINE_VERSION not in done:
cur.execute(
"INSERT INTO schema_version (version, name) VALUES (%s, %s) "
"ON CONFLICT (version) DO NOTHING",
(BASELINE_VERSION, "baseline"),
)
done.add(BASELINE_VERSION)

applied = 0
for version, name, fn in sorted(migrations, key=lambda m: m[0]):
if version in done:
continue
logger.info("Applying schema migration %d: %s", version, name)
fn(cur)
cur.execute(
"INSERT INTO schema_version (version, name) VALUES (%s, %s) "
"ON CONFLICT (version) DO NOTHING",
(version, name),
)
done.add(version)
applied += 1
return applied


def get_schema_version(cur):
# Highest applied version, or -1 if the table is empty / absent.
_ensure_version_table(cur)
cur.execute("SELECT COALESCE(MAX(version), -1) FROM schema_version")
return cur.fetchone()[0]
28 changes: 28 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Static type-checking, scoped to small, self-contained core modules so the
# check is a real gate that passes today. Widen `files` as more modules gain
# type hints. The bulk of the codebase is untyped, hence the lenient defaults
# (no disallow_untyped_defs) -- this catches concrete type errors, not missing
# annotations.
[mypy]
python_version = 3.11
ignore_missing_imports = True
# Errors are reported only for the modules listed in `files`; imported-but-not
# scoped modules are followed for type info but their own errors are suppressed.
follow_imports = silent
warn_redundant_casts = True
no_implicit_optional = True
warn_unused_ignores = False
show_error_codes = True
files =
config.py,
db_migrations.py,
sanitization.py,
ssrf_guard.py,
tz_helper.py,
proxy_prefix.py,
rate_limit.py,
error/

# Tests are not type-checked.
[mypy-test.*]
ignore_errors = True
50 changes: 50 additions & 0 deletions rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Shared flask-limiter instance.
#
# Defined in its own module (importing nothing from app.py / app_auth.py) so
# both the app factory and the auth routes can attach limits without a circular
# import. Enforcement only happens once limiter.init_app(app) runs in app.py;
# unit tests that build a bare Flask app and never call it are unaffected.
#
# flask-limiter is treated as optional: if it is not installed (e.g. a slim
# build variant), rate limiting degrades to a no-op rather than crashing the
# app at import time.

import logging

import config

logger = logging.getLogger(__name__)


class _NoopLimiter:
# Stand-in used when flask-limiter is unavailable. limit() returns an
# identity decorator and init_app() does nothing, so callers are unchanged.
def limit(self, *args, **kwargs):
def decorator(func):
return func
return decorator

def init_app(self, app):

Check warning on line 27 in rate_limit.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "app".

See more on https://sonarcloud.io/project/issues?id=NeptuneHub_AudioMuse-AI&issues=AZ7wLll2pYSNW_lXYdjT&open=AZ7wLll2pYSNW_lXYdjT&pullRequest=682
return None


try:
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

# default_limits is empty: only explicitly decorated endpoints (login, user
# administration) are limited, so normal high-frequency polling is never
# throttled. storage falls back to in-process memory when Redis is
# unreachable; swallow_errors keeps a storage hiccup from 500-ing a request
# (it fails open rather than locking users out).
limiter = Limiter(
key_func=get_remote_address,
default_limits=[],
storage_uri=config.RATE_LIMIT_STORAGE_URI or "memory://",
strategy="fixed-window",
swallow_errors=True,
enabled=config.RATE_LIMIT_ENABLED,
)
except ImportError:
logger.warning("flask-limiter not installed; API rate limiting is disabled.")
limiter = _NoopLimiter() # type: ignore[assignment]
4 changes: 3 additions & 1 deletion requirements/common.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ ftfy==6.3.1
flasgger==0.9.7.1
sqlglot==30.6.0
google-genai==1.57.0
mistralai>=1.11.1,<2.0.0
mistralai==1.12.4
umap-learn==0.5.12
av==13.1.0
psutil==7.2.2
Expand All @@ -39,3 +39,5 @@ argon2-cffi==25.1.0
gunicorn==25.3.0
zstandard==0.25.0
wn==0.13.0
# Rate limiting for login / user-admin endpoints (brute-force protection).
flask-limiter==3.12
Loading
Loading