| """Interim content scan for a quarantined upload (ADR-0011 β AWS-independent). |
| |
| The ADR's malware scan is deferred to AWS ("the optional malware scan runs on |
| the staged object *before* validation"). Until then, a fully local scan stands |
| in so the "content scan" box is checked honestly rather than left empty. It runs |
| between :func:`stage_upload` and :func:`validate_upload` and has two layers: |
| |
| 1. **Structural magic-byte check (always on, zero dependency).** The door gate |
| only trusts the *filename suffix*; this opens the first bytes and confirms the |
| content matches. An ``.h5ad`` must be an HDF5 container; a ``.gz`` must be |
| gzip; a flat-text matrix must contain no executable/archive/pickle magic, no |
| shebang, and no NUL bytes. This catches the obvious attack the suffix gate |
| misses β a renamed ELF/Mach-O/PE/ZIP/pickle wearing a ``.csv`` extension. |
| 2. **Optional anti-virus pass (ClamAV, if present).** If ``clamdscan``/``clamscan`` |
| is on PATH (or ``UPLOAD_SCAN_CMD`` names a scanner), the staged object is run |
| through it. This is an external AV *reading* the file β the file is still |
| never executed or interpreted here (no ``exec``/``eval``/``pickle``). |
| |
| Honesty rule (ADR Consequences): when no AV is available the scan is recorded as |
| ``skipped`` with a caveat, never silently reported as "malware-scanned". Set |
| ``UPLOAD_SCAN_REQUIRED=1`` to fail closed instead (reject when no AV is present). |
| A structural-magic failure is *always* a hard stop regardless of that flag. |
| |
| Which of those two the *deployment* actually gets is no longer implicit in |
| whatever is on the host's PATH: it is declared in ``deploy/scan_posture.yaml`` |
| and read via :mod:`src.uploads.posture`. Every record is stamped with the |
| posture it was scanned under, so a ``clean`` produced by a dev laptop's ClamAV |
| is distinguishable from coverage the deployment guarantees. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import shlex |
| import shutil |
| import subprocess |
| from datetime import datetime |
| from pathlib import Path |
| from typing import Any |
|
|
| from src.uploads.posture import ( |
| POSTURE_STRUCTURAL_ONLY, |
| av_required, |
| declared_posture, |
| ) |
| from src.uploads.records import ( |
| STATUS_QUARANTINED, |
| UploadRecord, |
| persist_record, |
| ) |
|
|
| |
| SCAN_CLEAN = "clean" |
| SCAN_INFECTED = "infected" |
| SCAN_SKIPPED = "skipped" |
|
|
| |
| |
| |
|
|
| |
| _HDF5_MAGIC = b"\x89HDF\r\n\x1a\n" |
| _GZIP_MAGIC = b"\x1f\x8b" |
|
|
| |
| |
| |
| _FORBIDDEN_TEXT_LEADERS: tuple[bytes, ...] = ( |
| b"\x7fELF", |
| b"MZ", |
| b"\xfe\xed\xfa\xce", |
| b"\xfe\xed\xfa\xcf", |
| b"\xce\xfa\xed\xfe", |
| b"\xcf\xfa\xed\xfe", |
| b"\xca\xfe\xba\xbe", |
| b"PK\x03\x04", |
| b"PK\x05\x06", |
| b"\x80\x04", |
| b"\x80\x05", |
| b"#!", |
| ) |
|
|
| |
| _SNIFF_BYTES = 65536 |
|
|
|
|
| class ScanError(Exception): |
| """Raised only when the record is not in a scannable state.""" |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _scan_required() -> bool: |
| """Fail closed when no clean AV result? Env knob OR the declared posture.""" |
| return av_required() |
|
|
|
|
| def _resolve_av_commands(staged_path: str) -> list[list[str]]: |
| """Return AV scanner argvs to try, in preference order (may be empty). |
| |
| ``UPLOAD_SCAN_CMD`` (a shell-style template, ``{path}`` substituted) takes |
| precedence and is the *only* candidate when set β an operator's explicit |
| choice is honoured verbatim, no fallback. Otherwise auto-detect ClamAV and |
| return both the daemon client (``clamdscan``, fast when ``clamd`` is warm) |
| AND the standalone binary (``clamscan``, works with no daemon). ``_run_av`` |
| tries them in order, so a missing/misconfigured daemon degrades to a |
| standalone scan instead of being reported as unscanned. |
| """ |
| override = os.environ.get("UPLOAD_SCAN_CMD", "").strip() |
| if override: |
| return [[tok.replace("{path}", staged_path) for tok in shlex.split(override)]] |
| candidates: list[list[str]] = [] |
| if shutil.which("clamdscan"): |
| candidates.append(["clamdscan", "--no-summary", "--fdpass", staged_path]) |
| if shutil.which("clamscan"): |
| candidates.append(["clamscan", "--no-summary", staged_path]) |
| return candidates |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _suffix_kind(name: str) -> str: |
| """Return 'h5ad' | 'gzip' | 'xlsx' | 'text' for a staged filename.""" |
| low = name.lower() |
| if low.endswith(".h5ad"): |
| return "h5ad" |
| if low.endswith(".xlsx"): |
| return "xlsx" |
| if low.endswith(".gz"): |
| return "gzip" |
| return "text" |
|
|
|
|
| def _xlsx_structural_check(path: Path, head: bytes) -> dict[str, Any]: |
| """An ``.xlsx`` must be a real OOXML workbook, not just any zip. |
| |
| A bare ``PK`` check would pass a jar, a docx, or an ODS β anything zipped. |
| So after the magic bytes we open the container (read-only, no extraction) |
| and require the two entries every xlsx has: ``[Content_Types].xml`` and a |
| ``xl/`` part. Nothing is decompressed to disk and no macro part is ever |
| evaluated; a workbook carrying ``xl/vbaProject.bin`` is rejected outright, |
| since a metadata sheet has no business shipping macros. |
| """ |
| import zipfile |
|
|
| if not head.startswith((b"PK\x03\x04", b"PK\x05\x06")): |
| return { |
| "ok": False, |
| "reason": "declared .xlsx but the file is not a zip container " |
| "(missing PK magic bytes).", |
| } |
| try: |
| with zipfile.ZipFile(path) as zf: |
| names = zf.namelist() |
| except zipfile.BadZipFile as exc: |
| return {"ok": False, "reason": f"declared .xlsx but the zip container is corrupt: {exc}"} |
| if "[Content_Types].xml" not in names or not any(n.startswith("xl/") for n in names): |
| return { |
| "ok": False, |
| "reason": "declared .xlsx but the zip is not an OOXML workbook " |
| "(no [Content_Types].xml / xl/ parts) β a renamed archive.", |
| } |
| macros = [n for n in names if n.lower().endswith("vbaproject.bin")] |
| if macros: |
| return { |
| "ok": False, |
| "reason": "the workbook contains a VBA macro project " |
| f"({macros[0]}); macro-bearing workbooks are refused.", |
| } |
| return {"ok": True, "reason": "OOXML workbook verified (no macro project)."} |
|
|
|
|
| def structural_check(staged_path: str) -> dict[str, Any]: |
| """Confirm the file's leading bytes match the type its suffix claims. |
| |
| Returns ``{"ok": bool, "reason": str}``. ``ok=False`` is a hard rejection β |
| the content contradicts the declared type (e.g. an executable renamed |
| ``.csv``), which no legitimate expression matrix does. |
| """ |
| path = Path(staged_path) |
| with open(path, "rb") as fh: |
| head = fh.read(_SNIFF_BYTES) |
|
|
| kind = _suffix_kind(path.name) |
|
|
| if kind == "h5ad": |
| if not head.startswith(_HDF5_MAGIC): |
| return { |
| "ok": False, |
| "reason": "declared .h5ad but the file is not an HDF5 container " |
| "(missing HDF5 magic bytes).", |
| } |
| return {"ok": True, "reason": "HDF5 container header verified."} |
|
|
| if kind == "xlsx": |
| return _xlsx_structural_check(path, head) |
|
|
| if kind == "gzip": |
| if not head.startswith(_GZIP_MAGIC): |
| return { |
| "ok": False, |
| "reason": "declared .gz but the file is not gzip-compressed " |
| "(missing gzip magic bytes).", |
| } |
| return {"ok": True, "reason": "gzip header verified."} |
|
|
| |
| for leader in _FORBIDDEN_TEXT_LEADERS: |
| if head.startswith(leader): |
| return { |
| "ok": False, |
| "reason": "declared a text matrix but the file begins with a " |
| f"binary/executable/archive signature ({leader!r}).", |
| } |
| if b"\x00" in head: |
| return { |
| "ok": False, |
| "reason": "declared a text matrix but contains NUL bytes β it is " |
| "binary content, not a delimited text file.", |
| } |
| return {"ok": True, "reason": "text matrix β no binary/executable signature."} |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _run_one_av(argv: list[str]) -> dict[str, Any]: |
| """Run a single AV scanner argv. ``result`` β {clean, infected, error}. |
| |
| ClamAV exit codes: 0 = clean, 1 = virus found, 2 = error. The subprocess |
| only *reads* the staged file; nothing in it is executed. |
| """ |
| try: |
| proc = subprocess.run( |
| argv, |
| capture_output=True, |
| text=True, |
| timeout=int(os.environ.get("UPLOAD_SCAN_TIMEOUT", "600")), |
| check=False, |
| ) |
| except (OSError, subprocess.SubprocessError) as exc: |
| return {"result": "error", "detail": f"scanner failed to run: {exc}"} |
|
|
| tool = Path(argv[0]).name |
| if proc.returncode == 0: |
| return {"result": "clean", "detail": f"{tool}: clean."} |
| if proc.returncode == 1: |
| signature = (proc.stdout or proc.stderr or "").strip().splitlines() |
| hit = signature[0] if signature else "malware signature match" |
| return {"result": "infected", "detail": f"{tool} flagged the file: {hit}"} |
| return { |
| "result": "error", |
| "detail": f"{tool} exited {proc.returncode}: " |
| f"{(proc.stderr or proc.stdout or '').strip()[:200]}", |
| } |
|
|
|
|
| def _run_av(staged_path: str) -> dict[str, Any]: |
| """Run the available AV scanner(s) in preference order. |
| |
| A definitive result (clean or infected) from any candidate is returned |
| immediately. If a candidate errors (e.g. ``clamdscan`` with no running |
| daemon), the next candidate is tried, so a standalone ``clamscan`` still |
| covers the file. Returns ``unavailable`` when nothing is installed, or the |
| last error if every candidate errored. |
| """ |
| candidates = _resolve_av_commands(staged_path) |
| if not candidates: |
| return {"result": "unavailable", "detail": "no anti-virus scanner on PATH."} |
| last = {"result": "unavailable", "detail": "no anti-virus scanner on PATH."} |
| for argv in candidates: |
| last = _run_one_av(argv) |
| if last["result"] in {"clean", "infected"}: |
| return last |
| return last |
|
|
|
|
| |
| |
| |
|
|
|
|
| def scan_upload(record: UploadRecord) -> tuple[UploadRecord, dict[str, Any]]: |
| """Content-scan a quarantined upload before it may be validated. |
| |
| Sets ``record.scan_status`` to one of ``clean`` / ``infected`` / ``skipped``, |
| stamps ``record.scanned_at``, and persists. The record's lifecycle |
| ``status`` is left ``quarantined`` in every case β a scan does not promote an |
| upload; it only decides whether validation may proceed: |
| |
| - **clean** β magic-byte check passed and (if an AV was present) it reported |
| clean. Validation may proceed. |
| - **skipped** β magic-byte check passed but no AV was available. A caveat is |
| recorded; validation may proceed unless ``UPLOAD_SCAN_REQUIRED`` is set, |
| in which case this is treated as ``infected`` (fail closed). |
| - **infected** β the structural magic-byte check failed (always a hard stop), |
| or the AV flagged the file. ``record.errors`` explains; validation refuses. |
| """ |
| if record.status != STATUS_QUARANTINED: |
| raise ScanError(f"Upload is '{record.status}', not 'quarantined' β nothing to scan.") |
| if not record.staged_path or not Path(record.staged_path).is_file(): |
| record.scan_status = SCAN_INFECTED |
| record.scanned_at = datetime.now().isoformat(timespec="seconds") |
| record.scan_detail = f"staged file missing: {record.staged_path}" |
| record.errors = [record.scan_detail] |
| persist_record(record) |
| return record, {"status": "error", "errors": record.errors} |
|
|
| record.scanned_at = datetime.now().isoformat(timespec="seconds") |
| posture = declared_posture() |
| record.scan_posture = posture |
|
|
| |
| struct = structural_check(record.staged_path) |
| if not struct["ok"]: |
| record.scan_status = SCAN_INFECTED |
| record.scan_detail = f"structural check: {struct['reason']}" |
| record.errors = ["Content scan rejected the upload β " + struct["reason"]] |
| persist_record(record) |
| return record, {"status": "error", "errors": record.errors, "scan": struct} |
|
|
| |
| av = _run_av(record.staged_path) |
| if av["result"] == "infected": |
| record.scan_status = SCAN_INFECTED |
| record.scan_detail = f"antivirus: {av['detail']}" |
| record.errors = ["Content scan rejected the upload β " + av["detail"]] |
| persist_record(record) |
| return record, {"status": "error", "errors": record.errors, "scan": av} |
|
|
| if av["result"] == "clean": |
| record.scan_status = SCAN_CLEAN |
| record.scan_detail = av["detail"] |
| record.errors = [] |
| if posture == POSTURE_STRUCTURAL_ONLY: |
| |
| |
| |
| host_caveat = ( |
| "Anti-virus ran on this host and reported clean, but the declared " |
| "deployment posture is 'structural_only' (deploy/scan_posture.yaml) " |
| "β the deployed Space has no AV binary. Do not generalise this " |
| "clean result to uploads made in production." |
| ) |
| if host_caveat not in record.caveats: |
| record.caveats.append(host_caveat) |
| persist_record(record) |
| return record, {"status": "pass", "scan": av, "posture": posture} |
|
|
| |
| if _scan_required(): |
| record.scan_status = SCAN_INFECTED |
| record.scan_detail = f"scan required but no clean AV result: {av['detail']}" |
| record.errors = [ |
| "Content scan could not complete and UPLOAD_SCAN_REQUIRED is set: " + av["detail"] |
| ] |
| persist_record(record) |
| return record, {"status": "error", "errors": record.errors, "scan": av} |
|
|
| record.scan_status = SCAN_SKIPPED |
| record.scan_detail = av["detail"] |
| caveat = ( |
| "Malware scan SKIPPED β " + av["detail"] + " Structural magic-byte checks " |
| "passed, but no anti-virus scan ran. This upload is " |
| "structurally-verified, NOT malware-scanned β do not describe it as " |
| "malware-scanned. This is the declared posture for this deployment " |
| "(posture='" + posture + "', deploy/scan_posture.yaml); managed AV is " |
| "deferred to the ADR-0011 AWS staged-object pass." |
| ) |
| if caveat not in record.caveats: |
| record.caveats.append(caveat) |
| persist_record(record) |
| return record, {"status": "skipped", "scan": av, "caveat": caveat, "posture": posture} |
|
|