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
7 changes: 6 additions & 1 deletion project_prompter/cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ def _cache_dir(project_root: Path) -> Path:


def _file_hash(file_path: Path) -> str:
"""SHA256 of file content, combined with size+mtime for speed."""
"""Return a short hash key for a file, derived from its path, size, and mtime.

Uses stat metadata rather than file content for speed. The resulting
key is stable as long as the file has not been modified (size or mtime
change) and serves as the cache-invalidation signal.
"""
try:
stat = file_path.stat()
# Fast key: path + size + mtime (no full read needed for cache key)
Expand Down
8 changes: 8 additions & 0 deletions project_prompter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,10 @@ def main() -> int:
return 1

if args.plan:
error = _validate_flags(args)
if error:
print(f"\nError: {error}", file=sys.stderr)
return 1
return _run_plan(args)

# Validate conflicting flags
Expand Down Expand Up @@ -429,6 +433,10 @@ def _run_plan(args: argparse.Namespace) -> int:

def _start_ui(args: argparse.Namespace) -> int:
"""Start the local web UI."""
if not (1 <= args.port <= 65535):
print(f"Error: --port must be between 1 and 65535 (got {args.port}).", file=sys.stderr)
return 1

try:
from .web import start_server
print(f"Local Project Prompt Generator v{__version__}")
Expand Down
5 changes: 5 additions & 0 deletions project_prompter/scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ def scan_project(
"""
Recursively scan a project directory.

Note: ``max_files`` is intentionally *not* enforced here. The scanner
walks the entire tree so that ``detect_tech_stack`` and
``domain_classifier`` can see all files. File selection is capped later
by ``prioritize_files`` which ranks files by importance before truncating.

Returns:
(scanned_files, redaction_findings)
scanned_files includes all discovered files with content_preview set.
Expand Down
34 changes: 29 additions & 5 deletions project_prompter/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import Any, Dict, List, Literal, Optional

try:
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
Expand Down Expand Up @@ -179,7 +179,7 @@ def _validate_analysis_inputs(request: Any) -> tuple[Path, Path, str]:
return project_path, output_path, ollama_url


def create_app() -> "FastAPI":
def create_app(host: str = "127.0.0.1", port: int = 8787) -> "FastAPI":
"""Create and configure the FastAPI application."""
if not WEB_AVAILABLE:
raise ImportError("FastAPI and uvicorn are required for the web UI. Install with: pip install fastapi uvicorn")
Expand All @@ -189,14 +189,36 @@ def create_app() -> "FastAPI":
description="Scan local projects and generate optimized AI prompts — locally, privately.",
version=__version__,
)

# Build CORS allowed origins from the configured host/port so the UI always
# matches regardless of which address the server was started on.
cors_origins: List[str] = [f"http://{host}:{port}"]
# Always include the canonical loopback aliases so tests and default usage work.
for alias in ("localhost", "127.0.0.1"):
origin = f"http://{alias}:{port}"
if origin not in cors_origins:
cors_origins.append(origin)

app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8787", "http://127.0.0.1:8787"],
allow_origins=cors_origins,
allow_credentials=False,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)

# ---------------------------------------------------------------------------
# Security-headers middleware — defence-in-depth for browser clients
# ---------------------------------------------------------------------------

@app.middleware("http")
async def add_security_headers(request: Request, call_next: Any) -> Response:
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return response

# Mount static files if directory exists
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
Expand Down Expand Up @@ -306,7 +328,7 @@ async def analyze(request: AnalyzeRequest, background_tasks: BackgroundTasks):
request.extra_ignore_dirs = validate_extra_ignore_dirs(request.extra_ignore_dirs)

_evict_old_scans()
scan_id = str(uuid.uuid4())[:12]
scan_id = uuid.uuid4().hex[:20]

_scans[scan_id] = {
"scan_id": scan_id,
Expand Down Expand Up @@ -378,6 +400,8 @@ def progress(msg: str) -> None:
scan["progress"].append(msg)

try:
# Re-validate inputs inside the background task so any tampered or
# stale values are caught before starting heavy analysis work.
project_path, output_base_path, ollama_url = _validate_analysis_inputs(request)
request.extra_ignore_dirs = validate_extra_ignore_dirs(request.extra_ignore_dirs)
output_path = output_base_path / scan_id
Expand Down Expand Up @@ -480,5 +504,5 @@ def start_server(host: str = "127.0.0.1", port: int = 8787) -> None:
raise ImportError(
"FastAPI and uvicorn are required. Install with: pip install fastapi uvicorn"
)
app = create_app()
app = create_app(host=host, port=port)
uvicorn.run(app, host=host, port=port, log_level="info")
5 changes: 4 additions & 1 deletion project_prompter/web_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ def validate_extra_ignore_dirs(extra_ignore_dirs: list[str]) -> list[str]:

def enforce_analyze_rate_limit() -> int:
raw_limit = os.environ.get(ANALYZE_RATE_LIMIT_PER_MIN_ENV, "60")
limit = int(raw_limit)
try:
limit = int(raw_limit)
except (ValueError, TypeError):
limit = 60 # fall back to safe default when env var is malformed
if limit <= 0:
return 0

Expand Down
27 changes: 27 additions & 0 deletions tests/test_cli_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,30 @@ def test_plan_command_creates_upgrade_plan_file(tmp_path: Path):
assert plan_file.exists()
text = plan_file.read_text(encoding="utf-8")
assert "Project Upgrade Plan" in text


def test_start_ui_rejects_out_of_range_port():
"""_start_ui must refuse ports outside 1-65535 without starting the server."""
import types

args = types.SimpleNamespace(port=0, host="127.0.0.1")
assert cli._start_ui(args) == 1

args.port = 65536
assert cli._start_ui(args) == 1

args.port = -1
assert cli._start_ui(args) == 1


def test_validate_flags_called_before_plan_with_conflicting_ollama_flags(tmp_path):
"""--plan should honour --use-ollama/--no-ollama conflict detection."""
parser = cli.create_parser()
args = parser.parse_args(
[str(tmp_path), "--plan", "--use-ollama", "--no-ollama", "--output", str(tmp_path / "out")]
)
# The main() entry-point calls _validate_flags before _run_plan;
# validate directly here to confirm the flag check catches it.
error = cli._validate_flags(args)
assert error is not None
assert "Conflicting" in error
27 changes: 27 additions & 0 deletions tests/test_security_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,30 @@ def test_connection_string_pattern_is_bounded_and_still_redacts():

assert "password123" not in redacted
assert ("CONNECTION_STRING", 1) in findings


@pytest.mark.skipif(TestClient is None or not web.WEB_AVAILABLE, reason="FastAPI not available")
def test_security_headers_present_on_health_endpoint():
"""Responses must include basic defence-in-depth security headers."""
client = TestClient(web.create_app())
response = client.get("/api/health")

assert response.status_code == 200
assert response.headers.get("x-content-type-options") == "nosniff"
assert response.headers.get("x-frame-options") == "DENY"
assert response.headers.get("referrer-policy") == "strict-origin-when-cross-origin"


@pytest.mark.skipif(TestClient is None or not web.WEB_AVAILABLE, reason="FastAPI not available")
def test_cors_uses_configured_port():
"""CORS allowed origins should reflect the port passed to create_app."""
client = TestClient(web.create_app(host="127.0.0.1", port=9090))

allowed = client.options(
"/api/health",
headers={
"Origin": "http://localhost:9090",
"Access-Control-Request-Method": "GET",
},
)
assert allowed.headers.get("access-control-allow-origin") == "http://localhost:9090"
10 changes: 10 additions & 0 deletions tests/test_web_rate_limit.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,13 @@ def test_analyze_rate_limit_disabled_with_non_positive_value(monkeypatch):

for _ in range(10):
web_security.enforce_analyze_rate_limit()


def test_analyze_rate_limit_falls_back_to_default_on_invalid_env_var(monkeypatch):
"""A non-integer env var must not crash the rate limiter; it falls back to 60."""
web_security._analyze_window.clear()
monkeypatch.setenv(web_security.ANALYZE_RATE_LIMIT_PER_MIN_ENV, "not-a-number")

# Should not raise — the fallback limit (60) has not been reached
for _ in range(5):
web_security.enforce_analyze_rate_limit()
Loading