diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3eb25bd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: ["main", "master"] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Lint + run: ruff check . + - name: Type check + run: mypy . + - name: Run tests + run: pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..30d3ca0 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: build-and-release +on: + push: + tags: ["v*.*.*"] + +jobs: + build: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install deps + run: | + python -m pip install -U pip + pip install -r requirements.txt + pip install -r requirements-optional.txt || true + pip install pyinstaller PySimpleGUI + pip install ruff mypy pytest + - name: Lint & Typecheck + run: | + ruff check . + mypy . + - name: Test + run: pytest -q + - name: Build PyInstaller + shell: bash + run: | + if [[ "$RUNNER_OS" == "Windows" ]]; then + python -m PyInstaller --noconfirm --clean --name TeacherSafeScanner --onefile --windowed --add-data "scanner/reporting/html_theme.css;scanner/reporting" scanner/gui.py + 7z a TeacherSafeScanner-windows.zip dist/TeacherSafeScanner.exe + elif [[ "$RUNNER_OS" == "macOS" ]]; then + python -m PyInstaller --noconfirm --clean --name TeacherSafeScanner --onefile --windowed --add-data "scanner/reporting/html_theme.css:scanner/reporting" scanner/gui.py + ditto -c -k --sequesterRsrc --keepParent dist/TeacherSafeScanner dist/TeacherSafeScanner-macos.zip || \ + (cd dist && zip -r ../TeacherSafeScanner-macos.zip TeacherSafeScanner) + else + python -m PyInstaller --noconfirm --clean --name TeacherSafeScanner --onefile --windowed --add-data "scanner/reporting/html_theme.css:scanner/reporting" scanner/gui.py + (cd dist && tar -czf ../TeacherSafeScanner-linux.tar.gz TeacherSafeScanner) + fi + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: TeacherSafeScanner-${{ matrix.os }} + path: | + TeacherSafeScanner-*.zip + TeacherSafeScanner-*.tar.gz + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: ./artifacts + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..005f06c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Generated demo assets +/docs/demo.gif +/docs/demo-placeholder.gif diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ac64c3b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + args: ["--fix"] + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy diff --git a/BEGINNERS_GUIDE.md b/BEGINNERS_GUIDE.md index 537d857..f560dda 100644 --- a/BEGINNERS_GUIDE.md +++ b/BEGINNERS_GUIDE.md @@ -75,6 +75,7 @@ This command creates three safe files inside `examples/benign_samples/`: ## 7. Run your first scan ```bash +python -m scanner scan examples/benign_samples python -m scanner.main scan examples/benign_samples ``` @@ -86,6 +87,7 @@ python -m scanner.main scan examples/benign_samples 1. **Do not open the file.** 2. Move it away from your main folders using: ```bash + python -m scanner quarantine PATH_TO_FILE --dest quarantine python -m scanner.main quarantine PATH_TO_FILE --dest quarantine ``` 3. Share the JSON or HTML report with your school IT team. @@ -105,4 +107,22 @@ python -m scanner.main scan examples/benign_samples - Read [SAFETY.md](SAFETY.md) for more safety advice. - If you are stuck, ask a colleague or your IT support team for help. Share any error messages exactly as they appear. +## Windows (PowerShell) + +```powershell +py -3 -m venv .venv +. .venv\Scripts\Activate.ps1 +pip install -r requirements.txt +python -m scanner.gui +``` + +## macOS + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python -m scanner.gui +``` + Stay safe and never execute files that you do not fully trust. diff --git a/README.md b/README.md index 3fea779..fac68a0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ > To add your own walkthrough, drop a GIF at `docs/demo.gif` and update this link. +Teacher-Safe Local File Scanner is a Python-based, offline-friendly toolkit that helps educators quickly triage student-submitted files before opening them. It performs static checks only—no execution of untrusted code—and produces human-readable and machine-readable reports. Teacher-Safe Local File Scanner is a Python-based, offline-friendly toolkit that helps educators quickly triage student-submitted files before opening them. It performs static checks only, no execution of untrusted code and produces human readable and machine/readable reports. ## Table of contents @@ -48,6 +49,7 @@ python examples/generate_benign_samples.py # Materialise demo files ### Scan files or folders ```bash +python -m scanner scan ./examples/benign_samples --max-file-size 5000000 --threads 4 python -m scanner.main scan ./examples/benign_samples --max-file-size 5000000 --threads 4 ``` @@ -59,12 +61,15 @@ python -m scanner.main scan ./examples/benign_samples --max-file-size 5000000 -- ### Watch a directory (polling, non-blocking) ```bash +python -m scanner scan --watch ./incoming python -m scanner.main scan --watch ./incoming ``` ### Produce reports ```bash +python -m scanner scan submissions --report-json scan_report.json --report-html scan_report.html +python -m scanner report scan_report.json --html --output scan_report.html python -m scanner.main scan submissions --output scan_report.json python -m scanner.main report scan_report.json --html --output scan_report.html ``` @@ -72,6 +77,7 @@ python -m scanner.main report scan_report.json --html --output scan_report.html ### Quarantine a file ```bash +python -m scanner quarantine ./submissions/suspicious.docx --dest ./quarantine python -m scanner.main quarantine ./submissions/suspicious.docx --dest ./quarantine ``` @@ -87,6 +93,20 @@ python examples/generate_benign_samples.py The script recreates a harmless text file, a minimal PNG image, and a macro-free `.docx` document without storing binary fixtures in the repository. +## One-click binaries + +Grab the latest release assets for Windows, macOS, or Linux to run the scanner without Python. Each bundle ships offline-first and collects no telemetry. + +### Windows context menu + +1. Copy `TeacherSafeScanner.exe` to `C:\Program Files\TeacherSafe\`. +2. Double-click `scripts/windows_add_context_menu.reg` to register a **Scan with Teacher-Safe** right-click option. + +### GUI launcher + +- On Python: run `python -m scanner.gui` and use the picker to select files or folders, then press **Scan** and **Open Report**. +- On packaged builds: launch `TeacherSafeScanner` from the extracted bundle and follow the same steps to save and open the HTML report. + ## How scanning works The scanner combines lightweight type identification, static detectors, and heuristic scoring: @@ -95,6 +115,7 @@ The scanner combines lightweight type identification, static detectors, and heur | --- | --- | --- | | Discovery | Files are walked recursively (respecting `--max-file-size`) and hashed using streaming reads. | [`scanner.utils`](scanner/utils.py) | | Type sniffing | If `python-magic` is enabled, MIME detection is delegated; otherwise magic bytes are inspected. | [`scanner.scanner_core`](scanner/scanner_core.py) | +| Detection | Format-specific rules look for risky markers (e.g., macros, embedded executables, appended payloads). | [`scanner.detectors`](scanner/detectors/__init__.py) | | Detection | Format-specific rules look for risky markers (e.g., macros, embedded executables, appended payloads). | [`scanner.detectors`](scanner/detectors.py) | | Scoring | Each finding contributes a weighted score mapped to Safe/Caution/Suspicious/High labels. | [`scanner.heuristics`](scanner/heuristics.py) | | Reporting | Results are aggregated into JSON, console, or HTML outputs. | [`scanner.reporters`](scanner/reporters.py) | @@ -103,6 +124,7 @@ The entire pipeline avoids running untrusted content and is safe to execute on o ## Command reference +The CLI exposes three subcommands and several shared options: The CLI exposes three subcommands and several shared options. ### `scan` @@ -116,6 +138,8 @@ python -m scanner.main scan [--output report.json] [--threads 8] [--max-f Useful flags: - `--watch `: poll for new files while continuing to monitor previously scanned ones. +- `--report-json` / `--report-html`: save structured and teacher-friendly reports in one run. +- `--pdf-rules`, `--office-rules`, `--zip-rules`, `--image-rules`: choose `off`, `normal`, or `strict` for per-format heuristics. - `--use-magic` / `--use-yara`: opt into external libraries when installed. - `--max-file-size`: skip overly large submissions to save time. - `--threads`: increase if you have many CPU cores and fast storage. @@ -157,6 +181,7 @@ The CLI flags are opt-in, and the scanner gracefully degrades when the libraries ## Workflow guidance for flagged files +1. **Do not open the file.** Treat warnings as serious until reviewed by IT. 1. Do not open the file. Treat warnings as serious until reviewed by IT. 2. Move the file to the quarantine folder for record keeping. 3. Escalate to your IT or security team with the JSON/HTML report. diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..89b3385 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,2 @@ +benign_samples/sample_image.png +benign_samples/sample_docx.docx diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..ac29c61 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +python_version = 3.11 +ignore_missing_imports = True +warn_return_any = True +warn_unused_ignores = True diff --git a/pyproject.toml b/pyproject.toml index 1055a3f..9a2efa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,12 @@ [build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "teacher-safe-local-file-scanner" +version = "0.1.0" +description = "Offline-first local file scanner for teachers" +requires-python = ">=3.10" requires = ["setuptools>=61", "wheel"] build-backend = "setuptools.build_meta" diff --git a/requirements-optional.txt b/requirements-optional.txt index 1e63063..54315b1 100644 --- a/requirements-optional.txt +++ b/requirements-optional.txt @@ -1,2 +1,5 @@ +python-magic==0.4.27 +yara-python==4.5.1 +watchdog==5.0.2 python-magic yara-python diff --git a/requirements.txt b/requirements.txt index 71cc539..ea602be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,6 @@ +PySimpleGUI==4.60.5 +pytest==8.3.3 +ruff==0.6.9 +mypy==1.11.2 pytest ruff diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..bf1cb55 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,2 @@ +line-length = 100 +target-version = "py311" diff --git a/scanner/__init__.py b/scanner/__init__.py index 9cb804b..5fad40b 100644 --- a/scanner/__init__.py +++ b/scanner/__init__.py @@ -1,3 +1,4 @@ +__all__ = ["__version__"] """Teacher-Safe Local File Scanner package.""" from __future__ import annotations diff --git a/scanner/__main__.py b/scanner/__main__.py new file mode 100644 index 0000000..7efb51f --- /dev/null +++ b/scanner/__main__.py @@ -0,0 +1,6 @@ +from .main import main + +if __name__ == "__main__": # pragma: no cover - package entry point + import sys + + sys.exit(main()) diff --git a/scanner/detectors/__init__.py b/scanner/detectors/__init__.py new file mode 100644 index 0000000..6ecfcb7 --- /dev/null +++ b/scanner/detectors/__init__.py @@ -0,0 +1,213 @@ +"""Static detectors for the Teacher-Safe Local File Scanner. + +This package exposes lightweight detectors that avoid executing untrusted +content. Modules provide both legacy heuristics and new rule-based analyses +used by the v0.1 release pipeline. +""" +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Dict, List, Optional + +from .. import utils +from .image_rules import analyze_image +from .office_rules import analyze_office +from .pdf_rules import analyze_pdf +from .zip_rules import analyze_zip + +LOGGER = logging.getLogger(__name__) + +SUSPICIOUS_EXECUTABLE_SUFFIXES = { + ".exe", + ".bat", + ".cmd", + ".scr", + ".pif", + ".js", + ".jse", + ".vbs", + ".vbe", + ".ps1", + ".dll", +} + +PDF_TOKENS = [b"/JAVASCRIPT", b"/JS", b"/OPENACTION", b"/LAUNCH", b"/EMBEDDEDFILE", b"/AA"] +URL_RE = re.compile(r"https?://[^\s]+") +PUNYCODE_PREFIX = "xn--" + + +def _issue(code: str, description: str, evidence: str | None = None) -> Dict[str, str]: + issue: Dict[str, str] = {"code": code, "description": description} + if evidence is not None: + issue["evidence"] = evidence + return issue + + +def detect_double_extension(path: Path) -> Optional[Dict[str, str]]: + """Flag filenames that use a double extension pattern.""" + suffixes = [suffix.lower() for suffix in path.suffixes] + if len(suffixes) < 2: + return None + primary, secondary = suffixes[-2], suffixes[-1] + disguising = {".pdf", ".doc", ".docx", ".txt", ".png", ".jpg"} + if primary in disguising and secondary in SUSPICIOUS_EXECUTABLE_SUFFIXES: + return _issue( + "double_extension", + "Filename uses double extension pattern", + evidence=path.name, + ) + if secondary in {".exe", ".bat"} and primary not in SUSPICIOUS_EXECUTABLE_SUFFIXES: + return _issue( + "double_extension", + "Suspicious trailing executable extension", + evidence=path.name, + ) + return None + + +def detect_pe_headers(path: Path) -> Optional[Dict[str, str]]: + """Detect Windows PE headers based on the 'MZ' magic string.""" + head = utils.safe_read_head(path, 2) + if head.startswith(b"MZ"): + return _issue("pe_header", "File starts with MZ header indicative of a Windows executable") + return None + + +def detect_zip_contents(path: Path) -> List[Dict[str, str]]: + """Inspect ZIP archives for suspicious embedded content.""" + findings: List[Dict[str, str]] = [] + members = utils.safe_list_zip_members(path) + for name in members: + lname = name.lower() + if len(name) > 180: + findings.append( + _issue("zip_long_name", "Archive member has unusually long name", evidence=name) + ) + if "\x00" in name: + findings.append( + _issue("zip_nul_byte", "Archive member name contains NUL byte", evidence=name) + ) + if any(lname.endswith(ext) for ext in SUSPICIOUS_EXECUTABLE_SUFFIXES): + findings.append( + _issue("exe_in_zip", "Found executable file inside archive", evidence=name) + ) + if detect_double_extension(Path(name)): + findings.append( + _issue("zip_double_extension", "Archive member has double extension", evidence=name) + ) + if name.endswith("vbaProject.bin"): + findings.append( + _issue( + "zip_vba_project", + "Archive contains potential Office macro store", + evidence=name, + ) + ) + return findings + + +def detect_pdf_risks(path: Path) -> List[Dict[str, str]]: + """Search for risky PDF constructs such as JavaScript actions.""" + findings: List[Dict[str, str]] = [] + try: + with path.open("rb") as handle: + buffer = b"" + while True: + chunk = handle.read(4096) + if not chunk: + break + combined = (buffer + chunk).upper() + for token in PDF_TOKENS: + if token in combined: + token_name = token.decode("ascii") + findings.append( + _issue("pdf_token", f"PDF contains token {token_name}", token_name) + ) + buffer = combined[-10:] + except (OSError, IOError) as exc: + LOGGER.warning("Unable to scan PDF %s: %s", path, exc) + return findings + + +def detect_image_appended_data(path: Path) -> Optional[Dict[str, str]]: + """Detect data appended after image end markers.""" + data = utils.safe_read_tail(path, 1024 * 8) + if not data: + return None + suffix = path.suffix.lower() + if suffix in {".png"}: + if b"IEND" in data: + index = data.rfind(b"IEND") + trailer = data[index + 4 :] + if trailer.strip(b"\x00"): + return _issue("image_trailing_data", "PNG file contains data after IEND chunk") + if suffix in {".jpg", ".jpeg"}: + marker = b"\xFF\xD9" + if marker in data: + index = data.rfind(marker) + if data[index + 2 :].strip(b"\x00"): + return _issue("image_trailing_data", "JPEG file contains data after end marker") + return None + + +def detect_office_macro(path: Path) -> Optional[Dict[str, str]]: + """Detect Office documents likely containing macros.""" + suffix = path.suffix.lower() + if suffix in {".docm", ".xlsm", ".pptm"}: + return _issue("office_macro_extension", "Office document type supports embedded macros") + if suffix in {".docx", ".pptx", ".xlsx"}: + members = utils.safe_list_zip_members(path) + for name in members: + if name.endswith("vbaProject.bin"): + return _issue("office_macro_container", "Office document contains vbaProject.bin") + return None + + +def extract_urls_and_flag(path: Path, *, max_bytes: int = 512_000) -> List[Dict[str, str]]: + """Extract URLs from text files and flag suspicious patterns.""" + findings: List[Dict[str, str]] = [] + try: + size = path.stat().st_size + if size > max_bytes: + LOGGER.debug("Skipping URL extraction for %s due to size", path) + return findings + except OSError as exc: + LOGGER.warning("Unable to stat %s: %s", path, exc) + return findings + + if not utils.is_text_file(path): + return findings + + try: + with path.open("r", encoding="utf-8", errors="ignore") as handle: + text = handle.read(max_bytes) + except (OSError, IOError) as exc: + LOGGER.warning("Unable to read text file %s: %s", path, exc) + return findings + + for match in URL_RE.findall(text): + if "://" not in match: + continue + host_part = match.split("//", 1)[1].split("/", 1)[0] + if host_part.startswith(PUNYCODE_PREFIX) or re.fullmatch(r"\d+\.\d+\.\d+\.\d+", host_part): + findings.append(_issue("url_suspicious", "Suspicious URL host", evidence=match)) + else: + findings.append(_issue("url", "URL found in document", evidence=match)) + return findings + + +__all__ = [ + "analyze_image", + "analyze_office", + "analyze_pdf", + "analyze_zip", + "detect_double_extension", + "detect_image_appended_data", + "detect_office_macro", + "detect_pdf_risks", + "detect_pe_headers", + "detect_zip_contents", + "extract_urls_and_flag", +] diff --git a/scanner/detectors/image_rules.py b/scanner/detectors/image_rules.py new file mode 100644 index 0000000..c8c2eb0 --- /dev/null +++ b/scanner/detectors/image_rules.py @@ -0,0 +1,33 @@ +from typing import BinaryIO, Dict, List + + +def analyze_image(f: BinaryIO, strict: bool = False) -> List[Dict]: + data = f.read() + findings: List[Dict] = [] + if data.startswith(b"\x89PNG\r\n\x1a\n"): + idx = data.rfind(b"IEND") + if idx != -1 and len(data) > idx + 12: + findings.append( + { + "rule": "png_appended_data", + "severity": "medium", + "detail": "Data present after PNG IEND (appended payload).", + } + ) + if data.startswith(b"\xff\xd8\xff") and not data.endswith(b"\xff\xd9"): + findings.append( + { + "rule": "jpeg_appended_data", + "severity": "medium", + "detail": "JPEG missing terminal marker; potential appended data.", + } + ) + if strict and b"Exif" in data and len(data) > 5_000_000: + findings.append( + { + "rule": "image_large_exif", + "severity": "low", + "detail": "Large image with EXIF present (strict mode).", + } + ) + return findings diff --git a/scanner/detectors/office_rules.py b/scanner/detectors/office_rules.py new file mode 100644 index 0000000..93880c5 --- /dev/null +++ b/scanner/detectors/office_rules.py @@ -0,0 +1,32 @@ +from typing import BinaryIO, Dict, List +import zipfile +import io + + +# Detects macro-enabled Office docs and macro streams +def analyze_office(f: BinaryIO, strict: bool = False) -> List[Dict]: + data = f.read() + findings: List[Dict] = [] + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + names = set(archive.namelist()) + if any(name.endswith("vbaProject.bin") for name in names): + findings.append( + { + "rule": "office_vba_project", + "severity": "high", + "detail": "vbaProject.bin present (macros).", + } + ) + if any("settings.xml" in name for name in names): + findings.append( + { + "rule": "office_auto_actions_hint", + "severity": "medium", + "detail": "Settings file may define auto behaviors.", + } + ) + except zipfile.BadZipFile: + # Not OOXML .docx/.pptx/.xlsx; could be legacy binary. v0.1: skip deep OLE parse. + pass + return findings diff --git a/scanner/detectors/pdf_rules.py b/scanner/detectors/pdf_rules.py new file mode 100644 index 0000000..e220e74 --- /dev/null +++ b/scanner/detectors/pdf_rules.py @@ -0,0 +1,40 @@ +from typing import BinaryIO, Dict, List +import re + + +def analyze_pdf(f: BinaryIO, strict: bool = False) -> List[Dict]: + data = f.read() + findings: List[Dict] = [] + if b"/OpenAction" in data or b"/AA" in data: + findings.append( + { + "rule": "pdf_auto_actions", + "severity": "high", + "detail": "Document defines automatic actions (OpenAction/AA).", + } + ) + if b"/JavaScript" in data or b"/JS" in data: + findings.append( + { + "rule": "pdf_javascript", + "severity": "high", + "detail": "Embedded JavaScript detected.", + } + ) + if re.search(rb"/EmbeddedFile|/Filespec", data): + findings.append( + { + "rule": "pdf_embedded_files", + "severity": "medium", + "detail": "Contains embedded files/attachments.", + } + ) + if strict and re.search(rb"http(s)?://", data): + findings.append( + { + "rule": "pdf_external_links", + "severity": "low", + "detail": "External URLs present (strict mode).", + } + ) + return findings diff --git a/scanner/detectors/zip_rules.py b/scanner/detectors/zip_rules.py new file mode 100644 index 0000000..9102efc --- /dev/null +++ b/scanner/detectors/zip_rules.py @@ -0,0 +1,54 @@ +from typing import BinaryIO, Dict, List +import zipfile +import io +import os + + +SUSP_EXT = {".exe", ".dll", ".js", ".vbs", ".bat", ".cmd", ".scr", ".ps1", ".jar"} + + +def _double_ext(name: str) -> bool: + base = os.path.basename(name) + parts = base.split(".") + return ( + len(parts) >= 3 + and parts[-1] not in ("zip", "rar", "7z", "gz") + and parts[-2] in {"jpg", "png", "pdf", "doc", "docx", "pptx", "xlsx"} + ) + + +def analyze_zip(f: BinaryIO, strict: bool = False) -> List[Dict]: + data = f.read() + findings: List[Dict] = [] + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + for info in archive.infolist(): + name = info.filename.lower() + _, ext = os.path.splitext(name) + if ext in SUSP_EXT: + findings.append( + { + "rule": "zip_susp_executable", + "severity": "high", + "detail": f"Archive contains suspicious file: {name}", + } + ) + if _double_ext(name): + findings.append( + { + "rule": "zip_double_extension", + "severity": "high", + "detail": f"Double extension pattern: {name}", + } + ) + if name.endswith(".zip") and strict: + findings.append( + { + "rule": "zip_nested_archive", + "severity": "medium", + "detail": f"Nested archive found (strict): {name}", + } + ) + except zipfile.BadZipFile: + pass + return findings diff --git a/scanner/gui.py b/scanner/gui.py new file mode 100644 index 0000000..0277a1c --- /dev/null +++ b/scanner/gui.py @@ -0,0 +1,99 @@ +"""Minimal cross-platform GUI wrapper around the CLI scanner.""" +from __future__ import annotations + +import threading +import webbrowser +from pathlib import Path +from typing import List, Optional + +try: # pragma: no cover - GUI dependency resolved at runtime + import PySimpleGUI as sg +except Exception: # pragma: no cover - imported at runtime only + print("PySimpleGUI is required for GUI. Run: pip install PySimpleGUI") + raise + +from .main import main as cli_main + + +def _parse_paths(raw: str) -> List[str]: + return [segment.strip() for segment in raw.split(";") if segment.strip()] + + +def run_scan(paths: List[str], out_html: Optional[Path]) -> int: + """Invoke the CLI scanner in-process to avoid subprocess complexity.""" + argv = ["scan", *paths] + if out_html: + argv += ["--report-html", str(out_html)] + return cli_main(argv) + + +def app() -> None: + """Launch the PySimpleGUI window.""" + sg.theme("SystemDefault") + layout = [ + [sg.Text("Teacher-Safe Local File Scanner", font=("Segoe UI", 16))], + [sg.Text("Pick files or a folder to scan (offline, safe defaults).")], + [ + sg.Input(key="-PATH-", enable_events=True, expand_x=True), + sg.FolderBrowse("Pick folder"), + sg.FilesBrowse("Pick files"), + ], + [ + sg.Text("Output HTML report"), + sg.Input(key="-OUT-", expand_x=True), + sg.FileSaveAs("Save As", file_types=(("HTML", "*.html"),)), + ], + [ + sg.Button("Scan", key="-SCAN-", bind_return_key=True), + sg.Button("Open Report", key="-OPEN-"), + sg.Button("Quit"), + ], + [sg.Output(size=(100, 20), key="-LOG-")], + ] + window = sg.Window("Teacher-Safe Scanner", layout, finalize=True) + report_path: Optional[Path] = None + + def do_scan() -> None: + nonlocal report_path + raw = window["-PATH-"].get() + selected_paths = _parse_paths(raw) + if not selected_paths: + print("Please select at least one file or folder.") + return + missing = [path for path in selected_paths if not Path(path).exists()] + if missing: + print(f"The following paths do not exist: {', '.join(missing)}") + return + out_raw = window["-OUT-"].get() + out_html = Path(out_raw) if out_raw else Path.cwd() / "scan_report.html" + print("Starting scan...") + try: + exit_code = run_scan(selected_paths, out_html) + except Exception as exc: # pragma: no cover - defensive UI logging + print(f"Scan failed: {exc}") + return + if out_html.exists(): + report_path = out_html + print(f"Report saved to: {report_path}") + else: + report_path = None + print("Scan completed but no report was generated.") + print(f"Scan finished with exit code {exit_code}.") + + while True: + event, _values = window.read() + if event in (sg.WINDOW_CLOSED, "Quit"): + break + if event == "-SCAN-": + thread = threading.Thread(target=do_scan, daemon=True) + thread.start() + if event == "-OPEN-": + if report_path and report_path.exists(): + webbrowser.open(report_path.as_uri()) + else: + print("No report to open yet.") + window.close() + + +if __name__ == "__main__": # pragma: no cover - GUI entry point + app() diff --git a/scanner/heuristics.py b/scanner/heuristics.py index 718ad23..095720b 100644 --- a/scanner/heuristics.py +++ b/scanner/heuristics.py @@ -17,6 +17,18 @@ "image_trailing_data": 15, "url_suspicious": 10, "url": 5, + # Rule-based detectors + "pdf_auto_actions": 40, + "pdf_javascript": 35, + "pdf_embedded_files": 20, + "pdf_external_links": 10, + "office_vba_project": 45, + "office_auto_actions_hint": 15, + "zip_susp_executable": 40, + "zip_nested_archive": 20, + "png_appended_data": 15, + "jpeg_appended_data": 15, + "image_large_exif": 10, } SEVERITY_BANDS: List[Tuple[int, int, str]] = [ @@ -32,6 +44,8 @@ def calculate_score(findings: Iterable[Dict[str, str]]) -> tuple[int, str, list[ score = 0 reasons: list[str] = [] for finding in findings: + code = finding.get("code") or finding.get("rule", "") + weight = WEIGHTS.get(code or "", 5) code = finding.get("code", "") weight = WEIGHTS.get(code, 5) score += weight diff --git a/scanner/main.py b/scanner/main.py index b22bc90..80a6ec4 100644 --- a/scanner/main.py +++ b/scanner/main.py @@ -12,6 +12,7 @@ import sys import time from pathlib import Path +from typing import List, Sequence from typing import Iterable, List from . import __version__, reporters @@ -22,6 +23,18 @@ def configure_logging(verbose: bool) -> None: + """Configure root logging based on the verbose flag.""" + level = logging.DEBUG if verbose else logging.INFO + if not logging.getLogger().handlers: + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + else: # pragma: no cover - defensive branch for repeated CLI invocations + logging.getLogger().setLevel(level) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: level = logging.DEBUG if verbose else logging.INFO logging.basicConfig(level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") @@ -36,6 +49,8 @@ def parse_args(argv: Iterable[str]) -> argparse.Namespace: subparsers = parser.add_subparsers(dest="command", required=True) + scan_parser = subparsers.add_parser("scan", help="Scan files or directories") + scan_parser.add_argument("targets", nargs="+", help="File(s) or directory to scan") scan_parser = subparsers.add_parser("scan", help="Scan a file or directory") scan_parser.add_argument("target", nargs="?", default=".", help="File or directory to scan") scan_parser.add_argument("--watch", type=Path, help="Enable polling watch mode on directory") @@ -56,6 +71,42 @@ def parse_args(argv: Iterable[str]) -> argparse.Namespace: help="Enable experimental YARA scanning", ) scan_parser.add_argument("--threads", type=int, default=4, help="Number of worker threads") + scan_parser.add_argument( + "--report-json", + "--output", + dest="report_json", + type=Path, + help="Path to write JSON report", + ) + scan_parser.add_argument( + "--report-html", + type=Path, + help="Path to write an HTML report", + ) + scan_parser.add_argument( + "--pdf-rules", + choices=("off", "normal", "strict"), + default="normal", + help="Control PDF rule sensitivity", + ) + scan_parser.add_argument( + "--office-rules", + choices=("off", "normal", "strict"), + default="normal", + help="Control Office document rule sensitivity", + ) + scan_parser.add_argument( + "--zip-rules", + choices=("off", "normal", "strict"), + default="normal", + help="Control archive rule sensitivity", + ) + scan_parser.add_argument( + "--image-rules", + choices=("off", "normal", "strict"), + default="normal", + help="Control image rule sensitivity", + ) scan_parser.add_argument("--output", type=Path, help="Path to write JSON report") quarantine_parser = subparsers.add_parser("quarantine", help="Move a file into quarantine") @@ -74,6 +125,7 @@ def parse_args(argv: Iterable[str]) -> argparse.Namespace: return parser.parse_args(list(argv)) +def watch_loop(target: Path, config: ScanConfig, *, report_json: Path | None, report_html: Path | None) -> int: def watch_loop(target: Path, config: ScanConfig) -> int: LOGGER.info("Starting watch mode for %s", target) seen: dict[Path, float] = {} @@ -89,6 +141,7 @@ def watch_loop(target: Path, config: ScanConfig) -> int: for path in changed: LOGGER.info("Detected change in %s", path) results = scan(path, config) + exit_code = max(exit_code, emit_results(results, report_json, report_html)) exit_code = max(exit_code, emit_results(results, None)) seen[path] = path.stat().st_mtime time.sleep(10) @@ -97,12 +150,25 @@ def watch_loop(target: Path, config: ScanConfig) -> int: return exit_code +def emit_results( + results: List[ScanResult], + report_json: Path | None, + report_html: Path | None, +) -> int: def emit_results(results: List[ScanResult], output: Path | None) -> int: if not results: LOGGER.info("No files scanned") return 0 dict_results = [res.to_dict() for res in results] reporters.print_console_report(dict_results, sys.stdout) + if report_json: + report_json.parent.mkdir(parents=True, exist_ok=True) + reporters.write_json_report(dict_results, report_json) + LOGGER.info("JSON report written to %s", report_json) + if report_html: + report_html.parent.mkdir(parents=True, exist_ok=True) + reporters.write_html_report(dict_results, report_html) + LOGGER.info("HTML report written to %s", report_html) if output: reporters.write_json_report(dict_results, output) LOGGER.info("Report written to %s", output) @@ -116,6 +182,10 @@ def emit_results(results: List[ScanResult], output: Path | None) -> int: def handle_scan(args: argparse.Namespace) -> int: + targets = [Path(target) for target in args.targets] + watch_target = args.watch + if watch_target and len(targets) != 1: + raise SystemExit("Watch mode requires a single target") target = Path(args.target) watch_target = args.watch if watch_target and not watch_target.exists(): @@ -125,6 +195,20 @@ def handle_scan(args: argparse.Namespace) -> int: use_magic=args.use_magic, use_yara=args.use_yara, threads=max(args.threads, 1), + pdf_rules=args.pdf_rules, + office_rules=args.office_rules, + zip_rules=args.zip_rules, + image_rules=args.image_rules, + ) + if watch_target: + return watch_loop(watch_target, config, report_json=args.report_json, report_html=args.report_html) + all_results: List[ScanResult] = [] + for target in targets: + if not target.exists(): + LOGGER.warning("Target %s does not exist", target) + continue + all_results.extend(scan(target, config)) + return emit_results(all_results, args.report_json, args.report_html) ) if watch_target: return watch_loop(watch_target, config) @@ -155,11 +239,14 @@ def handle_report(args: argparse.Namespace) -> int: if args.html: if not args.output: raise SystemExit("--output is required when using --html") + args.output.parent.mkdir(parents=True, exist_ok=True) reporters.write_html_report(files, args.output) LOGGER.info("HTML report written to %s", args.output) return 0 +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) def main(argv: Iterable[str] | None = None) -> int: argv = argv if argv is not None else sys.argv[1:] args = parse_args(argv) @@ -170,6 +257,10 @@ def main(argv: Iterable[str] | None = None) -> int: return handle_quarantine(args) if args.command == "report": return handle_report(args) + raise SystemExit(f"Unknown command {args.command}") + + +if __name__ == "__main__": # pragma: no cover - CLI entry point raise SystemExit("Unknown command") diff --git a/scanner/reporters.py b/scanner/reporters.py index 5f1ef3e..a8d3d03 100644 --- a/scanner/reporters.py +++ b/scanner/reporters.py @@ -1,6 +1,7 @@ """Reporters for Teacher-Safe Local File Scanner.""" from __future__ import annotations +import html import json import logging from datetime import datetime @@ -9,6 +10,14 @@ LOGGER = logging.getLogger(__name__) +THEME_PATH = Path(__file__).resolve().parent / "reporting" / "html_theme.css" +_FILE_SEVERITY_CLASS = { + "High": "badge-high", + "Suspicious": "badge-medium", + "Caution": "badge-medium", + "Safe": "badge-low", +} + def write_json_report(results: List[dict], destination: Path) -> None: """Write *results* to *destination* in JSON format.""" @@ -24,6 +33,14 @@ def render_console_table(results: List[dict]) -> str: headers = ("Path", "Severity", "Score", "Issues") rows: List[tuple[str, str, str, str]] = [] for entry in results: + issues = ", ".join(f.get("code", "") for f in entry.get("issues", [])) or "-" + rows.append( + ( + entry.get("path", ""), + entry.get("severity", ""), + str(entry.get("score", "")), + issues, + ) issues = ", ".join(f["code"] for f in entry.get("issues", [])) or "-" rows.append( (entry.get("path", ""), entry.get("severity", ""), str(entry.get("score", "")), issues) @@ -32,6 +49,10 @@ def render_console_table(results: List[dict]) -> str: for row in rows: for idx, column in enumerate(row): col_widths[idx] = max(col_widths[idx], len(column)) + + def format_row(row: Iterable[str]) -> str: + return " | ".join(col.ljust(col_widths[idx]) for idx, col in enumerate(row)) + def format_row(row: Iterable[str]) -> str: return " | ".join(col.ljust(col_widths[idx]) for idx, col in enumerate(row)) lines = [format_row(headers), "-+-".join("-" * width for width in col_widths)] @@ -44,6 +65,117 @@ def print_console_report(results: List[dict], stream: TextIO) -> None: stream.write(render_console_table(results) + "\n") +def _load_theme() -> str: + try: + return THEME_PATH.read_text(encoding="utf-8") + except FileNotFoundError: # pragma: no cover - packaging guard + LOGGER.debug("Theme CSS missing at %s", THEME_PATH) + return "" + + +def _issue_badge(severity: str | None) -> str: + cls = f"badge-{(severity or 'low').lower()}" + label = (severity or "low").title() + return f'{html.escape(label)}' + + +def generate_html_report(results: List[dict]) -> str: + """Generate a standalone HTML report for *results*.""" + css = _load_theme() + summary_rows: List[str] = [] + sections: List[str] = [] + for index, entry in enumerate(results, start=1): + path = entry.get("path", "") + severity = entry.get("severity", "Safe") + badge_class = _FILE_SEVERITY_CLASS.get(severity, "badge-low") + score = entry.get("score", 0) + summary_rows.append( + "{path}{sev}{score}".format( + idx=index, + path=html.escape(str(path)), + cls=badge_class, + sev=html.escape(severity), + score=html.escape(str(score)), + ) + ) + issue_items: List[str] = [] + issues = entry.get("issues", []) or [] + for issue in issues: + code = html.escape(str(issue.get("code", "finding"))) + detail = html.escape(str(issue.get("description", ""))) + evidence = issue.get("evidence") + badge = _issue_badge(issue.get("severity")) + body = f"{badge} {code} — {detail}" + if evidence: + body += f" {html.escape(str(evidence))}" + issue_items.append(f"
  • {body}
  • ") + if not issue_items: + issue_items.append("
  • No issues detected.
  • ") + details_block = """ +
    + Evidence ({count}) +
      + {items} +
    +
    +""".format(count=len(issue_items), items="\n ".join(issue_items)) + next_steps = """ +
    +

    What to do next

    +

    Stay cautious. Do not open this file directly on a classroom computer. If the severity is High or Suspicious, share the report with your IT helpdesk.

    +

    You can quarantine the file by running the command copied below.

    + +

    Command copied to clipboard when you press the button.

    +
    +""".format(path=html.escape(str(path))) + sections.append( + """ +
    +

    {path} {severity}

    +

    Score: {score}

    + {details} + {next_steps} +
    +""".format( + idx=index, + path=html.escape(str(path)), + badge=badge_class, + severity=html.escape(severity), + score=html.escape(str(score)), + details=details_block, + next_steps=next_steps, + ) + ) + summary_html = "\n".join(summary_rows) + sections_html = "\n".join(sections) + script = """ + +""" + return """ def generate_html_report(results: List[dict]) -> str: """Generate a standalone HTML report for *results*.""" rows_html = [] @@ -71,6 +203,16 @@ def generate_html_report(results: List[dict]) -> str: Teacher-Safe Scanner Report