diff --git a/project_prompter/cache_manager.py b/project_prompter/cache_manager.py index 7f3fba5..eca7604 100644 --- a/project_prompter/cache_manager.py +++ b/project_prompter/cache_manager.py @@ -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) diff --git a/project_prompter/cli.py b/project_prompter/cli.py index 662e0a4..33f5e3a 100644 --- a/project_prompter/cli.py +++ b/project_prompter/cli.py @@ -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 @@ -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__}") diff --git a/project_prompter/scanner.py b/project_prompter/scanner.py index dfee3c1..832fca1 100644 --- a/project_prompter/scanner.py +++ b/project_prompter/scanner.py @@ -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. diff --git a/project_prompter/web.py b/project_prompter/web.py index 775e289..1cf11ad 100644 --- a/project_prompter/web.py +++ b/project_prompter/web.py @@ -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 @@ -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") @@ -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") @@ -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, @@ -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 @@ -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") diff --git a/project_prompter/web_security.py b/project_prompter/web_security.py index 3321ffa..2e4855c 100644 --- a/project_prompter/web_security.py +++ b/project_prompter/web_security.py @@ -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 diff --git a/tests/test_cli_plan.py b/tests/test_cli_plan.py index 41633be..906a3dc 100644 --- a/tests/test_cli_plan.py +++ b/tests/test_cli_plan.py @@ -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 diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py index 44ec8b7..6d09317 100644 --- a/tests/test_security_hardening.py +++ b/tests/test_security_hardening.py @@ -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" diff --git a/tests/test_web_rate_limit.py b/tests/test_web_rate_limit.py index 19d97a7..dc8b120 100644 --- a/tests/test_web_rate_limit.py +++ b/tests/test_web_rate_limit.py @@ -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()