nene2-python 1.8.53__tar.gz → 1.8.55__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nene2_python-1.8.53 → nene2_python-1.8.55}/PKG-INFO +1 -1
- nene2_python-1.8.55/docs/field-trials/2026-05-field-trial-183.md +511 -0
- nene2_python-1.8.55/docs/field-trials/2026-05-field-trial-184.md +427 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/INDEX.md +6 -4
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/todo/current.md +11 -6
- {nene2_python-1.8.53 → nene2_python-1.8.55}/pyproject.toml +1 -1
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.env.example +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.github/workflows/ci.yml +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.github/workflows/docs.yml +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.github/workflows/publish.yml +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.gitignore +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.vitepress/config.mts +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.vitepress/theme/custom.css +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/.vitepress/theme/index.ts +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/AGENTS.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/CHANGELOG.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/CLAUDE.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/Dockerfile +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/LICENSE +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/README.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/alembic/README +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/alembic/env.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/alembic/script.py.mako +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/alembic/versions/001_create_notes_and_tags_tables.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/alembic.ini +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/compose.yaml +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0001-toolchain.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0002-clean-architecture.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0003-security-first.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0004-ai-first-design.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0005-logging.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0006-rate-limiting.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0009-mcp-design.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0010-async-use-case.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/adr/0011-mcp-as-core-dependency.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/de/index.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/de/tutorials/getting-started.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/explanation/architecture.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/explanation/design-philosophy.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-1.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-10.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-100.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-101.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-102.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-103.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-104.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-105.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-106.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-107.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-108.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-109.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-11.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-110.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-111.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-112.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-113.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-114.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-115.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-116.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-117.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-118.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-119.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-12.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-120.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-121.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-122.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-123.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-124.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-125.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-126.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-127.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-128.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-129.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-13.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-130.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-131.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-132.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-133.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-134.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-135.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-136.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-137.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-138.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-139.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-14.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-140.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-141.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-142.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-143.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-144.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-145.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-146.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-147.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-148.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-149.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-15.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-150.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-151.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-152.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-153.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-154.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-155.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-156.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-157.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-158.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-159.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-16.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-160.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-161.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-162.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-163.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-164.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-165.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-166.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-167.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-168.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-169.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-17.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-170.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-171.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-172.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-173.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-174.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-175.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-176.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-177.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-178.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-179.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-18.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-180.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-181.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-182.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-19.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-2.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-20.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-21.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-22.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-23.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-24.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-25.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-26.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-27.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-28.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-29.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-3.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-30.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-31.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-32.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-33.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-34.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-35.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-36.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-37.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-38.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-39.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-4.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-40.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-41.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-42.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-43.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-44.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-45.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-46.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-47.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-48.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-49.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-5.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-50.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-51.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-52.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-53.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-54.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-55.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-56.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-57.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-58.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-59.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-6.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-60.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-61.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-62.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-63.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-64.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-65.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-66.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-67.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-68.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-69.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-7.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-70.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-71.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-72.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-73.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-74.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-75.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-76.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-77.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-78.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-79.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-8.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-80.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-81.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-82.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-83.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-84.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-85.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-86.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-87.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-88.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-89.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-9.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-90.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-91.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-92.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-93.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-94.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-95.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-96.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-97.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-98.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/field-trials/2026-05-field-trial-99.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/fr/index.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/fr/tutorials/getting-started.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/add-new-domain.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/api-versioning.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/async-use-case.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/background-tasks.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/configure-auth.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/cors.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/custom-auth-middleware.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/dependency-injection.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/domain-events.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/file-upload.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/lifespan-and-app-state.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/middleware-stack.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/new-project.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/problem-details.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/response-patterns.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/run-tests.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/soft-delete.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/streaming.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/structured-logging.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/validation.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/how-to/webhook.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/howto/mcp-setup.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/index.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/explanation/architecture.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/explanation/design-philosophy.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/how-to/add-new-domain.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/how-to/configure-auth.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/how-to/new-project.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/how-to/run-tests.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/how-to/sqlalchemy-repository.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/howto/mcp-setup.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/index.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/reference/api.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/reference/configuration.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/reference/framework-modules.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/tutorials/first-domain.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/ja/tutorials/getting-started.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/pt-br/index.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/pt-br/tutorials/getting-started.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/reference/api.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/reference/configuration.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/reference/framework-modules.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/roadmap.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/templates/field-trial-report.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/tutorials/first-domain.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/tutorials/getting-started.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/zh/index.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/docs/zh/tutorials/getting-started.md +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/package-lock.json +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/package.json +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/__main__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/app.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/entity.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/handler.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/sqlalchemy_repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/comment/use_case.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/mcp.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/async_use_case.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/entity.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/handler.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/sqlalchemy_repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/note/use_case.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/schema.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/entity.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/handler.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/sqlalchemy_repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/example/tag/use_case.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/api_key.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/bearer_token.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/deps.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/interfaces.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/auth/local_verifier.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/cache/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/cache/ttl.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/config/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/config/settings.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/database/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/database/exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/database/health.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/database/interfaces.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/database/sqlalchemy_executor.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/database/utils.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/http/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/http/etag.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/http/health.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/http/pagination.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/http/problem_details.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/log/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/log/setup.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/mcp/http_client.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/mcp/server.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/domain_exception.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/error_handler.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/request_id.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/request_logging.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/request_size_limit.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/security_headers.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/setup.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/middleware/throttle.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/py.typed +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/security/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/security/webhook.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/use_case/protocols.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/validation/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/nene2/validation/exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/scripts/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/src/scripts/export_openapi.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/conftest.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/comment/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/comment/test_comment_http.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/comment/test_comment_repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/comment/test_comment_use_case.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/conftest.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/note/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/note/test_async_note_use_case.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/note/test_list_notes.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/note/test_note_repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/tag/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/tag/test_tag_repository.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/tag/test_tags.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/test_cors.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/example/test_mcp.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/auth/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/auth/test_api_key.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/auth/test_bearer_token.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/auth/test_make_require_auth.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/auth/test_token_issuer.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/cache/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/cache/test_ttl.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/config/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/config/test_settings.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/database/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/database/test_transaction.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/database/test_utils.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/http/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/http/test_etag.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/http/test_health.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/http/test_pagination.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/http/test_problem_details.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/log/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/log/test_setup.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/mcp/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/mcp/test_http_client.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/mcp/test_server.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_error_handler.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_request_id.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_request_logging.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_request_size_limit.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_security_headers.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_setup_middlewares.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_simple_domain_handler.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/middleware/test_throttle.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/security/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/security/test_webhook.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/use_case/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/use_case/test_protocols.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/use_case/test_run_in_threadpool.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/validation/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/nene2/validation/test_exceptions.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/scripts/__init__.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/tests/scripts/test_export_openapi.py +0 -0
- {nene2_python-1.8.53 → nene2_python-1.8.55}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nene2-python
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.55
|
|
4
4
|
Summary: NENE2 Python — minimal API framework following NENE2's design philosophy
|
|
5
5
|
Project-URL: Homepage, https://github.com/hideyukiMORI/nene2-python
|
|
6
6
|
Project-URL: Repository, https://github.com/hideyukiMORI/nene2-python
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
# FT183: smtplib モジュール
|
|
2
|
+
|
|
3
|
+
**日付**: 2026-05-21
|
|
4
|
+
**テーマ**: SMTP 送信・STARTTLS・ヘッダーインジェクション防御・モック戦略
|
|
5
|
+
**セキュリティ診断**: **あり**(183 % 3 = 0)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 概要
|
|
10
|
+
|
|
11
|
+
Python 標準ライブラリの `smtplib` モジュールを検証する。
|
|
12
|
+
FT182(email モジュール)でメッセージ構築を実装したのに続き、
|
|
13
|
+
今回は SMTP プロトコルでの実際の送信、STARTTLS/SMTP_SSL による暗号化、
|
|
14
|
+
ヘッダーインジェクション防御、サーバー機能確認(EHLO)を実装する。
|
|
15
|
+
実際のSMTPサーバーへの接続を必要としない `unittest.mock.patch` を使ったテスト戦略も確立する。
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 実装したサンプルアプリ
|
|
20
|
+
|
|
21
|
+
**場所**: `/home/xi/docker/nene2-python-FT/ft183-smtplib/`
|
|
22
|
+
|
|
23
|
+
### 主要機能
|
|
24
|
+
|
|
25
|
+
| 関数/クラス | 概要 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `validate_email_address(address)` | メールアドレス基本バリデーション |
|
|
28
|
+
| `sanitize_header(value)` | CR/LF 除去でヘッダーインジェクション防御 |
|
|
29
|
+
| `is_safe_header(value)` | ヘッダー値が安全か確認(bool) |
|
|
30
|
+
| `build_message(payload)` | `EmailPayload` → `EmailMessage`(ヘッダーサニタイズ済み) |
|
|
31
|
+
| `send_email(config, payload)` | STARTTLS で SMTP 送信(`SendResult` 返却) |
|
|
32
|
+
| `send_email_ssl(config, payload)` | SMTP_SSL(ポート 465)で送信 |
|
|
33
|
+
| `check_server(host, port, timeout)` | EHLO で SMTP サーバー機能一覧を取得 |
|
|
34
|
+
| `dry_run_send(payload)` | ネットワーク接続なしの検証・構築(テスト・ドキュメント用) |
|
|
35
|
+
| `SmtpConfig` | `@dataclass(frozen=True, slots=True)` — SMTP 接続設定 |
|
|
36
|
+
| `EmailPayload` | `@dataclass(frozen=True, slots=True)` — 送信データ |
|
|
37
|
+
| `SendResult` | `@dataclass(frozen=True, slots=True)` — 送信結果・拒否アドレス |
|
|
38
|
+
| `ServerInfo` | `@dataclass(frozen=True, slots=True)` — EHLO 応答 |
|
|
39
|
+
|
|
40
|
+
### HTTP エンドポイント
|
|
41
|
+
|
|
42
|
+
| メソッド | パス | 概要 |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| POST | `/dry-run` | 送信しないでメッセージ検証・構築 |
|
|
45
|
+
| POST | `/sanitize-header` | CR/LF を除去してヘッダーインジェクション防御 |
|
|
46
|
+
| POST | `/validate-address` | メールアドレスのフォーマット検証 |
|
|
47
|
+
| POST | `/send` | STARTTLS で SMTP 送信 |
|
|
48
|
+
| POST | `/check-server` | SMTP サーバーの EHLO 機能一覧を取得 |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## テスト結果
|
|
53
|
+
|
|
54
|
+
**47 passed**(初回 46 通過 → テスト修正後 47 全通過)
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
47 passed in 0.34s
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
mypy: Success / ruff: All checks passed / pip-audit: PYSEC-2025-183(継続監視)
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 摩擦ポイント
|
|
65
|
+
|
|
66
|
+
### F-1: `unittest.mock.patch("smtplib.SMTP")` はコンテキストマネージャの戻り値を手動設定する必要がある(深刻度: 低)
|
|
67
|
+
|
|
68
|
+
**事象**: `with patch("smtplib.SMTP", return_value=mock_smtp)` で `smtplib.SMTP()` を
|
|
69
|
+
モックしたが、`with smtplib.SMTP(...) as smtp:` の `smtp` が `mock_smtp` ではなく
|
|
70
|
+
`mock_smtp.__enter__.return_value` になる。
|
|
71
|
+
`smtp.sendmail()` が呼ばれず `AttributeError` が発生した。
|
|
72
|
+
|
|
73
|
+
**原因**: `MagicMock()` はデフォルトで `__enter__` / `__exit__` を持つが、
|
|
74
|
+
`__enter__.return_value` が自動的に `mock_smtp` 自身を返すわけではない。
|
|
75
|
+
コンテキストマネージャの `as` 変数は `__enter__()` の戻り値になるため、
|
|
76
|
+
`mock.__enter__ = MagicMock(return_value=mock)` と明示的に設定する必要がある。
|
|
77
|
+
|
|
78
|
+
**対応**:
|
|
79
|
+
```python
|
|
80
|
+
def _make_mock_smtp() -> MagicMock:
|
|
81
|
+
mock = MagicMock()
|
|
82
|
+
mock.__enter__ = MagicMock(return_value=mock) # ← 自身を返す
|
|
83
|
+
mock.__exit__ = MagicMock(return_value=False)
|
|
84
|
+
mock.sendmail = MagicMock(return_value={})
|
|
85
|
+
mock.ehlo_resp = b"mail.example.com"
|
|
86
|
+
mock.esmtp_features = {"starttls": "", "auth": "PLAIN LOGIN"}
|
|
87
|
+
return mock
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 観察点
|
|
93
|
+
|
|
94
|
+
### 観察1: smtplib の例外階層は詳細なエラー分類を提供する
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
smtplib.SMTPException # 基底クラス
|
|
98
|
+
├── SMTPServerDisconnected # 接続が切れた
|
|
99
|
+
├── SMTPResponseException # サーバーが非2xx を返した
|
|
100
|
+
│ ├── SMTPSenderRefused # MAIL FROM 拒否
|
|
101
|
+
│ ├── SMTPRecipientsRefused # RCPT TO 全拒否
|
|
102
|
+
│ ├── SMTPDataError # DATA フェーズのエラー
|
|
103
|
+
│ └── SMTPAuthenticationError # 認証失敗
|
|
104
|
+
└── SMTPConnectError # 接続失敗
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`SMTPRecipientsRefused` は `sendmail()` が一部の受信者を拒否したときに投げられるが、
|
|
108
|
+
一部が成功・一部が失敗の場合は例外ではなく `sendmail()` の戻り値(dict)として返る。
|
|
109
|
+
部分成功の検出には返り値チェックが必要。
|
|
110
|
+
|
|
111
|
+
### 観察2: STARTTLS は `use_starttls=True` 時のみ有効にすること
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# 危険: TLS なしで平文送信
|
|
115
|
+
smtp = smtplib.SMTP(host, port)
|
|
116
|
+
smtp.login(username, password) # 認証情報が平文で送信される
|
|
117
|
+
|
|
118
|
+
# 安全: STARTTLS で暗号化してから認証
|
|
119
|
+
smtp = smtplib.SMTP(host, port)
|
|
120
|
+
smtp.ehlo()
|
|
121
|
+
context = ssl.create_default_context()
|
|
122
|
+
smtp.starttls(context=context) # ← TLS アップグレード
|
|
123
|
+
smtp.ehlo() # ← TLS 後に再度 EHLO
|
|
124
|
+
smtp.login(username, password) # ← 暗号化された認証
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`starttls()` に `ssl.create_default_context()` を渡すことで証明書検証が有効になる。
|
|
128
|
+
デフォルト引数(`context=None`)は SSL コンテキストなしで接続するため非推奨。
|
|
129
|
+
|
|
130
|
+
### 観察3: SMTP_SSL は `ssl.create_default_context()` で証明書検証が必須
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
# 危険: 証明書検証なし(MITM 攻撃に脆弱)
|
|
134
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
135
|
+
context.check_hostname = False
|
|
136
|
+
context.verify_mode = ssl.CERT_NONE
|
|
137
|
+
|
|
138
|
+
# 安全: デフォルトコンテキスト(証明書検証あり)
|
|
139
|
+
context = ssl.create_default_context()
|
|
140
|
+
with smtplib.SMTP_SSL(host, port, context=context) as smtp:
|
|
141
|
+
...
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 観察4: `smtp.sendmail()` の戻り値は拒否アドレスの dict
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
refused = smtp.sendmail(from_addr, to_addrs, msg_bytes)
|
|
148
|
+
# refused = {} → 全員に送信成功
|
|
149
|
+
# refused = {"user@example.com": (550, b"User unknown")} → 一部拒否
|
|
150
|
+
# SMTPRecipientsRefused 例外 → 全員拒否
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`sendmail()` の第2引数(recipients)は文字列でも list でも受け付けるが、
|
|
154
|
+
戻り値の dict のキーは送信に使った宛先文字列そのもの。
|
|
155
|
+
Pydantic で受け取った `list[str]` をそのまま渡すことで型が一致する。
|
|
156
|
+
|
|
157
|
+
### 観察5: SMTP タイムアウトの設定は接続時のみ
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
smtp = smtplib.SMTP(host, port, timeout=30.0)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`timeout` は TCP 接続のタイムアウト。
|
|
164
|
+
`ehlo()` / `starttls()` / `sendmail()` などの各コマンドにも適用される。
|
|
165
|
+
`timeout=None` はブロッキングになるため、本番コードでは必ず設定すること。
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## nene2-python フレームワークとの統合
|
|
170
|
+
|
|
171
|
+
- `SmtpConfig` は Pydantic `SecretStr` で `password` フィールドをラップするべき — 現状は `str`
|
|
172
|
+
- `send_email()` は UseCase の `execute()` メソッド内に閉じ込め、HTTP ハンドラーから直接呼ばない
|
|
173
|
+
- `dry_run_send()` は UseCase のユニットテストでメール構築ロジックを検証するのに直接使える
|
|
174
|
+
- SMTP ゲートウェイは `EmailGatewayInterface` として抽象化し、テストでは `InMemoryEmailGateway` を注入
|
|
175
|
+
- 送信ログには宛先・件名のみ記録し、本文・認証情報は含めない
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Developer Experience (DX) Review
|
|
180
|
+
|
|
181
|
+
### ペルソナ1: 初心者(Python 歴1年・独学中・女性・バックエンド志望)
|
|
182
|
+
|
|
183
|
+
お問い合わせフォームからメールを送信する機能を実装しようとしている。
|
|
184
|
+
|
|
185
|
+
**ドキュメント理解**: Python Docs の smtplib の例は非常にシンプルで `starttls()` を省いた
|
|
186
|
+
危険なサンプルが多い。「STARTTLS を使うと安全」という説明はあるが、
|
|
187
|
+
「使わないと何が問題か」を説明するサンプルはドキュメントにない。
|
|
188
|
+
**事故リスク**: 高。`smtp.login(username, password)` を STARTTLS より前に呼ぶと
|
|
189
|
+
認証情報が平文でネットワークに流れる。初心者がこの順番を間違えてもエラーは出ない。
|
|
190
|
+
**規約の使いやすさ**: `with smtplib.SMTP(...) as smtp:` の context manager パターンは
|
|
191
|
+
明確で理解しやすい。例外の種類が多くて戸惑うが、
|
|
192
|
+
`SMTPException` を catch しておけば最低限動く。
|
|
193
|
+
|
|
194
|
+
### ペルソナ2: ロースキル経験者(Python 歴3-4年・スクリプト系・男性・SES)
|
|
195
|
+
|
|
196
|
+
既存のスクリプトを「API 化」しようとしている。
|
|
197
|
+
古いブログ記事のコードをそのまま FastAPI に移植する。
|
|
198
|
+
|
|
199
|
+
**コピペ可能性**: 古い Stack Overflow の回答には `ssl.PROTOCOL_TLS` や
|
|
200
|
+
`ssl.PROTOCOL_SSLv23` が使われていることが多く、
|
|
201
|
+
Python 3.12 では非推奨警告が出る。`ssl.create_default_context()` を使う記事は比較的新しい。
|
|
202
|
+
**拡張時の罠**: `smtp.ehlo()` を忘れると一部のサーバーで `SMTPException` が出る。
|
|
203
|
+
「前のコードで動いていたのに」という状態になりやすい。
|
|
204
|
+
**セキュリティ的な事故リスク**: 高。`timeout` の設定忘れでサーバーが応答しない場合に
|
|
205
|
+
スレッドがブロックされる DoS につながる。
|
|
206
|
+
|
|
207
|
+
### ペルソナ3: フロントエンド寄り経験者(React/TS 歴4年・バックエンド転向中・ノンバイナリ)
|
|
208
|
+
|
|
209
|
+
「送信ボタン」→ FastAPI → SMTP の連携を実装している。
|
|
210
|
+
sendgrid や AWS SES を使ったことがある。
|
|
211
|
+
|
|
212
|
+
**エラーレスポンスの質**: `SendResult.refused` に拒否された受信者と理由コードを含める設計は
|
|
213
|
+
クライアント側での「どのアドレスが使えないか」の判定に使える。
|
|
214
|
+
ただし SMTP のエラーコード(550 など)は HTTP ステータスコードとは別概念で、
|
|
215
|
+
フロントエンド側での解釈には説明が必要。
|
|
216
|
+
**Python 固有概念の学習コスト**: SendGrid SDK の `Mail` オブジェクトに慣れていると、
|
|
217
|
+
`EmailMessage` + `smtplib.SMTP` の2段構えが冗長に見える。
|
|
218
|
+
「構築と送信が分離している設計」の価値を説明する必要がある。
|
|
219
|
+
**事故リスク**: 低。HTTP 境界での Pydantic バリデーションが充実。
|
|
220
|
+
|
|
221
|
+
### ペルソナ4: バックエンド経験者(Django/FastAPI 歴5-6年・男性・リードエンジニア)
|
|
222
|
+
|
|
223
|
+
Django の `send_mail()` を FastAPI に移植しようとしている。
|
|
224
|
+
|
|
225
|
+
**他フレームワークとの差異**: Django は `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USE_TLS` を
|
|
226
|
+
`settings.py` で設定し `send_mail()` の1行で送れる。
|
|
227
|
+
nene2-python では `SmtpConfig` + `send_email()` の2段構えで「設定と送信の分離」を明示する。
|
|
228
|
+
環境変数からの設定注入は自前で実装する必要がある(`AppSettings` パターンが適用可能)。
|
|
229
|
+
**nene2-python の薄さへの評価**: `EmailGatewayInterface` の抽象化パターンは
|
|
230
|
+
`smtplib` のモックを排除しながらビジネスロジックのテストを可能にする。
|
|
231
|
+
Django の `EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'` と
|
|
232
|
+
同等のものが実装できる。
|
|
233
|
+
**本番投入可能性**: 証明書検証あり TLS・タイムアウト設定・ヘッダーインジェクション防御は
|
|
234
|
+
本番品質。認証情報の `SecretStr` 化は本番前の必須対応。
|
|
235
|
+
|
|
236
|
+
### ペルソナ5: シニアエンジニア(設計・コードレビュー担当・女性・10-12年)
|
|
237
|
+
|
|
238
|
+
チームのメンバーが書いたメール送信 PR をレビューしている。
|
|
239
|
+
|
|
240
|
+
**コードレビューチェックポイント**:
|
|
241
|
+
- [x] `starttls()` が `login()` より前に呼ばれているか
|
|
242
|
+
- [x] `ssl.create_default_context()` が使われているか(`verify_mode=CERT_NONE` でないか)
|
|
243
|
+
- [x] `timeout` が設定されているか(デフォルト `None` = ブロッキング)
|
|
244
|
+
- [x] Subject/From/To に `sanitize_header()` が適用されているか
|
|
245
|
+
- [x] パスワードが str として引数に渡されていないか(`SecretStr` 推奨)
|
|
246
|
+
|
|
247
|
+
**チームでの安全なパターン**: `SmtpConfig` の `password` フィールドを
|
|
248
|
+
`SecretStr` に変更し、ログに平文が出ないことを保証する。
|
|
249
|
+
**ツール追加の必要性**: `bandit B321`(smtplib 使用の警告)は誤検知が多いため
|
|
250
|
+
プロジェクト設定で抑制可能。`ssl.SSLContext.verify_mode == ssl.CERT_NONE` の検出は
|
|
251
|
+
`bandit B501/B502` でカバー可能。
|
|
252
|
+
|
|
253
|
+
### ペルソナ6: 設計者・ポリシー照合(nene2-python 設計ポリシー目線)
|
|
254
|
+
|
|
255
|
+
**ポリシー達成度**: 中
|
|
256
|
+
**「初心者でも安全な API」達成度**: 中
|
|
257
|
+
— STARTTLS 順序ミスが「エラーなく動くが危険」な状態を作る。
|
|
258
|
+
`SmtpConfig.use_starttls=True` をデフォルトにしているが、
|
|
259
|
+
False に変更すると認証情報が平文で送信されることをドキュメント化する必要がある。
|
|
260
|
+
**設計上の負債**: `SmtpConfig.password` が `str` のままで `SecretStr` でない。
|
|
261
|
+
ログ出力時に平文が残る可能性がある。
|
|
262
|
+
**Follow-up Issue 候補**: SmtpConfig の password を SecretStr にする
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## セキュリティ診断(FT183 % 3 = 0)
|
|
267
|
+
|
|
268
|
+
> **診断方針**: Django・FastAPI・SQLAlchemy 本体でも CVE が報告されてきたレベルの
|
|
269
|
+
> 攻撃ベクターを対象とする。「動いているから安全」は不正解。
|
|
270
|
+
> 実装ミスが起きやすい箇所を意図的に探し、問題がなければその理由まで記録する。
|
|
271
|
+
|
|
272
|
+
### 1. OWASP API Security Top 10 (2023)
|
|
273
|
+
|
|
274
|
+
#### API1: オブジェクトレベルの認可不備 (BOLA / IDOR)
|
|
275
|
+
- 認証保護なし — FT183 は SMTP クライアントライブラリのデモで、ユーザーオブジェクトを持たない
|
|
276
|
+
- **結果**: 該当なし(認可チェックが必要なリソースが存在しない)
|
|
277
|
+
|
|
278
|
+
#### API2: 認証の破損 (Broken Authentication)
|
|
279
|
+
- `/send` エンドポイントは誰でも呼べる(認証不要)
|
|
280
|
+
- 本番用途では Bearer Token 認証が必要だが FT のスコープ外
|
|
281
|
+
- **結果**: ⚠️ /send は本番で保護が必要(FT スコープでは許容)
|
|
282
|
+
|
|
283
|
+
#### API3: オブジェクトプロパティレベルの認可不備 (Mass Assignment)
|
|
284
|
+
- Pydantic モデルに定義されていないフィールドは自動無視(Pydantic v2 のデフォルト)
|
|
285
|
+
- `{"is_admin": true}` を POST しても無視される
|
|
286
|
+
- **結果**: ✅ 問題なし
|
|
287
|
+
|
|
288
|
+
#### API4: 無制限リソース消費 (Unrestricted Resource Consumption)
|
|
289
|
+
- `body: str = Field(max_length=100_000)` — 本文は 100KB に制限
|
|
290
|
+
- `to_addresses: list[str] = Field(max_length=100)` — 宛先は 100 件に制限
|
|
291
|
+
- `timeout: float = Field(ge=1.0, le=60.0)` — タイムアウトは 1〜60 秒に制限
|
|
292
|
+
- **結果**: ✅ 問題なし
|
|
293
|
+
|
|
294
|
+
#### API5: 機能レベルの認可不備 (Broken Function Level Authorization)
|
|
295
|
+
- `/send` は外部の SMTP サーバーに接続できるため、SSRF に近いリスクがある
|
|
296
|
+
- **結果**: ⚠️ /send は内部ネットワークの SMTP サーバーへの接続に使われる可能性がある(→F-2)
|
|
297
|
+
|
|
298
|
+
#### API6: サーバーサイドリクエストフォージェリ (SSRF)
|
|
299
|
+
- `/send` の `smtp_host` にユーザーが `127.0.0.1`・`169.254.169.254` を指定できる
|
|
300
|
+
- `check_server` の `host` も同様
|
|
301
|
+
- 内部 SMTP サービスへのプローブが可能
|
|
302
|
+
- **結果**: ⚠️ SSRF リスクあり(F-2 として記録)
|
|
303
|
+
|
|
304
|
+
#### API7: セキュリティの設定ミス
|
|
305
|
+
- CORS は FastAPI のデフォルト(制限なし)— FT スコープでは許容
|
|
306
|
+
- デバッグモードのスタックトレース露出なし
|
|
307
|
+
- **結果**: ⚠️ CORS 設定は本番前に対応が必要
|
|
308
|
+
|
|
309
|
+
#### API8 〜 API10
|
|
310
|
+
- 古いバージョンのエンドポイントなし・デバッグエンドポイントなし
|
|
311
|
+
- **結果**: ✅ 問題なし
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
### 2. インジェクション攻撃
|
|
316
|
+
|
|
317
|
+
#### SMTP ヘッダーインジェクション
|
|
318
|
+
- **攻撃**: Subject に `"Test\r\nBcc: attacker@evil.com"` を送信
|
|
319
|
+
- **実装**: `sanitize_header()` が `\r\n` を除去 → `"TestBcc: attacker@evil.com"` に変換
|
|
320
|
+
- **テスト**: `test_header_injection_payloads_sanitized` でパラメトライズドテストを実装
|
|
321
|
+
- **結果**: ✅ 全攻撃ペイロード耐性あり
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# 攻撃
|
|
325
|
+
{"subject": "Test\r\nBcc: attacker@evil.com"}
|
|
326
|
+
# sanitize_header() 後
|
|
327
|
+
Subject: TestBcc: attacker@evil.com # ← 単一行のまま(注入失敗)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
#### コマンドインジェクション
|
|
331
|
+
- `subprocess`・`os.system` は使用していない
|
|
332
|
+
- **結果**: ✅ 問題なし
|
|
333
|
+
|
|
334
|
+
#### パストラバーサル
|
|
335
|
+
- ファイルシステムアクセスなし
|
|
336
|
+
- **結果**: ✅ 該当なし
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
### 3. 認証・認可
|
|
341
|
+
|
|
342
|
+
- `SmtpConfig.password` は `str` 型 — `SecretStr` でないためログに平文が出る可能性
|
|
343
|
+
- タイミング攻撃: SMTP 認証は `smtplib` に委ねているため実装依存
|
|
344
|
+
- **結果**: ⚠️ パスワードの `SecretStr` 化が必要(F-3 として記録)
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
### 4. 入力バリデーション
|
|
349
|
+
|
|
350
|
+
```python
|
|
351
|
+
# テスト入力
|
|
352
|
+
Subject: "A" * 999 # max_length=998 → Pydantic が 422 を返す ✅
|
|
353
|
+
smtp_host: "" * 0 # 空文字 → check_server で None を返す ✅
|
|
354
|
+
smtp_port: 0 # ge=1 → Pydantic が 422 を返す ✅
|
|
355
|
+
smtp_port: 65536 # le=65535 → Pydantic が 422 を返す ✅
|
|
356
|
+
timeout: 0.5 # ge=1.0 → Pydantic が 422 を返す ✅
|
|
357
|
+
to_addresses: [] * 101 # max_length=100 → Pydantic が 422 を返す ✅
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
- 全フィールドに `max_length` / `ge` / `le` 制限あり
|
|
361
|
+
- **結果**: ✅ 問題なし
|
|
362
|
+
|
|
363
|
+
---
|
|
364
|
+
|
|
365
|
+
### 5. 情報漏洩
|
|
366
|
+
|
|
367
|
+
- SMTPAuthenticationError のエラーメッセージには SMTP レスポンスコード(535 等)のみを含む
|
|
368
|
+
- パスワードはエラーメッセージに含まれない
|
|
369
|
+
- **結果**: ✅ 問題なし(パスワード自体は SecretStr 化を推奨 — F-3)
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
### 6. Python / FastAPI 固有の攻撃ベクター
|
|
374
|
+
|
|
375
|
+
#### ReDoS
|
|
376
|
+
- `_EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$")`
|
|
377
|
+
- `^` と `$` でアンカーされており、バックトラッキングの爆発は限定的
|
|
378
|
+
- `max_length=254` が先行してチェックされるため入力上限あり
|
|
379
|
+
- **結果**: ✅ 問題なし
|
|
380
|
+
|
|
381
|
+
#### pickle / yaml
|
|
382
|
+
- 使用なし
|
|
383
|
+
- **結果**: ✅ 問題なし
|
|
384
|
+
|
|
385
|
+
#### 非同期レースコンディション
|
|
386
|
+
- 全エンドポイントが同期関数 — グローバル状態への書き込みなし
|
|
387
|
+
- **結果**: ✅ 問題なし
|
|
388
|
+
|
|
389
|
+
#### 型強制攻撃 (Pydantic Type Coercion)
|
|
390
|
+
- `use_starttls: bool` に `"yes"` を送ると `True` に変換される — 意図通り
|
|
391
|
+
- `smtp_port: int` に `"587"` を送ると `587` に変換される — 意図通り
|
|
392
|
+
- **結果**: ✅ 問題なし(意図した変換)
|
|
393
|
+
|
|
394
|
+
#### SSRF(/send・/check-server エンドポイント)
|
|
395
|
+
```
|
|
396
|
+
smtp_host: "127.0.0.1" → 内部 SMTP サービスにアクセス可能
|
|
397
|
+
smtp_host: "169.254.169.254" → AWS メタデータへの接続試行
|
|
398
|
+
smtp_host: "192.168.1.1" → 内部ネットワークへの接続
|
|
399
|
+
host: "127.0.0.1", port: 6379 → Redis のプローブ(SMTP プロトコルなので応答は失敗)
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`/check-server` は EHLO を試みるため、任意ホストへの TCP 接続を誘発できる。
|
|
403
|
+
ただし SMTP プロトコルでのやり取りのみで、HTTP リクエストや恣意的なデータ送信はできない。
|
|
404
|
+
**結果**: ⚠️ 内部ネットワークのポートスキャンに悪用される可能性あり(F-2)
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
### 7. 依存関係の脆弱性スキャン
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
Name Version ID Fix Versions
|
|
412
|
+
----- ------- -------------- ------------
|
|
413
|
+
pyjwt 2.12.1 PYSEC-2025-183 (修正版なし)
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
- PYSEC-2025-183: PyJWT の mcp 依存経由の推移的脆弱性
|
|
417
|
+
- **対応方針**: mcp ライブラリ側の修正を待ち。継続監視中
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
### 診断サマリー
|
|
422
|
+
|
|
423
|
+
| カテゴリ | 結果 | 最重要発見 |
|
|
424
|
+
|---|---|---|
|
|
425
|
+
| OWASP API Security Top 10 | ⚠️ 3件要対応 | /send の認証なし・SSRF リスク |
|
|
426
|
+
| SMTP ヘッダーインジェクション | ✅ 全通過 | sanitize_header() で防御済み |
|
|
427
|
+
| コマンドインジェクション | ✅ 問題なし | - |
|
|
428
|
+
| パストラバーサル | ✅ 該当なし | - |
|
|
429
|
+
| 認証・認可 | ⚠️ | SmtpConfig.password が str |
|
|
430
|
+
| 入力バリデーション | ✅ 問題なし | 全フィールドに制限あり |
|
|
431
|
+
| 情報漏洩 | ✅ 問題なし | コード番号のみ返す |
|
|
432
|
+
| ReDoS | ✅ 問題なし | アンカー + max_length 保護 |
|
|
433
|
+
| pickle / yaml | ✅ 問題なし | - |
|
|
434
|
+
| 非同期レースコンディション | ✅ 問題なし | - |
|
|
435
|
+
| 型強制攻撃 | ✅ 問題なし | - |
|
|
436
|
+
| 依存関係 CVE | ⚠️ | PYSEC-2025-183(継続監視) |
|
|
437
|
+
|
|
438
|
+
**総合評価**: 条件付き合格(F-2・F-3 を次 FT までに対応)
|
|
439
|
+
**発見した脆弱性**: 3件(CRITICAL: 0 / HIGH: 0 / MEDIUM: 2 / LOW: 1)
|
|
440
|
+
**新規セキュリティ Issue**: #513(SSRF)、#514(SecretStr)
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## 摩擦ポイント(セキュリティ診断由来)
|
|
445
|
+
|
|
446
|
+
### F-2: `/send`・`/check-server` で任意ホストへの TCP 接続が可能(深刻度: 中)
|
|
447
|
+
|
|
448
|
+
**事象**: `smtp_host: "127.0.0.1"` を送ると内部サービスへの接続を試みる。
|
|
449
|
+
内部 SMTP がない場合でも接続試行(タイムアウトまで)が行われる。
|
|
450
|
+
|
|
451
|
+
**対応**: ホスト名の許可リスト制限、または Private IP アドレスブロック(RFC 1918)の拒否:
|
|
452
|
+
```python
|
|
453
|
+
import ipaddress
|
|
454
|
+
|
|
455
|
+
_PRIVATE_RANGES = [
|
|
456
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
457
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
458
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
459
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
460
|
+
ipaddress.ip_network("169.254.0.0/16"),
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
def is_ssrf_safe_host(host: str) -> bool:
|
|
464
|
+
try:
|
|
465
|
+
addr = ipaddress.ip_address(host)
|
|
466
|
+
return not any(addr in network for network in _PRIVATE_RANGES)
|
|
467
|
+
except ValueError:
|
|
468
|
+
return True # ホスト名はドメイン名バリデーションに委ねる
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
### F-3: `SmtpConfig.password` が `str` 型でログに平文が残る可能性(深刻度: 低)
|
|
472
|
+
|
|
473
|
+
**事象**: `SmtpConfig(password="secret")` を `repr()` / `str()` するとパスワードが平文で出力される。
|
|
474
|
+
FastAPI のアクセスログやデバッグプリントで漏洩する可能性がある。
|
|
475
|
+
|
|
476
|
+
**対応**: Pydantic の `SecretStr` で保護する:
|
|
477
|
+
```python
|
|
478
|
+
from pydantic import SecretStr
|
|
479
|
+
|
|
480
|
+
# HTTP リクエストボディで受け取る場合
|
|
481
|
+
class SendRequest(BaseModel):
|
|
482
|
+
password: SecretStr # repr では '**********' と表示される
|
|
483
|
+
|
|
484
|
+
# UseCase に渡す場合
|
|
485
|
+
config = SmtpConfig(password=body.password.get_secret_value())
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## Follow-up Issues
|
|
491
|
+
|
|
492
|
+
| 優先度 | タイトル | 種別 |
|
|
493
|
+
|---|---|---|
|
|
494
|
+
| 中 | [FT183] /send・/check-server の SSRF 対策(Private IP ブロック) | security |
|
|
495
|
+
| 低 | [FT183] SmtpConfig.password を SecretStr に変更 | security |
|
|
496
|
+
|
|
497
|
+
---
|
|
498
|
+
|
|
499
|
+
## まとめ
|
|
500
|
+
|
|
501
|
+
FT183 では `smtplib` モジュールを中心に、SMTP 送信・TLS・ヘッダーインジェクション防御・
|
|
502
|
+
EHLO サーバー確認を実装した。47 テストが全通過し、mypy/ruff も問題なし。
|
|
503
|
+
|
|
504
|
+
セキュリティ診断(FT183 % 3 = 0)では2件の MEDIUM 指摘を発見:
|
|
505
|
+
- F-2: `/send`・`/check-server` の SSRF リスク(Private IP への接続制御が未実装)
|
|
506
|
+
- F-3: `SmtpConfig.password` が `str` のため `SecretStr` 化が必要
|
|
507
|
+
|
|
508
|
+
ヘッダーインジェクション防御(`sanitize_header()`)はパラメトライズドテストで
|
|
509
|
+
4種類の攻撃ペイロードに対して耐性を確認済み。
|
|
510
|
+
|
|
511
|
+
v1.8.54 としてリリース。
|