Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
33 changes: 33 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Normalise line endings so template files are never checked out with CRLF.
#
# A generated project's shell scripts run inside Linux containers; a CRLF
# `scripts/pre-start.sh` makes the app container die with
# `env: 'bash\r': No such file or directory`. Contributors on Windows (or with
# `core.autocrlf=true`) would otherwise ship CRLF into the templates.

# Let Git decide for anything it detects as text, but never rewrite to CRLF.
* text=auto eol=lf

# Template payload files (the `-tpl` marker is stripped at generation time).
*-tpl text eol=lf

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude binary template payloads from text normalization

The broad *-tpl text rule also matches supported binary payloads such as logo.png-tpl; the later *.png binary rule does not match that suffixed filename. On checkout Git will therefore normalize the PNG signature's CRLF bytes before the transducer can copy the file unchanged, corrupting such assets—the added test_binary_files_are_copied_untouched case assumes exactly this filename remains byte-for-byte intact. Add binary rules for the .<binary-extension>-tpl forms or avoid forcing every template payload to text.

Useful? React with 👍 / 👎.

*.sh-tpl text eol=lf
Dockerfile-tpl text eol=lf
Makefile-tpl text eol=lf

# Scripts and container files in this repository itself.
*.sh text eol=lf
*.bash text eol=lf
Dockerfile text eol=lf
Makefile text eol=lf

# Binary assets must never be touched.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.zip binary
*.gz binary
*.woff binary
*.woff2 binary
7 changes: 7 additions & 0 deletions .github/workflows/template-inspection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@ jobs:

- name: Setup PDM
uses: pdm-project/setup-pdm@v4
with:
# Pin the interpreter: without this PDM may resolve a newer runtime
# than the one actions/setup-python provisioned.
python-version: "3.12"

- name: Install dependencies
run: pdm install -G dev

- name: Verify Python version
run: pdm run python --version

- name: Install UV package manager
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/template-pr-inspection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,17 @@ jobs:

- name: Setup PDM
uses: pdm-project/setup-pdm@v4
with:
# Pin the interpreter: without this PDM may resolve a newer runtime
# than the one actions/setup-python provisioned.
python-version: "3.12"

- name: Install dependencies
run: pdm install -G dev

- name: Verify Python version
run: pdm run python --version

- name: Setup UV
uses: astral-sh/setup-uv@v7

Expand Down
144 changes: 95 additions & 49 deletions .github/workflows/template-security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,15 @@ jobs:

- name: Run security scan on templates
id: scan
env:
TEMPLATES_INPUT: ${{ github.event.inputs.templates }}
run: |
TEMPLATE_DIR="src/fastapi_fastkit/fastapi_project_template"
RESULTS_FILE="security_scan_results.json"
SCAN_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Initialize results
echo '{' > $RESULTS_FILE
echo ' "scan_date": "'$SCAN_DATE'",' >> $RESULTS_FILE
echo ' "templates": [' >> $RESULTS_FILE

TEMPLATES_INPUT="${{ github.event.inputs.templates }}"
FIRST_TEMPLATE=true
TOTAL_VULNERABILITIES=0
AFFECTED_TEMPLATES=""
TEMP_DIR=$(mktemp -d)
MANIFEST="$TEMP_DIR/manifest.txt"
: > "$MANIFEST"

for template_dir in $TEMPLATE_DIR/fastapi-*/; do
template_name=$(basename "$template_dir")
Expand All @@ -65,44 +60,83 @@ jobs:
temp_req=$(mktemp)
cp "$req_file" "$temp_req"

# Run pip-audit and capture output
audit_output=$(pip-audit -r "$temp_req" --format json 2>/dev/null || echo '[]')
rm "$temp_req"

# Count vulnerabilities
vuln_count=$(echo "$audit_output" | python3 -c "import sys, json; data = json.load(sys.stdin); print(len(data))" 2>/dev/null || echo "0")

if [ "$vuln_count" -gt 0 ]; then
TOTAL_VULNERABILITIES=$((TOTAL_VULNERABILITIES + vuln_count))
AFFECTED_TEMPLATES="$AFFECTED_TEMPLATES $template_name"
echo "⚠️ Found $vuln_count vulnerabilities in $template_name"
else
echo "✅ No vulnerabilities in $template_name"
fi
audit_file="$TEMP_DIR/${template_name}.json"
err_file="$TEMP_DIR/${template_name}.err"

# Add to JSON
if [ "$FIRST_TEMPLATE" = true ]; then
FIRST_TEMPLATE=false
else
echo ' ,' >> $RESULTS_FILE
if ! pip-audit -r "$temp_req" --format json > "$audit_file" 2>"$err_file"; then
echo "⚠️ pip-audit failed for $template_name (recorded in results)"
fi
rm -f "$temp_req"

echo ' {' >> $RESULTS_FILE
echo ' "name": "'$template_name'",' >> $RESULTS_FILE
echo ' "vulnerability_count": '$vuln_count',' >> $RESULTS_FILE
echo ' "vulnerabilities": '$audit_output >> $RESULTS_FILE
echo ' }' >> $RESULTS_FILE
echo "$template_name" >> "$MANIFEST"
fi
done

echo ' ],' >> $RESULTS_FILE
echo ' "total_vulnerabilities": '$TOTAL_VULNERABILITIES',' >> $RESULTS_FILE
echo ' "affected_templates": "'$(echo $AFFECTED_TEMPLATES | xargs)'"' >> $RESULTS_FILE
echo '}' >> $RESULTS_FILE

# Set outputs for later steps
echo "total_vulnerabilities=$TOTAL_VULNERABILITIES" >> $GITHUB_OUTPUT
echo "affected_templates=$AFFECTED_TEMPLATES" >> $GITHUB_OUTPUT
# Assemble valid JSON results (and never do it via shell string concatenation)
TEMP_DIR="$TEMP_DIR" RESULTS_FILE="$RESULTS_FILE" SCAN_DATE="$SCAN_DATE" python3 - <<'PYEOF'
import json
import os

temp_dir = os.environ["TEMP_DIR"]
results_file = os.environ["RESULTS_FILE"]
scan_date = os.environ["SCAN_DATE"]

manifest_path = os.path.join(temp_dir, "manifest.txt")
with open(manifest_path) as f:
template_names = [line.strip() for line in f if line.strip()]

templates = []
total_vulnerabilities = 0
affected_templates = []

for name in template_names:
audit_file = os.path.join(temp_dir, f"{name}.json")
err_file = os.path.join(temp_dir, f"{name}.err")

entry = {"template": name, "vulnerability_count": 0, "vulnerabilities": []}

try:
with open(audit_file) as af:
data = json.load(af)
dependencies = data.get("dependencies", [])
vulnerable_deps = [dep for dep in dependencies if dep.get("vulns")]
vuln_count = sum(len(dep.get("vulns", [])) for dep in dependencies)
entry["vulnerability_count"] = vuln_count
entry["vulnerabilities"] = vulnerable_deps
except Exception as exc:
error_msg = ""
if os.path.exists(err_file):
with open(err_file) as ef:
error_msg = ef.read().strip()
entry["error"] = error_msg or str(exc)

if entry["vulnerability_count"] > 0:
total_vulnerabilities += entry["vulnerability_count"]
affected_templates.append(name)
print(f"⚠️ Found {entry['vulnerability_count']} vulnerabilities in {name}")
elif "error" not in entry:
print(f"✅ No vulnerabilities in {name}")
else:
print(f"❌ pip-audit failed for {name}: {entry['error']}")

templates.append(entry)

results = {
"scan_date": scan_date,
"templates": templates,
"total_vulnerabilities": total_vulnerabilities,
"affected_templates": " ".join(affected_templates),
}

with open(results_file, "w") as rf:
json.dump(results, rf, indent=2)

github_output = os.environ.get("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as gh:
gh.write(f"total_vulnerabilities={total_vulnerabilities}\n")
gh.write(f"affected_templates={' '.join(affected_templates)}\n")
PYEOF

- name: Upload scan results
uses: actions/upload-artifact@v4
Expand All @@ -127,15 +161,27 @@ jobs:
const results = JSON.parse(fs.readFileSync('security_scan_results.json', 'utf8'));

results.templates.forEach(template => {
if (template.error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Surface scanner errors when the vulnerability count is zero

When pip-audit produces no parseable JSON, such as during a dependency-resolution or network failure, the report records template.error but leaves total_vulnerabilities at zero. The step-level condition on line 150 then skips this newly added error-reporting branch, and the summary instead claims that no vulnerabilities were found, leaving a failed weekly security scan looking successful. Gate issue creation on recorded scan errors as well as the vulnerability count, or fail the scan step.

Useful? React with 👍 / 👎.

issueBody += `### ❌ ${template.template}\n\n`;
issueBody += `pip-audit failed to scan this template:\n\n`;
issueBody += "```\n" + template.error + "\n```\n\n";
return;
}

if (template.vulnerability_count > 0) {
issueBody += `### ⚠️ ${template.name}\n\n`;
issueBody += `### ⚠️ ${template.template}\n\n`;
issueBody += `Found **${template.vulnerability_count}** vulnerabilities:\n\n`;
issueBody += `| Package | Installed | Fix Versions | Vulnerability ID |\n`;
issueBody += `|---------|-----------|--------------|------------------|\n`;

template.vulnerabilities.forEach(vuln => {
const fixVersions = vuln.fix_versions ? vuln.fix_versions.join(', ') : 'N/A';
issueBody += `| ${vuln.name} | ${vuln.version} | ${fixVersions} | ${vuln.id} |\n`;
issueBody += `| Package | Installed | Fix Versions | Vulnerability ID / Aliases |\n`;
issueBody += `|---------|-----------|--------------|------------------------------|\n`;

template.vulnerabilities.forEach(dep => {
(dep.vulns || []).forEach(vuln => {
const fixVersions = (vuln.fix_versions && vuln.fix_versions.length)
? vuln.fix_versions.join(', ')
: 'no fix available';
const idAliases = [vuln.id, ...(vuln.aliases || [])].filter(Boolean).join(', ');
issueBody += `| ${dep.name} | ${dep.version} | ${fixVersions} | ${idAliases} |\n`;
});
});

issueBody += `\n`;
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## v1.4.1 (2026-09-10)

### Security

- `python-jose` → `PyJWT[crypto]` in the JWT / FastAPI-Users catalog entries and the `fastapi-mcp` template (drops the unpatched `ecdsa` CVE-2024-23342); docs dependency floors raised for Dependabot advisories (mkdocs-material ≥ 9.7.7, pymdown-extensions ≥ 11.0.1, idna ≥ 3.15; urllib3 / virtualenv / filelock refreshed in lock files).
- `passlib[bcrypt]` → `pwdlib[argon2]` in the JWT / FastAPI-Users catalog entries and the `fastapi-mcp` template (passlib is unmaintained and pinned bcrypt to 4.0.1; generated projects now hash with argon2id like `fastapi-auth-jwt`).

### Fixes

- Template inspector's compile check no longer writes bytecode and runs before the Docker test step (fixes the weekly `fastapi-psql-orm` PermissionError); Docker strategy reclaims bind-mount ownership after container runs; CI pins PDM's interpreter to 3.12; security-scan workflow now builds a valid JSON report and counts vulnerabilities correctly.
- Template inspector parses every `docker-compose ps --format json` shape (single JSON array on Compose < 2.21, NDJSON on newer releases) and falls back to the `docker compose` plugin when the standalone binary is missing.
- Docker-backed templates are smoke-tested against the container's published port instead of requiring a host virtualenv (the weekly `fastapi-psql-orm` run now reaches and passes the smoke step).
- `startdemo` / `init` normalize copied text files to LF and keep executable bits, and a new `.gitattributes` pins template line endings, so shell scripts generated on Windows or with `core.autocrlf=true` no longer fail with `bash\r: No such file or directory`. `fastapi-psql-orm` `.env` now sets `ENVIRONMENT=development`.

## v1.4.0 (2026-09-04)

### Features
Expand Down
22 changes: 10 additions & 12 deletions docs/en/tutorial/mcp-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ Deploying FastAPI project using 'fastapi-mcp' template
│ Dependency 1 │ fastapi │
│ Dependency 2 │ uvicorn │
│ Dependency 3 │ pydantic │
│ Dependency 4 │ python-jose
│ Dependency 5 │ passlib
│ Dependency 4 │ PyJWT
│ Dependency 5 │ pwdlib[argon2]
│ Dependency 6 │ python-multipart│
│ Dependency 7 │ mcp │
└──────────────┴────────────────┘
Expand Down Expand Up @@ -131,21 +131,21 @@ ai-integrated-api/
```python
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
import jwt
from pwdlib import PasswordHash

from src.core.config import settings

# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Password hashing — argon2id, the recommended default for new applications.
password_hash = PasswordHash.recommended()

def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Password verification"""
return pwd_context.verify(plain_password, hashed_password)
return password_hash.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
"""Password hashing"""
return pwd_context.hash(password)
return password_hash.hash(password)

def create_access_token(data: Dict[str, Any], expires_delta: Optional[timedelta] = None) -> str:
"""Access token generation"""
Expand Down Expand Up @@ -189,7 +189,7 @@ def decode_token(token: str) -> Optional[Dict[str, Any]]:
algorithms=[settings.ALGORITHM]
)
return payload
except JWTError:
except jwt.PyJWTError:
return None

def verify_token(token: str, token_type: str = "access") -> Optional[str]:
Expand Down Expand Up @@ -452,8 +452,6 @@ user_db = UserDatabase()
from typing import Optional, List
from fastapi import Depends, HTTPException, status, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials, APIKeyHeader
from jose import JWTError

from src.auth.jwt_handler import decode_token, token_manager
from src.auth.models import User, UserInDB, Permission, user_db

Expand Down Expand Up @@ -486,7 +484,7 @@ async def get_current_user(
if user_id is None:
raise credentials_exception

except JWTError:
except jwt.PyJWTError:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Import jwt before catching PyJWTError

This src/auth/dependencies.py example is a separate module and never imports jwt. When an invalid, blacklisted, or subject-less token raises the intended HTTPException inside the try, Python evaluates jwt.PyJWTError while selecting a handler and raises NameError instead, turning the expected 401 into a 500 for readers who copy the tutorial. Import jwt in this snippet or remove the redundant handler because decode_token already converts JWT failures to None.

Useful? React with 👍 / 👎.

raise credentials_exception

user = user_db.get_user_by_id(user_id)
Expand Down
2 changes: 1 addition & 1 deletion docs/en/user-guide/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ warnings) differ.
| Axis | Choices | Installed packages | Generated files |
|---|---|---|---|
| `database` | PostgreSQL, MySQL, MongoDB, Redis, SQLite, None | PostgreSQL: `asyncpg`, `sqlalchemy` · MySQL: `aiomysql`, `sqlalchemy` · MongoDB: `motor` · Redis: `redis[hiredis]` · SQLite: `sqlalchemy`, `aiosqlite` | A database config module at the preset's path (e.g. `src/config/database.py`) |
| `authentication` | JWT, OAuth2, FastAPI-Users, Session-based, None | JWT: `python-jose[cryptography]`, `passlib[bcrypt]` · OAuth2: `authlib`, `itsdangerous`, `httpx` · FastAPI-Users: `fastapi-users[sqlalchemy]`, `python-jose[cryptography]`, `passlib[bcrypt]` · Session-based: `itsdangerous` | An auth config module at the preset's path; OAuth2 and Session-based also add `main.py` middleware setup |
| `authentication` | JWT, OAuth2, FastAPI-Users, Session-based, None | JWT: `pyjwt[crypto]`, `pwdlib[argon2]` · OAuth2: `authlib`, `itsdangerous`, `httpx` · FastAPI-Users: `fastapi-users[sqlalchemy]`, `pyjwt[crypto]`, `pwdlib[argon2]` · Session-based: `itsdangerous` | An auth config module at the preset's path; OAuth2 and Session-based also add `main.py` middleware setup |
| `async_tasks` | Celery, Dramatiq, None | Celery: `celery[redis]`, `redis[hiredis]` · Dramatiq: `dramatiq[redis]`, `redis[hiredis]` | `<pkg>/worker.py` (background worker) + `<pkg>/features/tasks.py` (task routes) |
| `testing` | Basic, Coverage, Advanced, None | Basic: `pytest`, `pytest-asyncio`, `httpx` · Coverage: + `pytest-cov` · Advanced: + `faker`, `factory-boy` | `pytest.ini`; Advanced also adds `tests/factories.py` and `tests/test_factories.py` |
| `caching` | Redis, None | Redis: `redis[hiredis]`, `fastapi-cache2`, `jinja2` | `<pkg>/features/cache.py` (cached endpoints) |
Expand Down
2 changes: 1 addition & 1 deletion docs/en/user-guide/creating-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ After project creation, you can add more dependencies:
<div class="termy">

```console
$ pip install requests httpx python-jose
$ pip install requests httpx pyjwt
$ pip freeze > requirements.txt
```

Expand Down
Loading
Loading