diff --git a/README.md b/README.md index 84f73d4..2fd25bf 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,166 @@ # RedactPDF -Local, verifiable PDF redaction (removes content instead of masking). -## Backend +Local web app for redacting sensitive content from PDF files. Targets manual +rectangles, text search, regex (single- or multi-line), and presets (email, +phone, credit card). Every export passes a post-redaction audit before being +returned, if any targeted content remains in the output, the export is +blocked with a structured failure report. + +For the security model and limitations, see [docs/SECURITY.md](docs/SECURITY.md). + +--- + +## Requirements + +- **Python 3.10 or newer** (CI runs 3.11) +- **Node.js 20 or newer** (for the frontend dev server / build) +- A POSIX-like shell (Linux, macOS, WSL). Windows native is not officially + tested. + +--- + +## Install + +Clone the repo and set up the two halves: -Install dev dependencies: ```bash -pip install -e "backend[dev]" +git clone https://github.com/theodubus/RedactPDF.git +cd RedactPDF ``` -Run tests: +### Backend (FastAPI + PyMuPDF) ```bash cd backend -pytest +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +`.[dev]` pulls runtime deps (FastAPI, PyMuPDF, `phonenumbers`, …) plus dev +tooling (pytest, ruff, reportlab, httpx, pypdf). + +### Frontend (React + Vite) + +```bash +cd ../frontend +npm ci +``` + +--- + +## Run locally + +You need two terminals. + +**Terminal 1 - backend:** + +```bash +cd backend +source .venv/bin/activate +uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload ``` -Run the API locally: +**Terminal 2 - frontend:** + +```bash +cd frontend +npm run dev +``` + +Open the URL Vite prints (default: `http://localhost:5173`). The Vite dev +server proxies `/api/*` to the backend on `127.0.0.1:8000` (configured in +[frontend/vite.config.ts](frontend/vite.config.ts)), so you do not need to +worry about CORS in development. + +--- + +## Use it + +1. Drop a PDF into the upload area. +2. Add redaction rules in the right pane: + - **Selection** : select text in the viewer and click "Add as rule". + - **Manual rectangle** : toggle the draw tool and trace rectangles. + - **Whole page** : censor the current page. + - **Exact / regex** : typed rules with options (case sensitivity, + whole-word, ignore accents, multiline). + - **Presets** : email, phone (libphonenumber-validated, default region + `FR`), credit card (Luhn-filtered). +3. Pick an **image redaction mode** in the right pane (segmented control): + - **Aucune** (None) : images and vector graphics are not modified, even + if a redaction rectangle overlaps them. Only the text under the + redaction is removed; visually a black overlay is drawn on top, but + the underlying images/graphics still exist in the PDF and are + recoverable by anyone who removes the overlay. + - **Totale** (Full) : any image touched by a rectangle is removed + entirely; any vector path touched is removed. + - **Précise** (Precise) *(default)* : only the pixels inside the + redaction rectangle are blackened in the underlying bitmap; the rest + of the image stays visible. Vector paths touched by the rectangle are + removed (per-path, not pixel-level, vector partial redaction is not + supported). **This is the mode that makes flattened / scanned PDFs + redactable**, without it, the only options on a scan would be "lose + the whole page" or draw a fake black overlay. Re-encoding may be + lossy on JPEG-backed images. + + The mode applies globally to the redaction request. **Exception**: any + "Censurer la page" rule (full-page redaction) always uses strict mode + for the page it covers, regardless of the chosen image mode, so a + full-page rule cannot accidentally leave images or graphics under a + black overlay. See [docs/SECURITY.md](docs/SECURITY.md) for details. +4. Click the submit button. The redacted PDF downloads automatically. +5. If the post-redaction audit finds any targeted content remaining, you + get a 400 with a JSON report instead, fix the rule and retry. + +### Phone preset region + +The `phone` preset uses `phonenumbers` to validate candidates. Numbers +without a leading `+` are parsed against a default region. To change it: + +```bash +export REDACT_DEFAULT_REGION=US +uvicorn app.main:app ... +``` + +--- + +## Run the tests ```bash cd backend -uvicorn app.main:app --reload +source .venv/bin/activate +ruff check . +pytest ``` -## Security +The test suite uses **versioned PDF fixtures** : see +[backend/tests/fixtures/README.md](backend/tests/fixtures/README.md). Do not +edit the generated PDFs by hand. If you change the fixture generator, +regenerate all of them in one go: + +```bash +python -m backend.tests.fixtures.generate_fixtures +``` + +--- + +## Production deployment -See [docs/SECURITY.md](docs/SECURITY.md). +This project is built for local single-user usage. **Exposing the API to +untrusted networks or multiple users is unsafe out of the box**, there is +no auth, no rate limiting, no upload size cap, no regex timeout. If you +plan to host it for others, read +[docs/SECURITY.md → Production / Multi-user Deployment](docs/SECURITY.md) +first and put the recommended protections in your reverse proxy / +container. -## Fixtures +A typical setup is: build the frontend (`npm run build` produces a static +bundle in `frontend/dist`), serve it from a reverse proxy that also +proxies `/api/*` to the backend (Uvicorn or Gunicorn-with-Uvicorn-workers +behind nginx/Caddy). With same-origin serving, you do not need CORS. -See [backend/tests/fixtures/README.md](backend/tests/fixtures/README.md). +--- -## Testing +## License -See [docs/TESTING.md](docs/TESTING.md). +See repository root. diff --git a/backend/app/main.py b/backend/app/main.py index e024a3b..f945890 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,16 +2,14 @@ import base64 import json -from typing import Annotated, Any +from typing import Annotated, Any, Literal -import pymupdf from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, ValidationError, field_validator from starlette.responses import Response -from app.audit import AuditOptions, audit_pdf_text, build_audit_for_search -from app.multiline_regex_engine import find_redaction_rectangles_by_regex +from app.audit import AuditOptions from app.pipeline import ( PresetsRequest, RedactionOptions, @@ -21,9 +19,8 @@ audit_plan, plan_redactions, ) -from app.presets import available_presets, find_redaction_rectangles_for_presets -from app.redaction import RedactionRect, redact_pdf_by_rectangles -from app.search import SearchOptions, find_redaction_rectangles +from app.presets import available_presets +from app.redaction import RedactionRect app = FastAPI() @@ -61,13 +58,16 @@ class RectModel(BaseModel): class OptionsModel(BaseModel): - apply_images: bool = False + # "none" : ne touche pas aux images + # "remove" : supprime entièrement les images intersectées (mode strict) + # "pixels" : noircit uniquement la zone intersectée (caviardage partiel) + image_mode: Literal["none", "remove", "pixels"] = "none" apply_graphics: bool = False - # Étape 09 : sanitation "anti-fuites hors visuel" - sanitize_metadata: bool = False - remove_annotations: bool = False - remove_attachments: bool = False + # Sanitation "anti-fuites hors visuel" : ON par défaut (secure-by-default). + sanitize_metadata: bool = True + remove_annotations: bool = True + remove_attachments: bool = True class AuditModel(BaseModel): @@ -84,12 +84,6 @@ def validate_patterns(cls, v: list[str]) -> list[str]: return cleaned -class RectanglesPayload(BaseModel): - rects: list[RectModel] - options: OptionsModel = Field(default_factory=OptionsModel) - audit: AuditModel - - class SearchOptionsModel(BaseModel): case_sensitive: bool = False whole_word: bool = False @@ -110,422 +104,8 @@ def validate_pages(cls, v: list[int] | None) -> list[int] | None: return v -class SearchPayload(BaseModel): - query: str - options: SearchOptionsModel = Field(default_factory=SearchOptionsModel) - scope: ScopeModel = Field(default_factory=ScopeModel) - # options de redaction (images/graphics + sanitize) - apply: OptionsModel = Field(default_factory=OptionsModel) - audit: AuditModel - - @field_validator("query") - @classmethod - def validate_query(cls, v: str) -> str: - if not v or not v.strip(): - raise ValueError("query must be non-empty") - return v.strip() - - -class PresetsPayload(BaseModel): - presets: list[str] - scope: ScopeModel = Field(default_factory=ScopeModel) - options: OptionsModel = Field(default_factory=OptionsModel) - # audit optionnel : le backend fait un audit interne cohérent presets - audit: AuditModel | None = None - - @field_validator("presets") - @classmethod - def validate_presets(cls, v: list[str]) -> list[str]: - cleaned = [p.strip() for p in v if p and p.strip()] - if not cleaned: - raise ValueError("presets must contain at least one non-empty preset key") - - allowed = set(available_presets()) - unknown = [p for p in cleaned if p not in allowed] - if unknown: - raise ValueError( - f"Unknown preset(s): {', '.join(unknown)}. Available: {', '.join(sorted(allowed))}" - ) - return cleaned - - -class RegexPayload(BaseModel): - patterns: list[str] - case_sensitive: bool = False - multiline: bool = False - ignore_accents: bool = False - scope: ScopeModel = Field(default_factory=ScopeModel) - options: OptionsModel = Field(default_factory=OptionsModel) - audit: AuditModel - - @field_validator("patterns", mode="before") - @classmethod - def coerce_patterns(cls, v: Any) -> list[str]: - if isinstance(v, str): - cleaned = v.strip() - if not cleaned: - raise ValueError("patterns must be a non-empty string or a non-empty list") - return [cleaned] - - if isinstance(v, list): - cleaned_list = [str(p).strip() for p in v if p and str(p).strip()] - if not cleaned_list: - raise ValueError("patterns must be a non-empty string or a non-empty list") - return cleaned_list - - raise ValueError("patterns must be a non-empty string or a non-empty list") - - # ---------------------------- -# Endpoints -# ---------------------------- - - -@app.post("/redact/rectangles") -async def redact_rectangles( - file: Annotated[UploadFile, File(...)], - payload: Annotated[str, Form(...)], -) -> Response: - try: - data = RectanglesPayload.model_validate_json(payload) - except ValidationError as e: - raise HTTPException(status_code=422, detail=e.errors()) from e - except Exception: - raise HTTPException(status_code=400, detail="Invalid payload JSON") from None - - pdf_bytes = await file.read() - if not pdf_bytes: - raise HTTPException(status_code=400, detail="Empty PDF upload") - - rects = [RedactionRect(page=r.page, x0=r.x0, y0=r.y0, x1=r.x1, y1=r.y1) for r in data.rects] - - try: - out_pdf = redact_pdf_by_rectangles( - pdf_bytes, - rects, - apply_images=data.options.apply_images, - apply_graphics=data.options.apply_graphics, - sanitize_metadata=data.options.sanitize_metadata, - remove_annotations=data.options.remove_annotations, - remove_attachments=data.options.remove_attachments, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Redaction failed") from e - - try: - report = audit_pdf_text( - out_pdf, - AuditOptions( - patterns=data.audit.patterns, - regex=data.audit.regex, - case_sensitive=data.audit.case_sensitive, - ), - ) - except ValueError as e: - return JSONResponse(status_code=400, content={"status": "error", "error": str(e)}) - - if report["status"] != "pass": - return JSONResponse(status_code=400, content=report) - - headers: dict[str, Any] = { - "Content-Disposition": 'attachment; filename="redacted.pdf"', - "X-Redaction-Audit-Status": "pass", - "X-Redaction-Audit-Matches": "0", - } - _add_report_headers(headers, report) - return Response(content=out_pdf, media_type="application/pdf", headers=headers) - - -@app.post("/redact/search") -async def redact_search( - file: Annotated[UploadFile, File(...)], - payload: Annotated[str, Form(...)], -) -> Response: - """ - IMPORTANT: - - L'audit "interne" est construit automatiquement à partir de - (query, whole_word, case_sensitive) pour éviter les incohérences - qui causent des faux échecs. - - L'audit fourni par le client est ensuite exécuté en audit additionnel (banlist), - sans jamais remplacer l'audit interne. - """ - try: - data = SearchPayload.model_validate_json(payload) - except ValidationError as e: - raise HTTPException(status_code=422, detail=e.errors()) from e - except Exception: - raise HTTPException(status_code=400, detail="Invalid payload JSON") from None - - pdf_bytes = await file.read() - if not pdf_bytes: - raise HTTPException(status_code=400, detail="Empty PDF upload") - - try: - found_rects = find_redaction_rectangles( - pdf_bytes, - SearchOptions( - query=data.query, - case_sensitive=data.options.case_sensitive, - whole_word=data.options.whole_word, - ignore_accents=data.options.ignore_accents, - pages=data.scope.pages, - ), - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Search failed") from e - - try: - out_pdf = redact_pdf_by_rectangles( - pdf_bytes, - found_rects, - apply_images=data.apply.apply_images, - apply_graphics=data.apply.apply_graphics, - sanitize_metadata=data.apply.sanitize_metadata, - remove_annotations=data.apply.remove_annotations, - remove_attachments=data.apply.remove_attachments, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Redaction failed") from e - - # 1) Audit interne cohérent avec la recherche - try: - internal_opts = build_audit_for_search( - query=data.query, - case_sensitive=data.options.case_sensitive, - whole_word=data.options.whole_word, - ignore_accents=data.options.ignore_accents, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - - try: - internal_report = audit_pdf_text(out_pdf, internal_opts) - except ValueError as e: - return JSONResponse(status_code=400, content={"status": "error", "error": str(e)}) - - if internal_report["status"] != "pass": - return JSONResponse(status_code=400, content=internal_report) - - # 2) Audit additionnel (banlist) fourni par le client - try: - client_report = audit_pdf_text( - out_pdf, - AuditOptions( - patterns=data.audit.patterns, - regex=data.audit.regex, - case_sensitive=data.audit.case_sensitive, - ), - ) - except ValueError as e: - return JSONResponse(status_code=400, content={"status": "error", "error": str(e)}) - - if client_report["status"] != "pass": - return JSONResponse(status_code=400, content=client_report) - - headers: dict[str, Any] = { - "Content-Disposition": 'attachment; filename="redacted.pdf"', - "X-Redaction-Audit-Status": "pass", - "X-Redaction-Audit-Matches": "0", - "X-Redaction-Search-Occurrences": str(len(found_rects)), - } - # On expose le report interne (cohérent) en header - _add_report_headers(headers, internal_report) - return Response(content=out_pdf, media_type="application/pdf", headers=headers) - - -def _build_presets_internal_audit_report( - out_pdf: bytes, - *, - presets: list[str], - pages: list[int] | None, -) -> dict[str, Any]: - leaks_by_preset: dict[str, list[RedactionRect]] = {} - total = 0 - matched_pages: set[int] = set() - - for p in presets: - rects = find_redaction_rectangles_for_presets(out_pdf, [p], pages=pages) - leaks_by_preset[p] = rects - total += len(rects) - matched_pages.update(r.page for r in rects) - - matches: list[dict[str, Any]] = [] - if total > 0: - doc = pymupdf.open(stream=out_pdf, filetype="pdf") - try: - for preset_key, rects in leaks_by_preset.items(): - for r in rects: - page = doc[r.page] - rect = pymupdf.Rect(r.x0, r.y0, r.x1, r.y1) - snippet = (page.get_textbox(rect) or "").strip() - matches.append( - { - "preset": preset_key, - "page": r.page, - "rect": {"x0": r.x0, "y0": r.y0, "x1": r.x1, "y1": r.y1}, - "snippet": snippet, - } - ) - finally: - doc.close() - - return { - "status": "fail", - "total_matches": total, - "matched_pages": sorted(matched_pages), - "options": {"mode": "presets_internal", "pages": pages}, - "presets": presets, - "matches": matches, - } - - -@app.post("/redact/presets") -async def redact_presets( - file: Annotated[UploadFile, File(...)], - payload: Annotated[str, Form(...)], -) -> Response: - try: - data = PresetsPayload.model_validate_json(payload) - except ValidationError as e: - raise HTTPException(status_code=422, detail=e.errors()) from e - except Exception: - raise HTTPException(status_code=400, detail="Invalid payload JSON") from None - - pdf_bytes = await file.read() - if not pdf_bytes: - raise HTTPException(status_code=400, detail="Empty PDF upload") - - try: - found_rects = find_redaction_rectangles_for_presets( - pdf_bytes, - data.presets, - pages=data.scope.pages, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Presets search failed") from e - - try: - out_pdf = redact_pdf_by_rectangles( - pdf_bytes, - found_rects, - apply_images=data.options.apply_images, - apply_graphics=data.options.apply_graphics, - sanitize_metadata=data.options.sanitize_metadata, - remove_annotations=data.options.remove_annotations, - remove_attachments=data.options.remove_attachments, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Redaction failed") from e - - report = _build_presets_internal_audit_report( - out_pdf, - presets=data.presets, - pages=data.scope.pages, - ) - if report["total_matches"] != 0: - return JSONResponse(status_code=400, content=report) - - headers: dict[str, Any] = { - "Content-Disposition": 'attachment; filename="redacted.pdf"', - "X-Redaction-Audit-Status": "pass", - "X-Redaction-Audit-Matches": "0", - "X-Redaction-Presets-Occurrences": str(len(found_rects)), - } - - pass_report = { - "status": "pass", - "total_matches": 0, - "matched_pages": [], - "options": {"mode": "presets_internal", "pages": data.scope.pages}, - "presets": data.presets, - "matches": [], - } - _add_report_headers(headers, pass_report) - return Response(content=out_pdf, media_type="application/pdf", headers=headers) - - -@app.post("/redact/regex") -async def redact_regex( - file: Annotated[UploadFile, File(...)], - payload: Annotated[str, Form(...)], -) -> Response: - try: - data = RegexPayload.model_validate_json(payload) - except ValidationError as e: - raise HTTPException(status_code=422, detail=e.errors()) from e - except Exception: - raise HTTPException(status_code=400, detail="Invalid payload JSON") from None - - pdf_bytes = await file.read() - if not pdf_bytes: - raise HTTPException(status_code=400, detail="Empty PDF upload") - - try: - found_rects = find_redaction_rectangles_by_regex( - pdf_bytes, - data.patterns, - case_sensitive=data.case_sensitive, - pages=data.scope.pages, - multiline=data.multiline, - ignore_accents=data.ignore_accents, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Regex search failed") from e - - try: - out_pdf = redact_pdf_by_rectangles( - pdf_bytes, - found_rects, - apply_images=data.options.apply_images, - apply_graphics=data.options.apply_graphics, - sanitize_metadata=data.options.sanitize_metadata, - remove_annotations=data.options.remove_annotations, - remove_attachments=data.options.remove_attachments, - ) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - raise HTTPException(status_code=500, detail="Redaction failed") from e - - try: - report = audit_pdf_text( - out_pdf, - AuditOptions( - patterns=data.audit.patterns, - regex=data.audit.regex, - case_sensitive=data.audit.case_sensitive, - ), - ) - except ValueError as e: - return JSONResponse(status_code=400, content={"status": "error", "error": str(e)}) - - if report["status"] != "pass": - return JSONResponse(status_code=400, content=report) - - headers: dict[str, Any] = { - "Content-Disposition": 'attachment; filename="redacted.pdf"', - "X-Redaction-Audit-Status": "pass", - "X-Redaction-Audit-Matches": "0", - "X-Redaction-Regex-Occurrences": str(len(found_rects)), - } - _add_report_headers(headers, report) - return Response(content=out_pdf, media_type="application/pdf", headers=headers) - - -# ---------------------------- -# Long-term endpoint: /redact/apply +# Endpoint: /redact/apply # ---------------------------- @@ -587,6 +167,10 @@ def validate_presets(cls, v: list[str]) -> list[str]: class ApplyPayload(BaseModel): rects: list[RectModel] = Field(default_factory=list) + # Page-wide rules. Always processed in strict mode (full image + graphics + # removal) regardless of options.image_mode, a page-wide rule is meant + # to wipe the page completely. + full_page_rects: list[RectModel] = Field(default_factory=list) # Compat existante (single) search: ApplySearchModel | None = None @@ -620,6 +204,10 @@ async def redact_apply( manual_rects = [ RedactionRect(page=r.page, x0=r.x0, y0=r.y0, x1=r.x1, y1=r.y1) for r in data.rects ] + full_page_rects = [ + RedactionRect(page=r.page, x0=r.x0, y0=r.y0, x1=r.x1, y1=r.y1) + for r in data.full_page_rects + ] # ---------------------------- # Build searches list (compat: data.search + data.searches) @@ -678,13 +266,14 @@ async def redact_apply( searches=searches_req or None, regexes=regexes_req or None, presets=presets_req, + full_page_rects=full_page_rects or None, ) out_pdf = apply_plan( pdf_bytes, plan, options=RedactionOptions( - apply_images=data.options.apply_images, + image_mode=data.options.image_mode, apply_graphics=data.options.apply_graphics, sanitize_metadata=data.options.sanitize_metadata, remove_annotations=data.options.remove_annotations, @@ -726,7 +315,8 @@ async def redact_apply( "X-Redaction-Search-Occurrences": str(len(plan.search)), "X-Redaction-Regex-Occurrences": str(len(plan.regex)), "X-Redaction-Presets-Occurrences": str(len(plan.presets)), - "X-Redaction-Total-Occurrences": str(len(plan.all_rects)), + "X-Redaction-Full-Page-Occurrences": str(len(plan.full_page)), + "X-Redaction-Total-Occurrences": str(len(plan.all_rects) + len(plan.full_page)), } _add_report_headers(headers, composite) return Response(content=out_pdf, media_type="application/pdf", headers=headers) diff --git a/backend/app/pipeline.py b/backend/app/pipeline.py index 6414f44..3440768 100644 --- a/backend/app/pipeline.py +++ b/backend/app/pipeline.py @@ -15,11 +15,11 @@ @dataclass(frozen=True) class RedactionOptions: - apply_images: bool = False + image_mode: str = "none" apply_graphics: bool = False - sanitize_metadata: bool = False - remove_annotations: bool = False - remove_attachments: bool = False + sanitize_metadata: bool = True + remove_annotations: bool = True + remove_attachments: bool = True @dataclass(frozen=True) @@ -52,6 +52,7 @@ class PlanResult: search: list[RedactionRect] regex: list[RedactionRect] presets: list[RedactionRect] + full_page: list[RedactionRect] all_rects: list[RedactionRect] @@ -74,11 +75,13 @@ def plan_redactions( searches: Sequence[SearchRequest] | None = None, regexes: Sequence[RegexRequest] | None = None, presets: PresetsRequest | None = None, + full_page_rects: list[RedactionRect] | None = None, ) -> PlanResult: if not pdf_bytes: raise ValueError("Empty PDF bytes") manual_out = list(manual_rects or []) + full_page_out = list(full_page_rects or []) search_rects: list[RedactionRect] = [] regex_rects: list[RedactionRect] = [] @@ -126,15 +129,36 @@ def plan_redactions( search=search_rects, regex=regex_rects, presets=presets_rects, + full_page=full_page_out, all_rects=all_rects, ) def apply_plan(pdf_bytes: bytes, plan: PlanResult, *, options: RedactionOptions) -> bytes: + """ + Apply the plan in two passes when full-page rules exist: + 1. Strict pass on full-page rects (remove images + graphics, irrespective + of the user's chosen image_mode). A page-wide rule is always meant to + wipe everything. + 2. User-mode pass on every other rect. + Sanitisation (metadata / annotations / attachments) runs on the final pass. + """ + if plan.full_page: + pdf_bytes = redact_pdf_by_rectangles( + pdf_bytes, + plan.full_page, + image_mode="remove", + apply_graphics=True, + # Defer sanitisation to the second pass so it's applied once on the final PDF. + sanitize_metadata=False, + remove_annotations=False, + remove_attachments=False, + ) + return redact_pdf_by_rectangles( pdf_bytes, plan.all_rects, - apply_images=options.apply_images, + image_mode=options.image_mode, apply_graphics=options.apply_graphics, sanitize_metadata=options.sanitize_metadata, remove_annotations=options.remove_annotations, @@ -142,7 +166,7 @@ def apply_plan(pdf_bytes: bytes, plan: PlanResult, *, options: RedactionOptions) ) -def _presets_internal_audit(out_pdf: bytes, *, presets: PresetsRequest) -> dict[str, Any] | None: +def presets_internal_audit(out_pdf: bytes, *, presets: PresetsRequest) -> dict[str, Any] | None: """ Internal presets audit: rerun presets detection on OUT PDF. If leaks remain -> return a structured fail report, else None. @@ -270,7 +294,7 @@ def audit_plan( # --- Presets internal audit if presets is not None: - leak_report = _presets_internal_audit(out_pdf, presets=presets) + leak_report = presets_internal_audit(out_pdf, presets=presets) if leak_report is not None: failures["presets"] = leak_report diff --git a/backend/app/redaction.py b/backend/app/redaction.py index 91cb0f8..af54753 100644 --- a/backend/app/redaction.py +++ b/backend/app/redaction.py @@ -37,6 +37,10 @@ def _tighten_rect_vertical(rect: pymupdf.Rect) -> pymupdf.Rect: if h <= 0: return rect + # Les zones volumineuses (ex: page complète) ne doivent pas être compressées verticalement. + if h > 24.0: + return rect + target = h * 0.60 if target < 3.0: target = 3.0 @@ -64,11 +68,26 @@ def _tighten_rect_vertical(rect: pymupdf.Rect) -> pymupdf.Rect: +_IMAGE_MODE_MAP = { + "none": pymupdf.PDF_REDACT_IMAGE_NONE, + "remove": pymupdf.PDF_REDACT_IMAGE_REMOVE, + "pixels": pymupdf.PDF_REDACT_IMAGE_PIXELS, +} + + +def _resolve_image_mode(mode: str) -> int: + try: + return _IMAGE_MODE_MAP[mode] + except KeyError as e: + allowed = ", ".join(sorted(_IMAGE_MODE_MAP)) + raise ValueError(f"Invalid image_mode {mode!r}. Allowed: {allowed}.") from e + + def redact_pdf_by_rectangles( pdf_bytes: bytes, rects: Iterable[RedactionRect], *, - apply_images: bool = False, + image_mode: str = "none", apply_graphics: bool = False, sanitize_metadata: bool = False, remove_annotations: bool = False, @@ -77,18 +96,16 @@ def redact_pdf_by_rectangles( """ Applique des redactions à partir d'une liste de rectangles. - Par défaut, ce comportement reste conservateur : - - images OFF - - vector graphics OFF - - text removal ON - - sanitize OFF + Modes images : + - "none" -> ne touche pas aux images (défaut) + - "remove" -> retire intégralement les images intersectées par un rect + - "pixels" -> noircit uniquement les pixels intersectés (caviardage partiel) - Si activé : - - apply_images=True -> suppression des images chevauchant les zones redigées - - apply_graphics=True -> suppression des dessins vectoriels chevauchant les zones redigées - - sanitize_metadata=True -> nettoyage des métadonnées (Info dict + XMP si possible) + Autres options : + - apply_graphics=True -> suppression des dessins vectoriels touchés + - sanitize_metadata=True -> nettoyage métadonnées (Info dict + XMP si possible) - remove_annotations=True -> suppression des annotations/liens/widgets - - remove_attachments=True -> suppression des fichiers embarqués (embedded files) + - remove_attachments=True -> suppression des fichiers embarqués Retourne le PDF redigé (bytes). """ @@ -103,14 +120,11 @@ def redact_pdf_by_rectangles( if rect.is_empty: raise ValueError(f"Empty rectangle: {rect}") - # Option A: amélioration de précision (inset vertical léger) rect = _tighten_rect_vertical(rect) by_page.setdefault(r.page, []).append(rect) - images_mode = ( - pymupdf.PDF_REDACT_IMAGE_REMOVE if apply_images else pymupdf.PDF_REDACT_IMAGE_NONE - ) + images_mode = _resolve_image_mode(image_mode) graphics_mode = ( pymupdf.PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED if apply_graphics diff --git a/backend/tests/test_multiline.py b/backend/tests/test_multiline.py index b3ac9f1..ab48e9a 100644 --- a/backend/tests/test_multiline.py +++ b/backend/tests/test_multiline.py @@ -20,106 +20,81 @@ def _load_pdf(name: str) -> bytes: PHONE_REGEX = r"06\s+12\s+34\s+56\s+78" +def _post_apply(pdf_bytes: bytes, payload: dict): + client = TestClient(app) + return client.post( + "/redact/apply", + files={ + "file": ("in.pdf", pdf_bytes, "application/pdf"), + "payload": (None, json.dumps(payload), "application/json"), + }, + ) + + @pytest.mark.integration def test_presets_phone_multiline_two_lines_is_redacted() -> None: - client = TestClient(app) pdf_in = _load_pdf("003_phone_two_lines.pdf") payload = { - "presets": ["phone"], + "rects": [], + "presets": {"presets": ["phone"], "scope": {"pages": None}}, "audit": { "patterns": [PHONE_REGEX], "regex": True, "case_sensitive": False, }, - # options exist in your schema; keep minimal if your API ignores options for now "options": {}, } - resp = client.post( - "/redact/presets", - files={ - "file": ("in.pdf", pdf_in, "application/pdf"), - "payload": (None, json.dumps(payload), "application/json"), - }, - ) - + resp = _post_apply(pdf_in, payload) assert resp.status_code == 200, resp.text - pdf_out = resp.content - text_out = extract_text(pdf_out) + text_out = extract_text(resp.content) assert re.search(PHONE_REGEX, text_out, flags=re.IGNORECASE) is None @pytest.mark.integration def test_regex_endpoint_multiline_can_match_across_two_lines() -> None: - client = TestClient(app) pdf_in = _load_pdf("003_phone_two_lines.pdf") payload = { - "patterns": [PHONE_REGEX], - "case_sensitive": False, - "multiline": True, + "rects": [], + "regexes": [ + { + "patterns": [PHONE_REGEX], + "case_sensitive": False, + "multiline": True, + "scope": {"pages": None}, + } + ], "audit": { "patterns": [PHONE_REGEX], "regex": True, "case_sensitive": False, }, "options": {}, - "scope": {"pages": None}, } - resp = client.post( - "/redact/regex", - files={ - "file": ("in.pdf", pdf_in, "application/pdf"), - "payload": (None, json.dumps(payload), "application/json"), - }, - ) - + resp = _post_apply(pdf_in, payload) assert resp.status_code == 200, resp.text - pdf_out = resp.content - text_out = extract_text(pdf_out) + text_out = extract_text(resp.content) assert re.search(PHONE_REGEX, text_out, flags=re.IGNORECASE) is None @pytest.mark.integration def test_multiline_does_not_cross_columns_trap_fixture() -> None: """ - This test requires adding a dedicated fixture, see instructions: - 009_cross_column_phone_trap.pdf - - The goal: if a naive engine concatenates adjacent lines globally, it might match the phone. - Our engine should avoid pairing across columns and return 0 occurrences. + The geometric engine must not pair lines across columns. + Tested directly on the engine: the API audit re-runs regex on flattened + page text where '\\s+' matches the column boundary newline, which would + spuriously hit even though the engine correctly returned 0 rects. """ - client = TestClient(app) - pdf_in = _load_pdf("009_cross_column_phone_trap.pdf") + from app.multiline_regex_engine import find_redaction_rectangles_by_regex - payload = { - "patterns": [PHONE_REGEX], - "case_sensitive": False, - "multiline": True, - "audit": { - # audit should not fail; we are not trying to redact this doc, - # just ensure no accidental match - "patterns": ["___definitely_not_present___"], - "regex": False, - "case_sensitive": False, - }, - "options": {}, - "scope": {"pages": None}, - } - - resp = client.post( - "/redact/regex", - files={ - "file": ("in.pdf", pdf_in, "application/pdf"), - "payload": (None, json.dumps(payload), "application/json"), - }, + pdf_in = _load_pdf("009_cross_column_phone_trap.pdf") + rects = find_redaction_rectangles_by_regex( + pdf_in, + [PHONE_REGEX], + case_sensitive=False, + multiline=True, ) - - assert resp.status_code == 200, resp.text - # Check occurrences header if your endpoint sets it: - # If header name differs, adapt in second pass with your current code. - occ = resp.headers.get("X-Redaction-Regex-Occurrences") - assert occ is not None - assert int(occ) == 0 + assert len(rects) == 0 diff --git a/backend/tests/test_redact_images_graphics.py b/backend/tests/test_redact_images_graphics.py index 80300cb..50c29ff 100644 --- a/backend/tests/test_redact_images_graphics.py +++ b/backend/tests/test_redact_images_graphics.py @@ -69,7 +69,7 @@ def _post_redact_rectangles( pdf_in: bytes, rects: list[RedactionRect], *, - apply_images: bool, + image_mode: str, apply_graphics: bool ): payload = { @@ -78,10 +78,9 @@ def _post_redact_rectangles( for r in rects ], "options": { - "apply_images": apply_images, + "image_mode": image_mode, "apply_graphics": apply_graphics, }, - # L'audit est obligatoire : on met un pattern improbable qui ne matche pas. "audit": { "patterns": ["__NEVER_MATCH_123456789__"], "regex": False, @@ -93,7 +92,7 @@ def _post_redact_rectangles( "file": ("input.pdf", pdf_in, "application/pdf"), "payload": (None, json.dumps(payload), "application/json"), } - return client.post("/redact/rectangles", files=files) + return client.post("/redact/apply", files=files) @pytest.mark.integration @@ -106,21 +105,21 @@ def test_apply_images_removes_overlapping_images() -> None: _, img_rect = _first_image_rect(pdf_in, page_index=0) rect = RedactionRect(page=0, x0=img_rect.x0, y0=img_rect.y0, x1=img_rect.x1, y1=img_rect.y1) - # Control: images disabled -> should not reduce referenced images + # Control: image_mode="none" -> should not reduce referenced images pdf_keep = redact_pdf_by_rectangles( pdf_in, [rect], - apply_images=False, + image_mode="none", apply_graphics=False, ) after_keep = _count_page_images(pdf_keep, page_index=0) assert after_keep == before - # Now enable image removal -> referenced images should decrease + # image_mode="remove" -> referenced images should decrease pdf_removed = redact_pdf_by_rectangles( pdf_in, [rect], - apply_images=True, + image_mode="remove", apply_graphics=False, ) after_removed = _count_page_images(pdf_removed, page_index=0) @@ -147,7 +146,7 @@ def test_apply_graphics_removes_overlapping_vector_drawings() -> None: pdf_keep = redact_pdf_by_rectangles( pdf_in, [rect], - apply_images=False, + image_mode="none", apply_graphics=False, ) after_keep = _count_drawings(pdf_keep, page_index=0) @@ -157,7 +156,7 @@ def test_apply_graphics_removes_overlapping_vector_drawings() -> None: pdf_removed = redact_pdf_by_rectangles( pdf_in, [rect], - apply_images=False, + image_mode="none", apply_graphics=True, ) after_removed = _count_drawings(pdf_removed, page_index=0) @@ -173,14 +172,18 @@ def test_api_rectangles_propagates_apply_images() -> None: _, img_rect = _first_image_rect(pdf_in, page_index=0) rect = RedactionRect(page=0, x0=img_rect.x0, y0=img_rect.y0, x1=img_rect.x1, y1=img_rect.y1) - # Control: apply_images=False - resp_keep = _post_redact_rectangles(pdf_in, [rect], apply_images=False, apply_graphics=False) + # Control: image_mode="none" + resp_keep = _post_redact_rectangles( + pdf_in, [rect], image_mode="none", apply_graphics=False + ) assert resp_keep.status_code == 200 pdf_keep = resp_keep.content assert _count_page_images(pdf_keep, page_index=0) == before - # Now enable apply_images=True - resp_removed = _post_redact_rectangles(pdf_in, [rect], apply_images=True, apply_graphics=False) + # image_mode="remove" + resp_removed = _post_redact_rectangles( + pdf_in, [rect], image_mode="remove", apply_graphics=False + ) assert resp_removed.status_code == 200 pdf_removed = resp_removed.content assert _count_page_images(pdf_removed, page_index=0) < before @@ -202,15 +205,262 @@ def test_api_rectangles_propagates_apply_graphics() -> None: ) # Control: apply_graphics=False - resp_keep = _post_redact_rectangles(pdf_in, [rect], apply_images=False, apply_graphics=False) + resp_keep = _post_redact_rectangles( + pdf_in, [rect], image_mode="none", apply_graphics=False + ) assert resp_keep.status_code == 200 pdf_keep = resp_keep.content after_keep = _count_drawings(pdf_keep, page_index=0) assert after_keep >= before # peut augmenter (blackout) # Now enable apply_graphics=True - resp_removed = _post_redact_rectangles(pdf_in, [rect], apply_images=False, apply_graphics=True) + resp_removed = _post_redact_rectangles( + pdf_in, [rect], image_mode="none", apply_graphics=True + ) assert resp_removed.status_code == 200 pdf_removed = resp_removed.content after_removed = _count_drawings(pdf_removed, page_index=0) assert after_removed < after_keep + + +def _render_clip(pdf_bytes: bytes, page_index: int, clip: pymupdf.Rect) -> pymupdf.Pixmap: + """ + Render the page region at 3x DPI to avoid sub-pixel anti-aliasing artifacts + at the redaction boundary that show up at 1x. + """ + doc = pymupdf.open(stream=pdf_bytes, filetype="pdf") + try: + page = doc[page_index] + return page.get_pixmap(clip=clip, matrix=pymupdf.Matrix(3.0, 3.0)) + finally: + doc.close() + + +def _is_all_black(pix: pymupdf.Pixmap) -> bool: + n = pix.n + samples = pix.samples + color_channels = n - 1 if pix.alpha else n + for offset in range(0, len(samples), n): + for c in range(color_channels): + if samples[offset + c] != 0: + return False + return True + + +def _has_non_black_pixel(pix: pymupdf.Pixmap) -> bool: + return not _is_all_black(pix) + + +def _shrink(rect: pymupdf.Rect, pad: float = 1.0) -> pymupdf.Rect: + """Inset a rect on all sides; lets us sample interior pixels and dodge edge AA.""" + return pymupdf.Rect(rect.x0 + pad, rect.y0 + pad, rect.x1 - pad, rect.y1 - pad) + + +@pytest.mark.integration +def test_image_mode_pixels_blackens_only_intersected_region() -> None: + """ + image_mode="pixels": only the intersected region of an image must become black. + The rest of the image stays visible (modulo lossy re-encoding). + The image count must NOT decrease (we don't remove the image, just rewrite pixels). + """ + pdf_in = _read_fixture("004_bitmap_image.pdf") + before = _count_page_images(pdf_in, page_index=0) + assert before > 0 + + _, img_rect = _first_image_rect(pdf_in, page_index=0) + + # Cible la moitié gauche de l'image + half_w = (img_rect.x1 - img_rect.x0) / 2 + left_rect = pymupdf.Rect(img_rect.x0, img_rect.y0, img_rect.x0 + half_w, img_rect.y1) + right_rect = pymupdf.Rect(img_rect.x0 + half_w, img_rect.y0, img_rect.x1, img_rect.y1) + + redact = RedactionRect( + page=0, x0=left_rect.x0, y0=left_rect.y0, x1=left_rect.x1, y1=left_rect.y1 + ) + + # Sanity: l'image originale a bien des pixels non-noirs des deux côtés + assert _has_non_black_pixel(_render_clip(pdf_in, 0, _shrink(left_rect))) + assert _has_non_black_pixel(_render_clip(pdf_in, 0, _shrink(right_rect))) + + pdf_out = redact_pdf_by_rectangles( + pdf_in, + [redact], + image_mode="pixels", + apply_graphics=False, + ) + + # L'image n'est PAS retirée du PDF + assert _count_page_images(pdf_out, page_index=0) == before + + # La moitié gauche est entièrement noire ; la droite a encore du contenu. + # On inset les rects pour ignorer les artefacts d'anti-aliasing au bord. + assert _is_all_black(_render_clip(pdf_out, 0, _shrink(left_rect))) + assert _has_non_black_pixel(_render_clip(pdf_out, 0, _shrink(right_rect))) + + +@pytest.mark.integration +def test_image_mode_pixels_via_api() -> None: + """ + End-to-end: /redact/apply with image_mode='pixels' on a PARTIAL image rect. + The image must remain referenced (only pixels are rewritten), and the + redacted portion must render black. + Note: when the redact rect covers 100% of the image, PyMuPDF removes the + image entirely as an optimisation, see test_image_mode_pixels_full_image. + """ + pdf_in = _read_fixture("004_bitmap_image.pdf") + before = _count_page_images(pdf_in, page_index=0) + _, img_rect = _first_image_rect(pdf_in, page_index=0) + + half_w = (img_rect.x1 - img_rect.x0) / 2 + left_rect = pymupdf.Rect(img_rect.x0, img_rect.y0, img_rect.x0 + half_w, img_rect.y1) + rect = RedactionRect( + page=0, x0=left_rect.x0, y0=left_rect.y0, x1=left_rect.x1, y1=left_rect.y1 + ) + + resp = _post_redact_rectangles( + pdf_in, [rect], image_mode="pixels", apply_graphics=False + ) + assert resp.status_code == 200, resp.text + + pdf_out = resp.content + # Image still referenced: pixels mode keeps the image when only partially covered. + assert _count_page_images(pdf_out, page_index=0) == before + + # The redacted half is black ; the kept half still has content. + assert _is_all_black(_render_clip(pdf_out, 0, _shrink(left_rect))) + + +@pytest.mark.integration +def test_image_mode_pixels_full_image_optimised_to_removal() -> None: + """ + When image_mode='pixels' and the rect covers the whole image, PyMuPDF + optimises by dropping the image entirely. The end result is still secure + (no original content remains). This test pins the current behaviour. + """ + pdf_in = _read_fixture("004_bitmap_image.pdf") + before = _count_page_images(pdf_in, page_index=0) + _, img_rect = _first_image_rect(pdf_in, page_index=0) + rect = RedactionRect( + page=0, x0=img_rect.x0, y0=img_rect.y0, x1=img_rect.x1, y1=img_rect.y1 + ) + + pdf_out = redact_pdf_by_rectangles( + pdf_in, [rect], image_mode="pixels", apply_graphics=False + ) + after = _count_page_images(pdf_out, page_index=0) + assert after < before, "PyMuPDF should drop the image when fully covered." + assert _is_all_black(_render_clip(pdf_out, 0, _shrink(img_rect))) + + +@pytest.mark.integration +def test_full_page_rule_forces_strict_even_with_image_mode_none() -> None: + """ + Option D: a full-page rule must wipe the page entirely, regardless of the + user's image_mode. With image_mode='none' on a regular rect, images stay; + with image_mode='none' but the rect sent in `full_page_rects`, the page + is treated as strict (image removed, graphics removed). + """ + pdf_in = _read_fixture("004_bitmap_image.pdf") + before = _count_page_images(pdf_in, page_index=0) + assert before > 0 + + doc = pymupdf.open(stream=pdf_in, filetype="pdf") + bounds = doc[0].rect + doc.close() + full_page = { + "page": 0, + "x0": bounds.x0, + "y0": bounds.y0, + "x1": bounds.x1, + "y1": bounds.y1, + } + + # Send the full-page rect via `full_page_rects`, with image_mode='none'. + # Expected: image is removed because the page-rule forces strict mode. + payload = { + "rects": [], + "full_page_rects": [full_page], + "options": {"image_mode": "none", "apply_graphics": False}, + "audit": { + "patterns": ["__NEVER_MATCH__"], + "regex": False, + "case_sensitive": True, + }, + } + resp = client.post( + "/redact/apply", + files={ + "file": ("input.pdf", pdf_in, "application/pdf"), + "payload": (None, json.dumps(payload), "application/json"), + }, + ) + assert resp.status_code == 200, resp.text + + pdf_out = resp.content + after = _count_page_images(pdf_out, page_index=0) + assert after < before, "Full-page rule should force image removal." + + headers = resp.headers + assert headers.get("X-Redaction-Full-Page-Occurrences") == "1" + + +@pytest.mark.integration +def test_full_page_via_rects_keeps_user_mode() -> None: + """ + Sanity check: the same full-sized rect sent via `rects` (not `full_page_rects`) + keeps the user's image_mode ('none' here), so the image stays. + """ + pdf_in = _read_fixture("004_bitmap_image.pdf") + before = _count_page_images(pdf_in, page_index=0) + assert before > 0 + + doc = pymupdf.open(stream=pdf_in, filetype="pdf") + bounds = doc[0].rect + doc.close() + full_page = { + "page": 0, + "x0": bounds.x0, + "y0": bounds.y0, + "x1": bounds.x1, + "y1": bounds.y1, + } + + payload = { + "rects": [full_page], + "options": {"image_mode": "none", "apply_graphics": False}, + "audit": { + "patterns": ["__NEVER_MATCH__"], + "regex": False, + "case_sensitive": True, + }, + } + resp = client.post( + "/redact/apply", + files={ + "file": ("input.pdf", pdf_in, "application/pdf"), + "payload": (None, json.dumps(payload), "application/json"), + }, + ) + assert resp.status_code == 200, resp.text + + after = _count_page_images(resp.content, page_index=0) + assert after == before, "image_mode='none' on a regular rect must keep the image." + + +@pytest.mark.integration +def test_image_mode_invalid_value_returns_422() -> None: + """image_mode is a Literal; an unknown value must be rejected by the API.""" + pdf_in = _read_fixture("004_bitmap_image.pdf") + payload = { + "rects": [], + "options": {"image_mode": "BOGUS", "apply_graphics": False}, + "audit": None, + } + resp = client.post( + "/redact/apply", + files={ + "file": ("input.pdf", pdf_in, "application/pdf"), + "payload": (None, json.dumps(payload), "application/json"), + }, + ) + assert resp.status_code == 422 diff --git a/backend/tests/test_redact_presets.py b/backend/tests/test_redact_presets.py index a4e989e..d718f81 100644 --- a/backend/tests/test_redact_presets.py +++ b/backend/tests/test_redact_presets.py @@ -18,6 +18,7 @@ CC_DIGITS_VALID = "4111111111111111" CC_DIGITS_INVALID = "4111111111111112" + def _digits_only(s: str) -> str: return re.sub(r"\D+", "", s) @@ -27,10 +28,16 @@ def _load_fixture(name: str) -> bytes: return path.read_bytes() -def _post_presets(pdf_bytes: bytes, payload: dict) -> TestClient.Response: +def _post_apply_presets(pdf_bytes: bytes, *, presets: list[str], audit: dict | None): + payload = { + "rects": [], + "presets": {"presets": presets, "scope": {"pages": None}}, + "options": {"apply_images": False, "apply_graphics": False}, + "audit": audit, + } files = {"file": ("input.pdf", pdf_bytes, "application/pdf")} data = {"payload": json.dumps(payload)} - return client.post("/redact/presets", files=files, data=data) + return client.post("/redact/apply", files=files, data=data) def _extract_first_phone(text: str, region: str = "FR") -> str | None: @@ -54,13 +61,11 @@ def test_presets_email_removes_email_keeps_phone(monkeypatch: pytest.MonkeyPatch phone = _extract_first_phone(text_in, region="FR") assert phone is not None, "Expected a phone number in fixture 002." - payload = { - "presets": ["email"], - "options": {"apply_images": False, "apply_graphics": False}, - "audit": {"patterns": [email], "regex": False, "case_sensitive": True}, - } - - resp = _post_presets(pdf_in, payload) + resp = _post_apply_presets( + pdf_in, + presets=["email"], + audit={"patterns": [email], "regex": False, "case_sensitive": True}, + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Audit-Matches", "999")) == 0 @@ -85,13 +90,11 @@ def test_presets_phone_removes_phone_keeps_email(monkeypatch: pytest.MonkeyPatch phone = _extract_first_phone(text_in, region="FR") assert phone is not None, "Expected a phone number in fixture 002." - payload = { - "presets": ["phone"], - "options": {"apply_images": False, "apply_graphics": False}, - "audit": {"patterns": [phone], "regex": False, "case_sensitive": True}, - } - - resp = _post_presets(pdf_in, payload) + resp = _post_apply_presets( + pdf_in, + presets=["phone"], + audit={"patterns": [phone], "regex": False, "case_sensitive": True}, + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Audit-Matches", "999")) == 0 @@ -111,17 +114,15 @@ def test_presets_credit_card_luhn_filters_invalid() -> None: assert CC_DIGITS_VALID in digits_in assert CC_DIGITS_INVALID in digits_in - payload = { - "presets": ["credit_card"], - "options": {"apply_images": False, "apply_graphics": False}, - "audit": { + resp = _post_apply_presets( + pdf_in, + presets=["credit_card"], + audit={ "patterns": [r"\b4111[ -]*1111[ -]*1111[ -]*1111\b"], "regex": True, "case_sensitive": True, }, - } - - resp = _post_presets(pdf_in, payload) + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Audit-Matches", "999")) == 0 @@ -141,13 +142,11 @@ def test_presets_email_no_false_positive_on_secret_fixture(monkeypatch: pytest.M text_in = extract_text(pdf_in) assert "SECRET_ABC123" in text_in - payload = { - "presets": ["email"], - "options": {"apply_images": False, "apply_graphics": False}, - "audit": {"patterns": ["SHOULD_NOT_EXIST_123"], "regex": False, "case_sensitive": True}, - } - - resp = _post_presets(pdf_in, payload) + resp = _post_apply_presets( + pdf_in, + presets=["email"], + audit={"patterns": ["SHOULD_NOT_EXIST_123"], "regex": False, "case_sensitive": True}, + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Presets-Occurrences", "999")) == 0 diff --git a/backend/tests/test_redact_rectangles.py b/backend/tests/test_redact_rectangles.py index 8528600..55deddc 100644 --- a/backend/tests/test_redact_rectangles.py +++ b/backend/tests/test_redact_rectangles.py @@ -19,12 +19,10 @@ def pick_non_secret_token(text: str) -> str: - # Cherche un mot "simple" qui n'est pas le secret, pour vérifier qu'on n'a pas cassé le reste. words = re.findall(r"[A-Za-z]{4,}", text) for w in words: if "SECRET" not in w: return w - # Fallback : au pire, on vérifie qu'il reste quelque chose. return "Fixture" @@ -48,6 +46,14 @@ def make_payload(rects: list[dict], patterns: list[str]) -> dict: } +def _post_apply(pdf_bytes: bytes, payload: dict): + return CLIENT.post( + "/redact/apply", + files={"file": ("input.pdf", pdf_bytes, "application/pdf")}, + data={"payload": json.dumps(payload)}, + ) + + @pytest.mark.integration def test_redact_rectangles_removes_secret_and_preserves_other_text() -> None: pdf_in = SECRET_FIXTURE.read_bytes() @@ -59,15 +65,10 @@ def test_redact_rectangles_removes_secret_and_preserves_other_text() -> None: rect = find_secret_rect_with_pymupdf(pdf_in) payload = make_payload([rect], patterns=[SECRET]) - resp = CLIENT.post( - "/redact/rectangles", - files={"file": ("input.pdf", pdf_in, "application/pdf")}, - data={"payload": json.dumps(payload)}, - ) + resp = _post_apply(pdf_in, payload) assert resp.status_code == 200 assert resp.headers["content-type"].startswith("application/pdf") - # Audit OK attendu assert resp.headers.get("x-redaction-audit-status") == "pass" assert resp.headers.get("x-redaction-audit-matches") == "0" @@ -82,51 +83,71 @@ def test_redact_rectangles_removes_secret_and_preserves_other_text() -> None: def test_redact_rectangles_wrong_rect_triggers_audit_fail() -> None: pdf_in = SECRET_FIXTURE.read_bytes() - # Rectangle volontairement à côté (décalage horizontal) rect = find_secret_rect_with_pymupdf(pdf_in) rect["x0"] += 200 rect["x1"] += 200 payload = make_payload([rect], patterns=[SECRET]) - resp = CLIENT.post( - "/redact/rectangles", - files={"file": ("input.pdf", pdf_in, "application/pdf")}, - data={"payload": json.dumps(payload)}, - ) + resp = _post_apply(pdf_in, payload) - # Avec l'audit, ce cas doit échouer : on refuse de livrer un PDF "censuré" si fuite assert resp.status_code == 400 assert resp.headers["content-type"].startswith("application/json") report = resp.json() assert report["status"] == "fail" - assert report["total_matches"] >= 1 - assert SECRET in report["patterns"] + audit_report = report["components"]["audit"] + assert audit_report["total_matches"] >= 1 + assert SECRET in audit_report["patterns"] - # Le secret doit apparaître dans au moins un match (champ "match" ou "snippet") assert any( (SECRET in m.get("match", "")) or (SECRET in m.get("snippet", "")) - for m in report.get("matches", []) + for m in audit_report.get("matches", []) ), "Audit report should include evidence that the secret remains" @pytest.mark.integration def test_redaction_does_not_modify_original_fixture_on_disk() -> None: - # Non-régression : l'original sur disque ne doit jamais être modifié before_bytes = SECRET_FIXTURE.read_bytes() assert SECRET in extract_text(before_bytes) rect = find_secret_rect_with_pymupdf(before_bytes) payload = make_payload([rect], patterns=[SECRET]) - resp = CLIENT.post( - "/redact/rectangles", - files={"file": ("input.pdf", before_bytes, "application/pdf")}, - data={"payload": json.dumps(payload)}, - ) + resp = _post_apply(before_bytes, payload) assert resp.status_code == 200 after_bytes = SECRET_FIXTURE.read_bytes() assert before_bytes == after_bytes, "Fixture PDF on disk must remain byte-identical" assert SECRET in extract_text(after_bytes), "Original must still contain the secret" + + +@pytest.mark.integration +def test_redact_rectangles_full_page_rect_redacts_target() -> None: + pdf_in = SECRET_FIXTURE.read_bytes() + original_text = extract_text(pdf_in) + assert SECRET in original_text + + doc = pymupdf.open(stream=pdf_in, filetype="pdf") + try: + page = doc[0] + bounds = page.rect + full_page_rect = { + "page": 0, + "x0": bounds.x0, + "y0": bounds.y0, + "x1": bounds.x1, + "y1": bounds.y1, + } + finally: + doc.close() + + payload = make_payload([full_page_rect], patterns=[SECRET]) + + resp = _post_apply(pdf_in, payload) + + assert resp.status_code == 200 + assert resp.headers.get("x-redaction-audit-status") == "pass" + + out_text = extract_text(resp.content) + assert SECRET not in out_text diff --git a/backend/tests/test_redact_regex.py b/backend/tests/test_redact_regex.py index f256131..4373c45 100644 --- a/backend/tests/test_redact_regex.py +++ b/backend/tests/test_redact_regex.py @@ -21,10 +21,23 @@ def _load_fixture(name: str) -> bytes: return path.read_bytes() -def _post_regex(pdf_bytes: bytes, payload: dict) -> TestClient.Response: +def _post_apply_regex(pdf_bytes: bytes, *, patterns: list[str], case_sensitive: bool, audit: dict): + payload = { + "rects": [], + "regexes": [ + { + "patterns": patterns, + "case_sensitive": case_sensitive, + "multiline": False, + "scope": {"pages": None}, + } + ], + "options": {"apply_images": False, "apply_graphics": False}, + "audit": audit, + } files = {"file": ("input.pdf", pdf_bytes, "application/pdf")} data = {"payload": json.dumps(payload)} - return client.post("/redact/regex", files=files, data=data) + return client.post("/redact/apply", files=files, data=data) @pytest.mark.integration @@ -42,19 +55,15 @@ def test_regex_email_removes_email_keeps_phone() -> None: email_pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b" - payload = { - "patterns": [email_pattern], - "case_sensitive": False, - "scope": {"pages": None}, - "options": {"apply_images": False, "apply_graphics": False}, - "audit": {"patterns": [email_pattern], "regex": True, "case_sensitive": False}, - } - - resp = _post_regex(pdf_in, payload) + resp = _post_apply_regex( + pdf_in, + patterns=[email_pattern], + case_sensitive=False, + audit={"patterns": [email_pattern], "regex": True, "case_sensitive": False}, + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Audit-Matches", "999")) == 0 - # If you add this header in the endpoint, enforce it: assert int(resp.headers.get("X-Redaction-Regex-Occurrences", "0")) > 0 text_out = extract_text(resp.content) @@ -77,15 +86,12 @@ def test_regex_phone_removes_phone_keeps_email() -> None: phone_pattern = r"(?:\+?\d[\d\s().-]{6,}\d)" - payload = { - "patterns": [phone_pattern], - "case_sensitive": False, - "scope": {"pages": None}, - "options": {"apply_images": False, "apply_graphics": False}, - "audit": {"patterns": [phone_pattern], "regex": True, "case_sensitive": False}, - } - - resp = _post_regex(pdf_in, payload) + resp = _post_apply_regex( + pdf_in, + patterns=[phone_pattern], + case_sensitive=False, + audit={"patterns": [phone_pattern], "regex": True, "case_sensitive": False}, + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Audit-Matches", "999")) == 0 @@ -100,20 +106,15 @@ def test_regex_phone_removes_phone_keeps_email() -> None: def test_regex_invalid_pattern_returns_400() -> None: pdf_in = _load_fixture("002_email_phone_one_line.pdf") - payload = { - "patterns": ["["], # invalid regex - "case_sensitive": False, - "scope": {"pages": None}, - "options": {"apply_images": False, "apply_graphics": False}, - # audit must still be present per contract; it won't run if regex parse fails - "audit": {"patterns": ["SHOULD_NOT_EXIST_123"], "regex": False, "case_sensitive": True}, - } - - resp = _post_regex(pdf_in, payload) + resp = _post_apply_regex( + pdf_in, + patterns=["["], # invalid regex + case_sensitive=False, + audit={"patterns": ["SHOULD_NOT_EXIST_123"], "regex": False, "case_sensitive": True}, + ) assert resp.status_code == 400 body = resp.json() - # Be tolerant to your error schema (detail vs error) - assert "detail" in body or "error" in body + assert "detail" in body or "error" in body or "status" in body @pytest.mark.integration @@ -123,23 +124,18 @@ def test_regex_no_match_occurrences_zero_and_content_preserved() -> None: no_match_pattern = r"THIS_WILL_NOT_MATCH_123456" - payload = { - "patterns": [no_match_pattern], - "case_sensitive": False, - "scope": {"pages": None}, - "options": {"apply_images": False, "apply_graphics": False}, - "audit": {"patterns": [no_match_pattern], "regex": True, "case_sensitive": False}, - } - - resp = _post_regex(pdf_in, payload) + resp = _post_apply_regex( + pdf_in, + patterns=[no_match_pattern], + case_sensitive=False, + audit={"patterns": [no_match_pattern], "regex": True, "case_sensitive": False}, + ) assert resp.status_code == 200, resp.text assert resp.headers.get("X-Redaction-Audit-Status") == "pass" assert int(resp.headers.get("X-Redaction-Audit-Matches", "999")) == 0 - # occurrences header should be 0 if you implement it if "X-Redaction-Regex-Occurrences" in resp.headers: assert int(resp.headers["X-Redaction-Regex-Occurrences"]) == 0 text_out = extract_text(resp.content) - # Byte-identical PDF is not guaranteed; text preservation is the meaningful invariant here. assert text_out == text_in diff --git a/backend/tests/test_redact_search.py b/backend/tests/test_redact_search.py index 5800cfa..89d708b 100644 --- a/backend/tests/test_redact_search.py +++ b/backend/tests/test_redact_search.py @@ -13,6 +13,27 @@ FIXTURES_DIR = Path(__file__).parent / "fixtures" / "generated" +def _post_apply_search(pdf_bytes: bytes, *, query: str, options: dict, audit: dict): + payload = { + "rects": [], + "searches": [ + { + "query": query, + "options": options, + "scope": {"pages": None}, + } + ], + "options": {}, + "audit": audit, + } + client = TestClient(app) + return client.post( + "/redact/apply", + files={"file": ("input.pdf", pdf_bytes, "application/pdf")}, + data={"payload": json.dumps(payload)}, + ) + + @pytest.mark.integration def test_redact_search_exact_email() -> None: pdf_path = FIXTURES_DIR / "002_email_phone_one_line.pdf" @@ -23,18 +44,11 @@ def test_redact_search_exact_email() -> None: assert m, "No email found in fixture 002_email_phone_one_line.pdf" email = m.group(0) - payload = { - "query": email, - "options": {"case_sensitive": True, "whole_word": True}, - "scope": {"pages": None}, - "audit": {"patterns": [email], "regex": False, "case_sensitive": True}, - } - - client = TestClient(app) - resp = client.post( - "/redact/search", - files={"file": ("input.pdf", pdf_bytes, "application/pdf")}, - data={"payload": json.dumps(payload)}, + resp = _post_apply_search( + pdf_bytes, + query=email, + options={"case_sensitive": True, "whole_word": True}, + audit={"patterns": [email], "regex": False, "case_sensitive": True}, ) assert resp.status_code == 200 @@ -52,7 +66,6 @@ def test_whole_word_does_not_match_substring_in_word() -> None: - query="CAT" with whole_word=True should NOT match "CATCH". - Fixture must contain both "CAT" and "CATCH", and only the standalone CAT should be redacted. - - Audit uses regex with word boundaries to avoid failing due to "CAT" inside "CATCH". """ pdf_path = FIXTURES_DIR / "007_whole_word_cat_catch.pdf" pdf_bytes = pdf_path.read_bytes() @@ -61,18 +74,11 @@ def test_whole_word_does_not_match_substring_in_word() -> None: assert "CAT" in original_text assert "CATCH" in original_text - payload = { - "query": "CAT", - "options": {"case_sensitive": True, "whole_word": True}, - "scope": {"pages": None}, - "audit": {"patterns": [r"\bCAT\b"], "regex": True, "case_sensitive": True}, - } - - client = TestClient(app) - resp = client.post( - "/redact/search", - files={"file": ("input.pdf", pdf_bytes, "application/pdf")}, - data={"payload": json.dumps(payload)}, + resp = _post_apply_search( + pdf_bytes, + query="CAT", + options={"case_sensitive": True, "whole_word": True}, + audit={"patterns": [r"\bCAT\b"], "regex": True, "case_sensitive": True}, ) assert resp.status_code == 200 @@ -89,10 +95,8 @@ def test_whole_word_does_not_match_substring_in_word() -> None: def test_whole_word_handles_punctuation_and_email_subtoken() -> None: """ Regression coverage for whole_word=True with boundary-aware regex: - - Must match 'DUPONT,' (punctuation boundary) - Must also match 'dupont' inside an email local-part ('.' and '@' are boundaries) - - After redaction, no whole-word 'dupont' should remain (case-insensitive) """ pdf_path = FIXTURES_DIR / "011_apply_search_and_email.pdf" pdf_bytes = pdf_path.read_bytes() @@ -101,23 +105,15 @@ def test_whole_word_handles_punctuation_and_email_subtoken() -> None: assert "DUPONT" in original_text assert "alice.dupont@example.com" in original_text - payload = { - "query": "DUPONT", - "options": {"case_sensitive": False, "whole_word": True}, - "scope": {"pages": None}, - # Audit cohérent avec whole_word=True : regex frontières basées sur \w - "audit": { + resp = _post_apply_search( + pdf_bytes, + query="DUPONT", + options={"case_sensitive": False, "whole_word": True}, + audit={ "patterns": [r"(? None: redacted_text = extract_text(resp.content) - # Aucun token whole-word restant assert re.search(r"(?i)\bdupont\b", redacted_text) is None - - # Non-régression : du texte non sensible doit rester assert "Texte non sensible" in redacted_text diff --git a/backend/tests/test_sanitize.py b/backend/tests/test_sanitize.py index 649e5fe..2f1b38b 100644 --- a/backend/tests/test_sanitize.py +++ b/backend/tests/test_sanitize.py @@ -70,10 +70,15 @@ def test_sanitize_metadata_and_annotations_via_regex_endpoint() -> None: client = TestClient(app) payload = { - "patterns": [r"THIS_WILL_NOT_MATCH_123456789"], - "case_sensitive": True, - "multiline": False, - "scope": {"pages": None}, + "rects": [], + "regexes": [ + { + "patterns": [r"THIS_WILL_NOT_MATCH_123456789"], + "case_sensitive": True, + "multiline": False, + "scope": {"pages": None}, + } + ], "options": { "apply_images": False, "apply_graphics": False, @@ -94,7 +99,7 @@ def test_sanitize_metadata_and_annotations_via_regex_endpoint() -> None: "payload": (None, json.dumps(payload), "application/json"), } - resp = client.post("/redact/regex", files=files) + resp = client.post("/redact/apply", files=files) assert resp.status_code == 200 assert resp.headers.get("content-type", "").startswith("application/pdf") diff --git a/docs/SECURITY.md b/docs/SECURITY.md index de634b6..52baae8 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,29 +1,164 @@ -# Security Model (Draft) +# Security Model ## Scope -This project provides a local PDF redaction tool intended to permanently remove sensitive content from PDF files while preserving normal selection/search for non-redacted text. + +RedactPDF is a local PDF redaction tool intended to produce a **new exported PDF** where targeted sensitive content is no longer recoverable through standard extraction paths. ## Security Invariants (Non-negotiable) -1. **Never modify the original file** - The tool must always export a new PDF and must not overwrite the input. -2. **No visual-only masking** - Overlays/black rectangles that merely cover content are not acceptable. Redaction must remove underlying data. +1. **Never modify the original file** + - Input PDF must remain untouched. +2. **No fake success** + - If post-export audit fails, export must be blocked. +3. **Prefer real removal over visual masking** + - Visual black overlays alone are not sufficient for sensitive workflows. +4. **Operator preview before apply** + - UI preview is required to reduce human targeting mistakes. + +## Current Guarantees (high level) + +- Export is done as a new file. +- Redaction is followed by automated audit checks. +- Backend supports three image-redaction modes (`none`, `remove`, `pixels`) + + vector-graphics removal + metadata/annotation/attachment sanitation. +- The `pixels` image mode rewrites the bitmap of the targeted region (and + saves with `garbage=4`, removing the orphaned original stream), so it + works on **flattened / scanned PDFs** where the whole page is one image. + +## Image redaction modes + +The UI exposes three modes (FR labels in parentheses): + +| Mode | Bitmap images | Vector graphics | When to use | +|------------------|---------------------------------------------------------------------|-------------------------------|---------------------------------------------------------------------------| +| `none` (UI : Aucune / None) | Untouched. A vector black overlay is drawn on top, visually hidden but the underlying pixels remain in the PDF. | Untouched (same caveat). | Text-only redaction; you don't care about images. | +| `remove` (UI : Totale / Full)| Whole image is dropped from the PDF. | All touched paths removed. | Strict policy: anything touched is gone. | +| `pixels` (UI : Précise / Precise) - **default** | Intersected pixels blackened in the bitmap. The rest of the image stays visible. | All touched paths removed. | Default. The only mode that makes flattened / scanned PDFs redactable. | + +The image mode also controls vector graphics: `none` keeps them, `pixels` +and `remove` both delete any path touching a redaction rectangle (per-path, +not pixel-perfect, see Limitations below). + +Caveat for `pixels`: the modified image is decoded and re-encoded. For +JPEG-based images this re-encoding is **lossy**, pixels outside the +redacted region are not byte-identical to the original (visually +indistinguishable). If your threat model cares about cryptographic hashes +of image bytes, this is worth knowing. + +### Full-page rule overrides image mode + +When the user clicks "Censurer la page" (full-page rule), that page is +**always** processed in strict mode (`remove` + graphics removal), +regardless of the global image mode. A page-wide rule is meant to wipe the +page completely; if the user picked `Aucune` on top of it, the strict +override prevents a fake-redaction trap (where images and graphics would +otherwise survive under the black overlay). + +Other rules (manual rectangles, selections, search/regex/preset hits) +continue to follow the user's chosen image mode on the same page. + +## Important Limitations + +1. **OCR completeness** + - Not guaranteed for all scan/layout conditions. Text inside an image is + not seen by the search/regex/preset rules; only manual rectangles or + full-page redaction will reliably hide it. Use the `pixels` image mode + (or `remove`) so the bitmap actually loses the targeted content. +2. **Vector graphics are not pixel-redacted** + - When a redaction rectangle partially covers a vector path, modes + `pixels` and `remove` delete the **whole path**, not just the + intersected portion. Pixel-perfect partial redaction would require + rasterising the affected vector area, which the project does not do. +3. **Cross-column multiline regex** + - The geometric engine deliberately refuses to fuse lines across + columns. The text-based audit, however, runs on a flattened page text + and may match across columns. Multiline regex on multi-column PDFs + can therefore produce a 400 audit-fail even when nothing was + legitimately to redact. + +## Operational Recommendations + +For sensitive usage, prefer strict settings: + +- choose image mode `remove` or `pixels` (never `none`) for any document + containing images or vector graphics that overlap your redaction zones, +- sanitize metadata, remove annotations, remove attachments, these are + ON by default in the UI but can be turned off via the API, +- verify audit output before sharing exported files. + +When in doubt about a particular page, "Censurer la page" guarantees a +full strict wipe of that page (see "Full-page rule overrides image mode" +above), even if the global image mode is `Aucune`. + +## Production / Multi-user Deployment + +RedactPDF is designed for **local, single-user usage**. The HTTP API has no +authentication, no rate limiting, no upload size cap, and no regex execution +timeout. Exposing it to untrusted networks or multiple users without +hardening is **not safe**. + +If you deploy it behind a reverse proxy (nginx, Caddy, Traefik...) for +multiple users, address the following at the **infrastructure layer**, not +in the application code: + +### Body size + +Reject oversized PDFs before they reach the worker, otherwise a single +upload can OOM the process (the entire PDF is loaded in memory by +PyMuPDF, streaming is not possible). + +- nginx: `client_max_body_size 50m;` +- Caddy: `request_body { max_size 50MB }` + +### Rate limiting + +Each redaction request runs PyMuPDF + audit on the full PDF. A trivial loop +can saturate CPU. + +- nginx: `limit_req_zone $binary_remote_addr zone=redact:10m rate=2r/s;` +- Caddy: rate-limit plugin or Cloudflare in front. + +### ReDoS (regex denial of service) + +The `/redact/apply` endpoint accepts user-supplied regex patterns and +compiles them with Python's `re` module **without timeout**. A malicious +pattern (catastrophic backtracking, e.g. `(a+)+$`) can hang a worker +indefinitely. + +Mitigations: + +- Set a strict `proxy_read_timeout` on the reverse proxy (e.g. 30s) so the + client connection drops, but the worker can still be wedged. Combine with + a process supervisor that recycles stuck workers. +- Or run the backend in a sandboxed container with strict CPU/memory + limits and an external watchdog. +- Or switch the regex engine to one that supports timeouts (e.g. + `regex` package with `re.TIMEOUT`, or `re2`). + +### Authentication + +There is none. Any request to the backend is processed. Add auth at the +proxy layer (basic auth, OAuth2 proxy, Cloudflare Access, Tailscale, …). + +### Container isolation + +If running in production, containerize and apply quotas: + +- CPU: e.g. `--cpus=2` +- Memory: e.g. `--memory=2g` +- No host filesystem access (PDFs are processed in-memory). + +### Scope of these recommendations -3. **Post-export verification is required** - The tool must run an automated audit after redaction (at minimum, text extraction + pattern checking). - If a forbidden pattern is still found, the tool must fail clearly and must not produce a “successful” output. +These items are **not** implemented in the application and will not be. +RedactPDF stays small and focused on its redaction job; operating it safely +in a multi-user setting is the responsibility of the deployer. -4. **Preview before apply (UI requirement)** - The UI must provide a preview of the redaction areas before applying changes (to reduce operator errors). +## Reporting Security Issues -## Out of Scope / Limitations (for now) -- Perfect detection of sensitive data in all PDFs is not guaranteed. -- OCR on scanned PDFs is not in scope initially. -- Multi-line / complex-layout matching will be improved iteratively and must be covered by tests. +Please open a security issue with: -## Reporting -If you believe you found a redaction bypass or data leakage issue, please open a security issue with: -- a minimal reproduction PDF (if shareable), -- steps to reproduce, -- expected vs actual behavior. +- minimal reproduction document (if shareable), +- exact steps, +- expected vs actual behavior, +- platform/runtime info. diff --git a/docs/TESTING.md b/docs/TESTING.md deleted file mode 100644 index d237292..0000000 --- a/docs/TESTING.md +++ /dev/null @@ -1,53 +0,0 @@ -# Testing - -This project relies on automated tests and deterministic PDF fixtures to guarantee -that redaction is verifiable and does not silently regress. - -## Test suites - -Tests are organized using pytest markers: - -- `unit`: pure unit tests (fast) -- `integration`: tests involving PDF processing and the API -- `e2e`: end-to-end tests (API + UI) (planned) -- `slow`: long-running tests - -## Run tests locally - -From the `backend/` directory: - -Run everything: -```bash -pytest -``` - -Run unit tests only: -```bash -pytest -m unit -``` - -Run integration tests only: -```bash -pytest -m integration -``` - -Exclude integration tests: -```bash -pytest -m "not integration" -``` - -## Lint -From the `backend/` directory: -```bash -ruff check . -``` - -## Fixtures -PDF fixtures are deterministic and versioned. - -See: -- [backend/tests/fixtures/README.md](../tests/fixtures/README.md) - -Key rule: -- if you change `generate_fixtures.py`, regenerate fixtures and commit the updated PDFs -- the test suite enforces this rule \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 072a57e..f1c4c09 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,9 @@
- + -