From 8c3420501841379688e699954a33c5ab12a96a5a Mon Sep 17 00:00:00 2001 From: Constripacity <105997939+constripacity@users.noreply.github.com> Date: Sun, 16 Nov 2025 21:43:33 +0100 Subject: [PATCH] Move project to repository root --- .github/workflows/ci.yml | 29 +++ .github/workflows/release.yml | 61 ++++++ .gitignore | 3 + .pre-commit-config.yaml | 14 ++ BEGINNERS_GUIDE.md | 126 +++++++++++ CHANGELOG.md | 9 + CONTRIBUTING.md | 30 +++ LICENSE | 21 ++ README.md | 269 +++++++++++++++++++++++- SAFETY.md | 15 ++ docs/README.md | 3 + examples/.gitignore | 2 + examples/__init__.py | 0 examples/benign_samples/sample_text.txt | 1 + examples/generate_benign_samples.py | 117 +++++++++++ examples/sample_report.json | 17 ++ mypy.ini | 5 + pyproject.toml | 9 + requirements-optional.txt | 3 + requirements.txt | 4 + ruff.toml | 2 + scanner/__init__.py | 2 + scanner/__main__.py | 6 + scanner/detectors/__init__.py | 213 +++++++++++++++++++ scanner/detectors/image_rules.py | 33 +++ scanner/detectors/office_rules.py | 32 +++ scanner/detectors/pdf_rules.py | 40 ++++ scanner/detectors/zip_rules.py | 54 +++++ scanner/gui.py | 99 +++++++++ scanner/heuristics.py | 55 +++++ scanner/main.py | 239 +++++++++++++++++++++ scanner/quarantine.py | 55 +++++ scanner/reporters.py | 213 +++++++++++++++++++ scanner/reporting/html_theme.css | 6 + scanner/scanner_core.py | 238 +++++++++++++++++++++ scanner/utils.py | 141 +++++++++++++ scripts/build_pyinstaller.ps1 | 8 + scripts/build_pyinstaller.sh | 10 + scripts/windows_add_context_menu.reg | 7 + setup.cfg | 8 + tests/__init__.py | 0 tests/conftest.py | 16 ++ tests/test_detectors.py | 36 ++++ tests/test_heuristics.py | 20 ++ tests/test_image_rules.py | 13 ++ tests/test_integration.py | 12 ++ tests/test_office_rules.py | 13 ++ tests/test_pdf_rules.py | 14 ++ tests/test_zip_rules.py | 14 ++ tests/utils_make_samples.py | 35 +++ 50 files changed, 2371 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 BEGINNERS_GUIDE.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SAFETY.md create mode 100644 docs/README.md create mode 100644 examples/.gitignore create mode 100644 examples/__init__.py create mode 100644 examples/benign_samples/sample_text.txt create mode 100644 examples/generate_benign_samples.py create mode 100644 examples/sample_report.json create mode 100644 mypy.ini create mode 100644 pyproject.toml create mode 100644 requirements-optional.txt create mode 100644 requirements.txt create mode 100644 ruff.toml create mode 100644 scanner/__init__.py create mode 100644 scanner/__main__.py create mode 100644 scanner/detectors/__init__.py create mode 100644 scanner/detectors/image_rules.py create mode 100644 scanner/detectors/office_rules.py create mode 100644 scanner/detectors/pdf_rules.py create mode 100644 scanner/detectors/zip_rules.py create mode 100644 scanner/gui.py create mode 100644 scanner/heuristics.py create mode 100644 scanner/main.py create mode 100644 scanner/quarantine.py create mode 100644 scanner/reporters.py create mode 100644 scanner/reporting/html_theme.css create mode 100644 scanner/scanner_core.py create mode 100644 scanner/utils.py create mode 100644 scripts/build_pyinstaller.ps1 create mode 100755 scripts/build_pyinstaller.sh create mode 100644 scripts/windows_add_context_menu.reg create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_detectors.py create mode 100644 tests/test_heuristics.py create mode 100644 tests/test_image_rules.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_office_rules.py create mode 100644 tests/test_pdf_rules.py create mode 100644 tests/test_zip_rules.py create mode 100644 tests/utils_make_samples.py 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 new file mode 100644 index 0000000..630ab2f --- /dev/null +++ b/BEGINNERS_GUIDE.md @@ -0,0 +1,126 @@ +# Beginner Guide: Teacher-Safe Local File Scanner + +This guide is for people who are new to computers and want a safe way to check student files before opening them. Every step is written as plainly as possible. + +## 1. What you need + +- A computer with Windows, macOS, or Linux. +- Python 3.10 or newer. If you are not sure, open a terminal (Command Prompt on Windows) and type `python --version`. +- About 15 minutes to follow the steps. + +## 2. Download the project + +1. Open your web browser. +2. Visit the project page and click **Code → Download ZIP**. +3. Unzip the file into a folder you can find easily, such as `Documents/teacher-safe-scanner`. + +## 3. Open a terminal in the project folder + +- **Windows:** Open the Start menu, type **Command Prompt**, press Enter, then run: + ```cmd + cd %HOMEPATH%\Documents\teacher-safe-scanner + ``` +- **macOS:** Open Spotlight (⌘ + Space), type **Terminal**, press Enter, then run: + ```bash + cd ~/Documents/teacher-safe-scanner + ``` +- **Linux:** Open your terminal app and type: + ```bash + cd ~/Documents/teacher-safe-scanner + ``` + +If the terminal says "The system cannot find the path specified" or "No such file or directory", double-check the folder location and try again. + +## 4. Create a safe Python environment + +Copy and paste these commands into the terminal, one line at a time. Press Enter after each line. + +```bash +python -m venv .venv +``` + +- On **Windows** run: + ```cmd + .venv\Scripts\activate + ``` +- On **macOS/Linux** run: + ```bash + source .venv/bin/activate + ``` + +When the environment is active you will see `(.venv)` at the beginning of the terminal line. + +## 5. Install the scanner + +```bash +pip install -r requirements.txt +``` + +Wait until the installation finishes. If you see an error, ensure your internet connection is working and run the command again. + +## 6. Create the example files + +The repository avoids storing binary files, so you need to create the harmless samples locally. Run: + +```bash +python examples/generate_benign_samples.py +``` + +This command creates three safe files inside `examples/benign_samples/`: + +- `sample_text.txt` – a normal text file. +- `sample_image.png` – a tiny picture. +- `sample_docx.docx` – a Word document with no macros. + +## 7. Run your first scan + +```bash +python -m scanner scan examples/benign_samples +``` + +- If everything is safe, the program finishes with exit code `0` and prints a summary. +- If you ever see exit code `1`, `2`, or `3`, read the message shown on screen and follow the safety tips below. + +## 8. What to do if something is flagged + +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 + ``` +3. Share the JSON or HTML report with your school IT team. + +## 9. Keep things up to date + +- To update the scanner later, open the project folder, activate the virtual environment again, and run: + ```bash + git pull + pip install -r requirements.txt + ``` +- Run the generator script again if you need fresh example files. + +## 10. Extra help + +- Read [README.md](README.md) for advanced features. +- 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2da99bf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented here. + +## [0.1.0] - 2024-01-01 +### Added +- Initial release of the Teacher-Safe Local File Scanner scaffold with CLI, detectors, heuristic scoring, reporters, and quarantine tooling. +- Example benign samples and example report for testing. +- GitHub Actions workflow for pytest and ruff. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c2a9271 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,30 @@ +# Contributing to Teacher-Safe Local File Scanner + +Thanks for keeping classrooms secure! Before submitting a contribution, please review these guidelines. + +## Code of conduct + +Be respectful. This project focuses on defensive security education—no offensive tooling. + +## Getting started + +1. Fork the repository and create a feature branch (`git checkout -b feature/your-change`). +2. Install dependencies: `pip install -r requirements.txt` (and `requirements-optional.txt` if needed). +3. Run the test suite and linters before committing. + +## Coding standards + +- Follow PEP 8 and keep functions well-documented with docstrings and type hints. +- Prefer the Python standard library; optional defensive packages must be feature-flagged. +- Use pathlib for path manipulation and never execute untrusted content. + +## Pull request checklist + +- [ ] Tests (`pytest`) pass locally. +- [ ] Linting (`ruff check .`) passes. +- [ ] Documentation updated (README, SAFETY, CHANGELOG as appropriate). +- [ ] New code includes logging where useful and avoids executing untrusted inputs. + +## Release process + +Releases are tracked in `CHANGELOG.md`. Update the file and bump the version in `scanner/__init__.py` and `pyproject.toml` when preparing a release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b327574 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Teacher Safe Maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 55fe99e..79ce2c2 100644 --- a/README.md +++ b/README.md @@ -1 +1,268 @@ -# Teacher-Safe-Local-File-Scanner \ No newline at end of file +# Teacher-Safe Local File Scanner + +> **Defensive notice:** This project is provided for educational and defensive use by teachers and school IT staff. It is **not** a replacement for enterprise antivirus or endpoint protection. + +![Demo GIF placeholder](https://img.shields.io/badge/Demo%20GIF-coming%20soon-blue) + +> 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. + +## Table of contents + +1. [Features](#features) +2. [Quickstart](#quickstart) +3. [How scanning works](#how-scanning-works) +4. [Command reference](#command-reference) +5. [Optional defensive plugins](#optional-defensive-plugins) +6. [Workflow guidance for flagged files](#workflow-guidance-for-flagged-files) +7. [Safety, ethics, and limitations](#safety-ethics-and-limitations) +8. [Cross-platform notes](#cross-platform-notes) +9. [Reports and outputs](#reports-and-outputs) +10. [Troubleshooting & FAQ](#troubleshooting--faq) +11. [Development](#development) +12. [Contributing](#contributing) +13. [License](#license) + +If you are new to command-line tools, start with the [Beginner Guide](BEGINNERS_GUIDE.md) for a slower, step-by-step walkthrough. + +## Features + +- Static detectors for risky constructs in ZIP, Office, PDF, and image files +- Heuristic scoring with clear severity labels +- Console, JSON, and HTML reporting +- Optional directory watch mode using polling +- Quarantine helper that moves, never deletes, suspicious files +- Cross-platform (Windows, macOS, Linux) with standard library defaults +- Optional integrations with `python-magic` and `yara-python` + +## Quickstart + +```bash +python -m venv .venv +source .venv/bin/activate # On Windows use: .venv\\Scripts\\activate +pip install -r requirements.txt +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 +``` + +- Exit code `0`: no suspicious findings +- Exit code `1`: caution or suspicious findings +- Exit code `2`: high severity findings +- Exit code `3`: internal scanner error + +### Watch a directory (polling, non-blocking) + +```bash +python -m scanner 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 +``` + +### Quarantine a file + +```bash +python -m scanner quarantine ./submissions/suspicious.docx --dest ./quarantine +``` + +The quarantine command moves the file safely, sets read-only permissions, and leaves a `.meta.json` file with provenance details. + +### Refreshing the benign examples + +If you delete the generated examples or clone the repository fresh, run: + +```bash +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: + +| Phase | What happens | Key modules | +| --- | --- | --- | +| 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) | +| 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) | + +The entire pipeline avoids running untrusted content and is safe to execute on offline, air-gapped devices. + +## Command reference + +The CLI exposes three subcommands and several shared options: + +### `scan` + +Scan one file or a directory tree. + +```bash +python -m scanner.main scan [--output report.json] [--threads 8] [--max-file-size 200000000] +``` + +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. + +The scan command exits with a severity-driven code so it integrates well with CI or folder monitors. + +### `quarantine` + +Move suspicious files to a safe holding area without deleting them. + +```bash +python -m scanner.main quarantine ./submissions/suspicious.docx --dest ./quarantine +``` + +The destination receives a read-only copy plus a `.meta.json` file recording the original location, hash, and timestamp. + +### `report` + +Render previously generated JSON results into other formats. + +```bash +python -m scanner.main report scan_report.json --html --output scan_report.html +``` + +Omit `--html` to stream a human-readable console summary instead. + +## Optional defensive plugins + +Install optional packages only if your environment permits: + +```bash +pip install -r requirements-optional.txt +``` + +- `python-magic`: richer MIME identification (`--use-magic`) +- `yara-python`: experimental pattern matching (`--use-yara`) + +The CLI flags are opt-in, and the scanner gracefully degrades when the libraries are unavailable. + +## Workflow guidance for flagged files + +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. +4. Review in an isolated virtual machine if your institution allows it. +5. When in doubt, collect additional context (e.g., student name, assignment) in a secure ticketing system. + +## Safety, ethics, and limitations + +- Static analysis only; no attempt is made to remove malware. +- Large or encrypted archives may hide malicious content the scanner cannot inspect. +- The heuristics prioritise minimizing false negatives but may produce false positives—always confirm with professional tools. +- The tool never executes or modifies untrusted binaries beyond safe hashing and metadata reads. + +Read more in [SAFETY.md](SAFETY.md). + +## Cross-platform notes + +- Paths are managed with `pathlib`. When running on Windows, prefer PowerShell or CMD with UTF-8 enabled (`chcp 65001`). +- Quarantine sets read-only attributes; if you need to restore a quarantined file, manually adjust permissions via `attrib -r` on Windows or `chmod +w` on Unix. +- Polling-based watch mode relies on filesystem timestamps; on slow or networked drives expect a 10-second delay before changes are detected. +- For macOS Gatekeeper prompts, run `xattr -dr com.apple.quarantine ` only on files you trust and after verifying reports. + +## Reports and outputs + +Reports follow a stable JSON schema so they can be ingested by help-desk systems: + +```json +{ + "path": "submissions/homework1.zip", + "sha256": "abc123...", + "size": 34567, + "magic_type": "zip", + "issues": [ + {"code": "exe_in_zip", "description": "Found executable file payload.exe inside archive", "evidence": "payload.exe"}, + {"code": "double_extension", "description": "Filename uses double extension 'report.pdf.exe'", "evidence": "report.pdf.exe"} + ], + "score": 75, + "severity": "Suspicious" +} +``` + +When exporting HTML the report includes: + +- A safety banner reminding readers not to open flagged files. +- A severity-coloured table summarising each item. +- Collapsible detail sections for detector evidence. +- Footer tips on next steps for educators. + +Console output defaults to a clean table suitable for terminal screenshots. Use `--verbose` during scans for additional logging. + +## Troubleshooting & FAQ + +**The scanner skips files larger than expected.** + +- Confirm the `--max-file-size` flag; the default is 100 MB. Some learning management systems export multi-gigabyte ZIPs that may need a higher limit. + +**`python-magic` or `yara-python` import errors appear.** + +- Ensure you installed `requirements-optional.txt`. On Windows you may need the Visual C++ Build Tools; on macOS install Homebrew `libmagic` first. + +**Watching a network share misses changes.** + +- Keep the watch directory local when possible. The default 10-second polling interval may drift on congested networks—re-run the command if scans appear delayed. + +**How do I update the benign sample files?** + +- Run `python examples/generate_benign_samples.py --force` to regenerate all fixtures. The script never overwrites files unless the hash changes, so it is safe to run repeatedly. + +**Can I integrate results into another system?** + +- Yes. The JSON report is linearly structured. Use `jq`, Python, or your preferred language to parse the `issues` array per file. The exit code makes automation straightforward. + +## Development + +```bash +pip install -r requirements.txt +pytest +ruff check . +``` + +Recommended editor settings: + +- Enable `black`-style formatting at 88 columns. +- Turn on type checking (MyPy or Pyright) for early detection of annotation issues. +- Configure your IDE to respect `.editorconfig` if present. + +## Contributing + +We welcome defensive-minded contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for coding standards and submission guidelines. + +## License + +MIT License © Teacher Safe Maintainers diff --git a/SAFETY.md b/SAFETY.md new file mode 100644 index 0000000..9d13e46 --- /dev/null +++ b/SAFETY.md @@ -0,0 +1,15 @@ +# Safety & Ethics Guidance + +Teacher-Safe Local File Scanner exists to reduce risk for teachers receiving student files. It must be used responsibly: + +- **Defensive only:** The project must never be repurposed to build or distribute malicious tooling. Contributions that violate this principle will be rejected. +- **No execution:** The scanner reads metadata and file structures only. It never runs embedded macros, executables, or scripts. +- **Verification:** Treat scanner output as advisory. Confirm suspicious findings with professional antivirus or your institution’s security operations before taking disciplinary action. +- **Handling flagged files:** + 1. Quarantine the file using the provided command or another safe storage mechanism. + 2. Notify your IT or security team and share the generated reports. + 3. Review the file only inside an isolated sandbox with no network access. + 4. Document all actions for accountability and compliance. +- **Data privacy:** Reports may contain file paths or filenames. Store them securely and follow your school’s privacy requirements. + +The maintainers welcome responsible disclosures and feedback via issues or pull requests. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..582c869 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +# Demo Assets + +Add walkthrough GIFs or screenshots for the scanner in this folder. By default the project links to `docs/demo.gif`; replace the placeholder URL in the main README once you capture a real demonstration. 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/examples/benign_samples/sample_text.txt b/examples/benign_samples/sample_text.txt new file mode 100644 index 0000000..9bf425c --- /dev/null +++ b/examples/benign_samples/sample_text.txt @@ -0,0 +1 @@ +Hello teacher! This is a harmless student submission. diff --git a/examples/generate_benign_samples.py b/examples/generate_benign_samples.py new file mode 100644 index 0000000..037f8fc --- /dev/null +++ b/examples/generate_benign_samples.py @@ -0,0 +1,117 @@ +"""Utility script to generate benign sample files for demonstrations. + +This script avoids storing binary fixtures directly in the repository to +keep pull requests text-only while still providing realistic sample files +for scanner demonstrations and tests. +""" +from __future__ import annotations + +import base64 +import zipfile +from pathlib import Path + +SAMPLE_DIR = Path(__file__).parent / "benign_samples" + +PNG_BASE64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==" +) + + +def ensure_directory(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def create_sample_image(path: Path) -> None: + if path.exists(): + return + data = base64.b64decode(PNG_BASE64) + path.write_bytes(data) + + +def create_sample_text(path: Path) -> None: + if path.exists(): + return + path.write_text( + "This is a harmless plain text file used to validate the teacher-safe scanner.\n", + encoding="utf-8", + ) + + +def create_sample_docx(path: Path) -> None: + if path.exists(): + return + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "[Content_Types].xml", + ( + "\n" + "\n" + " \n" + " \n" + " \n" + "\n" + ), + ) + zf.writestr( + "_rels/.rels", + ( + "\n" + "\n" + " \n" + "\n" + ), + ) + zf.writestr( + "docProps/app.xml", + ( + "\n" + "\n" + " Teacher Safe Scanner\n" + "\n" + ), + ) + zf.writestr( + "docProps/core.xml", + ( + "\n" + "\n" + " Harmless Example\n" + "\n" + ), + ) + zf.writestr( + "word/document.xml", + ( + "\n" + "\n" + " \n" + " \n" + " \n" + " This document is a benign placeholder with no macros.\n" + " \n" + " \n" + " \n" + "\n" + ), + ) + + +def main() -> None: + ensure_directory(SAMPLE_DIR) + create_sample_text(SAMPLE_DIR / "sample_text.txt") + create_sample_image(SAMPLE_DIR / "sample_image.png") + create_sample_docx(SAMPLE_DIR / "sample_docx.docx") + + +if __name__ == "__main__": + main() diff --git a/examples/sample_report.json b/examples/sample_report.json new file mode 100644 index 0000000..a1a77ae --- /dev/null +++ b/examples/sample_report.json @@ -0,0 +1,17 @@ +{ + "generated_at": "2024-01-01T00:00:00Z", + "files": [ + { + "path": "submissions/homework1.zip", + "sha256": "abc123", + "size": 34567, + "magic_type": "zip", + "issues": [ + {"code": "exe_in_zip", "description": "Found executable file payload.exe inside archive", "evidence": "payload.exe"}, + {"code": "double_extension", "description": "Filename uses double extension 'report.pdf.exe'", "evidence": "report.pdf.exe"} + ], + "score": 75, + "severity": "Suspicious" + } + ] +} 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 new file mode 100644 index 0000000..6e79ab5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[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" diff --git a/requirements-optional.txt b/requirements-optional.txt new file mode 100644 index 0000000..e841b35 --- /dev/null +++ b/requirements-optional.txt @@ -0,0 +1,3 @@ +python-magic==0.4.27 +yara-python==4.5.1 +watchdog==5.0.2 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9a87b42 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PySimpleGUI==4.60.5 +pytest==8.3.3 +ruff==0.6.9 +mypy==1.11.2 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 new file mode 100644 index 0000000..07c5de9 --- /dev/null +++ b/scanner/__init__.py @@ -0,0 +1,2 @@ +__all__ = ["__version__"] +__version__ = "0.1.0" 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 new file mode 100644 index 0000000..030ac74 --- /dev/null +++ b/scanner/heuristics.py @@ -0,0 +1,55 @@ +"""Heuristic scoring for the Teacher-Safe Local File Scanner.""" +from __future__ import annotations + +from typing import Dict, Iterable, List, Tuple + +WEIGHTS: Dict[str, int] = { + "exe_in_zip": 40, + "zip_vba_project": 35, + "zip_double_extension": 25, + "zip_long_name": 10, + "zip_nul_byte": 25, + "pdf_token": 20, + "double_extension": 15, + "pe_header": 50, + "office_macro_extension": 30, + "office_macro_container": 45, + "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]] = [ + (0, 19, "Safe"), + (20, 49, "Caution"), + (50, 79, "Suspicious"), + (80, 1000, "High"), +] + + +def calculate_score(findings: Iterable[Dict[str, str]]) -> tuple[int, str, list[str]]: + """Calculate a heuristic score from individual findings.""" + score = 0 + reasons: list[str] = [] + for finding in findings: + code = finding.get("code") or finding.get("rule", "") + weight = WEIGHTS.get(code or "", 5) + score += weight + reasons.append(f"{code}: +{weight}") + score = min(score, 100) + for low, high, label in SEVERITY_BANDS: + if low <= score <= high: + return score, label, reasons + return score, "Unknown", reasons diff --git a/scanner/main.py b/scanner/main.py new file mode 100644 index 0000000..f2f7809 --- /dev/null +++ b/scanner/main.py @@ -0,0 +1,239 @@ +"""Command line entry point for the Teacher-Safe Local File Scanner. + +Threat model: educators receiving potentially risky student submissions. +Limitations: static analysis only, no disinfection, no guarantee of malware +absence. The tool never executes untrusted content; it only inspects files. +""" +from __future__ import annotations + +import argparse +import json +import logging +import sys +import time +from pathlib import Path +from typing import List, Sequence + +from . import __version__, reporters +from .quarantine import move_to_quarantine +from .scanner_core import ScanConfig, ScanResult, scan + +LOGGER = logging.getLogger(__name__) + + +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: + parser = argparse.ArgumentParser( + prog="teacher-safe-scanner", + description="Static defensive scanner for teachers reviewing student submissions.", + ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + parser.add_argument("--verbose", action="store_true", help="Enable verbose logging") + + 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.add_argument("--watch", type=Path, help="Enable polling watch mode on directory") + scan_parser.add_argument( + "--max-file-size", + type=int, + default=100_000_000, + help="Skip files larger than this size in bytes", + ) + scan_parser.add_argument( + "--use-magic", + action="store_true", + help="Use python-magic for type detection", + ) + scan_parser.add_argument( + "--use-yara", + action="store_true", + 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", + ) + + quarantine_parser = subparsers.add_parser("quarantine", help="Move a file into quarantine") + quarantine_parser.add_argument("path", type=Path, help="File or directory to quarantine") + quarantine_parser.add_argument( + "--dest", type=Path, required=True, help="Destination directory for quarantine" + ) + + report_parser = subparsers.add_parser("report", help="Render a human-friendly report from JSON") + report_parser.add_argument("report", type=Path, help="JSON report file from a previous scan") + report_parser.add_argument( + "--html", action="store_true", help="Render HTML output alongside console" + ) + report_parser.add_argument("--output", type=Path, help="Write HTML report to this path") + + return parser.parse_args(list(argv)) + + +def watch_loop(target: Path, config: ScanConfig, *, report_json: Path | None, report_html: Path | None) -> int: + LOGGER.info("Starting watch mode for %s", target) + seen: dict[Path, float] = {} + exit_code = 0 + try: + while True: + current_files = list(target.rglob("*")) if target.is_dir() else [target] + changed = [ + path + for path in current_files + if path.is_file() and seen.get(path) != path.stat().st_mtime + ] + 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)) + seen[path] = path.stat().st_mtime + time.sleep(10) + except KeyboardInterrupt: + LOGGER.info("Watch mode interrupted by user") + return exit_code + + +def emit_results( + results: List[ScanResult], + report_json: Path | None, + report_html: 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) + mapping = {"Safe": 0, "Caution": 1, "Suspicious": 1, "High": 2} + exit_code = 0 + for result in results: + if result.error: + exit_code = max(exit_code, 3) + exit_code = max(exit_code, mapping.get(result.severity, 0)) + return exit_code + + +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") + if watch_target and not watch_target.exists(): + raise SystemExit(f"Watch directory {watch_target} does not exist") + config = ScanConfig( + max_file_size=args.max_file_size, + 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) + + +def handle_quarantine(args: argparse.Namespace) -> int: + path = args.path + dest = args.dest + if not path.exists(): + raise SystemExit(f"Path {path} does not exist") + if path.is_dir(): + for file_path in path.iterdir(): + if file_path.is_file(): + dest_path = move_to_quarantine(file_path, dest) + LOGGER.info("Moved %s to %s", file_path, dest_path) + return 0 + dest_path = move_to_quarantine(path, dest) + LOGGER.info("Moved %s to %s", path, dest_path) + return 0 + + +def handle_report(args: argparse.Namespace) -> int: + data = json.loads(args.report.read_text(encoding="utf-8")) + files = data.get("files", []) + reporters.print_console_report(files, sys.stdout) + 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:]) + configure_logging(args.verbose) + if args.command == "scan": + return handle_scan(args) + if args.command == "quarantine": + 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 + sys.exit(main()) diff --git a/scanner/quarantine.py b/scanner/quarantine.py new file mode 100644 index 0000000..bd2073f --- /dev/null +++ b/scanner/quarantine.py @@ -0,0 +1,55 @@ +"""Quarantine utilities for the Teacher-Safe Local File Scanner.""" +from __future__ import annotations + +import json +import logging +import stat +from datetime import datetime +from pathlib import Path + +from . import utils + +LOGGER = logging.getLogger(__name__) + + +def set_read_only(path: Path) -> None: + """Set read-only permissions for *path*.""" + try: + mode = path.stat().st_mode + path.chmod(mode & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH) + except OSError as exc: # pragma: no cover - platform specific + LOGGER.warning("Unable to set read-only permissions on %s: %s", path, exc) + + +def move_to_quarantine(src: Path, dest_dir: Path, *, sha256: str | None = None) -> Path: + """Move *src* into *dest_dir* in a safe, copy-then-rename fashion.""" + utils.ensure_directory(dest_dir) + destination = dest_dir / src.name + temp_destination = dest_dir / f".{src.name}.tmp" + sha256_value = sha256 or utils.sha256_stream(src) + original_path = str(src) + + try: + src.rename(temp_destination) + except OSError as exc: + LOGGER.debug("Rename failed for %s, falling back to copy", src) + utils.copy_file(src, temp_destination) + original_size = src.stat().st_size + copied_size = temp_destination.stat().st_size + if original_size == copied_size: + src.unlink() + else: + temp_destination.unlink(missing_ok=True) + raise RuntimeError("Failed to move file to quarantine: copy size mismatch") from exc + + temp_destination.rename(destination) + set_read_only(destination) + + metadata = { + "original_path": original_path, + "quarantined_at": datetime.utcnow().isoformat() + "Z", + "sha256": sha256_value, + } + metadata_path = destination.with_suffix(destination.suffix + ".meta.json") + metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") + return destination diff --git a/scanner/reporters.py b/scanner/reporters.py new file mode 100644 index 0000000..6fb1dbd --- /dev/null +++ b/scanner/reporters.py @@ -0,0 +1,213 @@ +"""Reporters for Teacher-Safe Local File Scanner.""" +from __future__ import annotations + +import html +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Iterable, List, TextIO + +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.""" + payload = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "files": results, + } + destination.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + +def render_console_table(results: List[dict]) -> str: + """Return a simple console table summarising scan results.""" + 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, + ) + ) + col_widths = [len(h) for h in headers] + 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)) + + lines = [format_row(headers), "-+-".join("-" * width for width in col_widths)] + lines.extend(format_row(row) for row in rows) + return "\n".join(lines) + + +def print_console_report(results: List[dict], stream: TextIO) -> None: + """Print the console table to *stream*.""" + 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 """ + + + + +Teacher-Safe Scanner Report + + + +
    +

    Teacher-Safe Local File Scanner

    +

    This report is generated for defensive and educational purposes. If any file is flagged, do not open it on a production machine.

    +

    Consult your IT department or open it in an isolated, school-approved sandbox.

    +
    + + + + {summary} + +
    PathSeverityScore
    +{sections} +{script} + + +""".format(summary=summary_html, sections=sections_html, css=css, script=script) + + +def write_html_report(results: List[dict], destination: Path) -> None: + """Write a standalone HTML report to *destination*.""" + destination.write_text(generate_html_report(results), encoding="utf-8") diff --git a/scanner/reporting/html_theme.css b/scanner/reporting/html_theme.css new file mode 100644 index 0000000..3067bc9 --- /dev/null +++ b/scanner/reporting/html_theme.css @@ -0,0 +1,6 @@ +.badge-low{background:#e0f2f1;color:#00695c;padding:2px 6px;border-radius:6px} +.badge-medium{background:#fff3e0;color:#e65100;padding:2px 6px;border-radius:6px} +.badge-high{background:#ffebee;color:#b71c1c;padding:2px 6px;border-radius:6px} +.details{margin:.25rem 0} +.summary-row{cursor:pointer} +.next-steps{margin-top:1rem;padding:.75rem;border:1px dashed #aaa;border-radius:8px} diff --git a/scanner/scanner_core.py b/scanner/scanner_core.py new file mode 100644 index 0000000..e356d76 --- /dev/null +++ b/scanner/scanner_core.py @@ -0,0 +1,238 @@ +"""Core orchestration for the Teacher-Safe Local File Scanner.""" +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional + +from . import detectors, heuristics, utils + +LOGGER = logging.getLogger(__name__) + +try: # pragma: no cover - optional dependency + import yara +except ImportError: # pragma: no cover + yara = None + + +@dataclass +class ScanConfig: + """Configuration for a scan operation.""" + + max_file_size: int = 100 * 1024 * 1024 + use_magic: bool = False + use_yara: bool = False + threads: int = 4 + pdf_rules: str = "normal" + office_rules: str = "normal" + zip_rules: str = "normal" + image_rules: str = "normal" + + +@dataclass +class ScanResult: + """Result produced for each scanned file.""" + + path: Path + sha256: str + size: int + magic_type: str + issues: List[Dict[str, str]] + score: int + severity: str + reasons: List[str] + error: Optional[str] = None + + def to_dict(self) -> Dict[str, object]: + return { + "path": str(self.path), + "sha256": self.sha256, + "size": self.size, + "magic_type": self.magic_type, + "issues": self.issues, + "score": self.score, + "severity": self.severity, + "reasons": self.reasons, + **({"error": self.error} if self.error else {}), + } + + +YARA_RULES = """ +rule TeacherSafeSuspiciousStrings { + strings: + $mz = "This program cannot be run in DOS mode" + $powershell = "powershell" + condition: + any of them +} +""" + + +def _run_yara(path: Path) -> List[Dict[str, str]]: + if yara is None: + return [] + try: + rules = yara.compile(source=YARA_RULES) + matches = rules.match(str(path)) + findings: List[Dict[str, str]] = [] + for match in matches: + if match.strings: + evidence = ",".join(s[2] for s in match.strings) + else: + evidence = match.rule + findings.append( + { + "code": f"yara_{match.rule}", + "description": "YARA rule matched potential suspicious content", + "evidence": evidence, + } + ) + return findings + except Exception as exc: # pragma: no cover - optional path + LOGGER.warning("YARA scanning failed for %s: %s", path, exc) + return [] + + +def _normalize_rule_findings(rule_findings: List[Dict]) -> List[Dict[str, str]]: + issues: List[Dict[str, str]] = [] + for finding in rule_findings: + rule = str(finding.get("rule", "rule")) + detail = str(finding.get("detail", "")) + issue: Dict[str, str] = { + "code": rule, + "description": detail, + } + severity = finding.get("severity") + if severity: + issue["severity"] = str(severity) + issues.append(issue) + return issues + + +def _deduplicate(findings: List[Dict[str, str]]) -> List[Dict[str, str]]: + seen: set[tuple[str | None, str | None, str | None]] = set() + unique: List[Dict[str, str]] = [] + for finding in findings: + key = ( + finding.get("code"), + finding.get("evidence"), + finding.get("description"), + ) + if key in seen: + continue + seen.add(key) + unique.append(finding) + return unique + + +def _collect_findings(path: Path, magic_type: str, config: ScanConfig) -> List[Dict[str, str]]: + findings: List[Dict[str, str]] = [] + maybe = detectors.detect_double_extension(path) + if maybe: + findings.append(maybe) + maybe = detectors.detect_pe_headers(path) + if maybe: + findings.append(maybe) + + suffix = path.suffix.lower() + if magic_type == "zip" or suffix in {".zip", ".docx", ".pptx", ".xlsx"}: + findings.extend(detectors.detect_zip_contents(path)) + if magic_type == "pdf" or suffix == ".pdf": + findings.extend(detectors.detect_pdf_risks(path)) + if suffix in {".png", ".jpg", ".jpeg"}: + maybe = detectors.detect_image_appended_data(path) + if maybe: + findings.append(maybe) + maybe = detectors.detect_office_macro(path) + if maybe: + findings.append(maybe) + findings.extend(detectors.extract_urls_and_flag(path)) + + if config.pdf_rules != "off" and (magic_type == "pdf" or suffix == ".pdf"): + try: + with path.open("rb") as handle: + findings.extend( + _normalize_rule_findings( + detectors.analyze_pdf(handle, strict=config.pdf_rules == "strict") + ) + ) + except OSError as exc: + LOGGER.warning("Unable to run PDF rules for %s: %s", path, exc) + if config.office_rules != "off" and suffix in {".docx", ".pptx", ".xlsx", ".docm", ".xlsm", ".pptm"}: + try: + with path.open("rb") as handle: + findings.extend( + _normalize_rule_findings( + detectors.analyze_office(handle, strict=config.office_rules == "strict") + ) + ) + except OSError as exc: + LOGGER.warning("Unable to run Office rules for %s: %s", path, exc) + if config.zip_rules != "off" and (magic_type == "zip" or suffix == ".zip"): + try: + with path.open("rb") as handle: + findings.extend( + _normalize_rule_findings( + detectors.analyze_zip(handle, strict=config.zip_rules == "strict") + ) + ) + except OSError as exc: + LOGGER.warning("Unable to run ZIP rules for %s: %s", path, exc) + if config.image_rules != "off" and suffix in {".png", ".jpg", ".jpeg"}: + try: + with path.open("rb") as handle: + findings.extend( + _normalize_rule_findings( + detectors.analyze_image(handle, strict=config.image_rules == "strict") + ) + ) + except OSError as exc: + LOGGER.warning("Unable to run image rules for %s: %s", path, exc) + + if config.use_yara: + findings.extend(_run_yara(path)) + return _deduplicate(findings) + + +def _scan_file(path: Path, config: ScanConfig) -> ScanResult: + try: + size = path.stat().st_size + except OSError as exc: + return ScanResult(path, "", 0, "unknown", [], 0, "Safe", [], error=str(exc)) + + if size > config.max_file_size: + return ScanResult( + path, + "", + size, + "unknown", + [{"code": "skipped_large", "description": "File skipped due to size"}], + 0, + "Safe", + ["skipped_large"], + ) + + sha256 = utils.sha256_stream(path) + magic_type = utils.detect_magic_type(path, use_magic=config.use_magic) + findings = _collect_findings(path, magic_type, config) + score, severity, reasons = heuristics.calculate_score(findings) + return ScanResult(path, sha256, size, magic_type, findings, score, severity, reasons) + + +def scan(root: Path, config: ScanConfig) -> List[ScanResult]: + """Scan *root* recursively and return a list of :class:`ScanResult`.""" + targets = list(utils.iter_directory_files(root)) + results: List[ScanResult] = [] + if not targets: + LOGGER.info("No files found for scanning in %s", root) + return results + + with ThreadPoolExecutor(max_workers=config.threads) as executor: + future_map = {executor.submit(_scan_file, path, config): path for path in targets} + for future in as_completed(future_map): + result = future.result() + results.append(result) + results.sort(key=lambda res: res.path) + return results diff --git a/scanner/utils.py b/scanner/utils.py new file mode 100644 index 0000000..cfdcf9f --- /dev/null +++ b/scanner/utils.py @@ -0,0 +1,141 @@ +"""Utility helpers for the Teacher-Safe Local File Scanner. + +This module provides pure helper utilities shared across the project. The +functions defined here do **not** execute untrusted content and are limited to +reading metadata, hashes, and small sections of files. +""" +from __future__ import annotations + +import hashlib +import logging +import os +import shutil +from pathlib import Path +from typing import Iterator + +LOGGER = logging.getLogger(__name__) + + +CHUNK_SIZE = 1024 * 1024 + + +def sha256_stream(path: Path) -> str: + """Return the SHA256 digest of *path* using a streaming read.""" + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(CHUNK_SIZE), b""): + digest.update(chunk) + return digest.hexdigest() + + +def safe_read_head(path: Path, nbytes: int) -> bytes: + """Read up to *nbytes* bytes from the beginning of *path* safely.""" + try: + with path.open("rb") as handle: + return handle.read(nbytes) + except (OSError, IOError) as exc: # pragma: no cover - exercised indirectly + LOGGER.warning("Unable to read head of %s: %s", path, exc) + return b"" + + +def safe_read_tail(path: Path, nbytes: int) -> bytes: + """Read the last *nbytes* bytes of *path* without loading the file.""" + try: + size = path.stat().st_size + except OSError as exc: # pragma: no cover - exercised indirectly + LOGGER.warning("Unable to stat %s: %s", path, exc) + return b"" + if size == 0: + return b"" + offset = max(size - nbytes, 0) + try: + with path.open("rb") as handle: + handle.seek(offset) + return handle.read(nbytes) + except (OSError, IOError) as exc: # pragma: no cover + LOGGER.warning("Unable to read tail of %s: %s", path, exc) + return b"" + + +MAGIC_SIGNATURES = { + b"%PDF": "pdf", + b"PK\x03\x04": "zip", + b"PK\x05\x06": "zip", + b"PK\x07\x08": "zip", + b"\xFF\xD8\xFF": "jpeg", + b"\x89PNG\r\n\x1A\n": "png", + b"MZ": "pe", +} + + +try: # pragma: no cover - optional dependency + import magic +except ImportError: # pragma: no cover - no optional dep in tests + magic = None + + +def detect_magic_type(path: Path, *, use_magic: bool = False) -> str: + """Detect the file type using python-magic if enabled, otherwise magic bytes.""" + if use_magic and magic is not None: + try: + mime = magic.from_file(str(path), mime=True) + return mime or "unknown" + except Exception as exc: # pragma: no cover - optional path + LOGGER.debug("python-magic failed for %s: %s", path, exc) + head = safe_read_head(path, 16) + for signature, label in MAGIC_SIGNATURES.items(): + if head.startswith(signature): + return label + return "unknown" + + +def is_text_file(path: Path, *, max_bytes: int = 4096) -> bool: + """Heuristic to decide whether *path* appears to be text.""" + head = safe_read_head(path, max_bytes) + if not head: + return False + if b"\x00" in head: + return False + try: + head.decode("utf-8") + return True + except UnicodeDecodeError: + return False + + +def safe_list_zip_members(path: Path) -> list[str]: + """Return the member list of a zip archive, handling corruption gracefully.""" + import zipfile + + members: list[str] = [] + try: + with zipfile.ZipFile(path) as archive: + for info in archive.infolist(): + members.append(info.filename) + except zipfile.BadZipFile as exc: + LOGGER.warning("Corrupt ZIP %s: %s", path, exc) + except OSError as exc: + LOGGER.warning("Unable to open ZIP %s: %s", path, exc) + return members + + +def iter_directory_files(root: Path) -> Iterator[Path]: + """Yield files within *root* recursively.""" + if root.is_file(): + yield root + return + for dirpath, _, filenames in os.walk(root): + base = Path(dirpath) + for filename in filenames: + yield base / filename + + +def copy_file(src: Path, dest: Path) -> None: + """Copy *src* to *dest* using buffered IO.""" + with src.open("rb") as source, dest.open("wb") as target: + shutil.copyfileobj(source, target, length=CHUNK_SIZE) + + +def ensure_directory(path: Path) -> None: + """Ensure *path* exists as a directory.""" + path.mkdir(parents=True, exist_ok=True) diff --git a/scripts/build_pyinstaller.ps1 b/scripts/build_pyinstaller.ps1 new file mode 100644 index 0000000..45eb4bf --- /dev/null +++ b/scripts/build_pyinstaller.ps1 @@ -0,0 +1,8 @@ +param([string]$Entry="scanner/gui.py", [string]$Name="TeacherSafeScanner") + +pyinstaller --noconfirm --clean ` + --name $Name ` + --onefile ` + --windowed ` + --add-data "scanner/reporting/html_theme.css;scanner/reporting" ` + $Entry diff --git a/scripts/build_pyinstaller.sh b/scripts/build_pyinstaller.sh new file mode 100755 index 0000000..0fd1c21 --- /dev/null +++ b/scripts/build_pyinstaller.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +ENTRY="${1:-scanner/gui.py}" +NAME="${2:-TeacherSafeScanner}" +pyinstaller --noconfirm --clean \ + --name "$NAME" \ + --onefile \ + --windowed \ + --add-data "scanner/reporting/html_theme.css:scanner/reporting" \ + "$ENTRY" diff --git a/scripts/windows_add_context_menu.reg b/scripts/windows_add_context_menu.reg new file mode 100644 index 0000000..f384e3a --- /dev/null +++ b/scripts/windows_add_context_menu.reg @@ -0,0 +1,7 @@ +Windows Registry Editor Version 5.00 + +[HKEY_CLASSES_ROOT\*\shell\Scan with Teacher-Safe] +@="Scan with Teacher-Safe" + +[HKEY_CLASSES_ROOT\*\shell\Scan with Teacher-Safe\command] +@="\"%ProgramFiles%\\TeacherSafe\\TeacherSafeScanner.exe\" \"%1\"" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..060fb67 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[metadata] +license_file = LICENSE + +[mypy] +python_version = 3.10 +ignore_missing_imports = True +warn_unused_ignores = True +warn_return_any = True diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6938c11 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +@pytest.fixture(scope="session", autouse=True) +def generate_examples() -> None: + """Ensure benign sample files are materialised before tests run.""" + from examples.generate_benign_samples import main as generate + + generate() diff --git a/tests/test_detectors.py b/tests/test_detectors.py new file mode 100644 index 0000000..51769a6 --- /dev/null +++ b/tests/test_detectors.py @@ -0,0 +1,36 @@ +from scanner import detectors + + +def test_detect_double_extension(tmp_path): + path = tmp_path / "essay.pdf.exe" + path.write_bytes(b"MZ") + issue = detectors.detect_double_extension(path) + assert issue and issue["code"] == "double_extension" + + +def test_detect_pdf_risks(tmp_path): + path = tmp_path / "test.pdf" + path.write_bytes(b"%PDF-1.4 /JavaScript") + findings = detectors.detect_pdf_risks(path) + assert any(f["code"] == "pdf_token" for f in findings) + + +def test_detect_zip_contents_flags_macro(tmp_path): + import zipfile + + path = tmp_path / "doc.zip" + with zipfile.ZipFile(path, "w") as zf: + zf.writestr("word/vbaProject.bin", b"dummy") + zf.writestr("payload.exe", b"MZ") + findings = detectors.detect_zip_contents(path) + codes = {finding["code"] for finding in findings} + assert "exe_in_zip" in codes + assert "zip_vba_project" in codes + + +def test_extract_urls_and_flag(tmp_path): + path = tmp_path / "notes.txt" + path.write_text("Visit http://xn--example.com for details", encoding="utf-8") + findings = detectors.extract_urls_and_flag(path) + assert findings + assert findings[0]["code"] in {"url", "url_suspicious"} diff --git a/tests/test_heuristics.py b/tests/test_heuristics.py new file mode 100644 index 0000000..1cc5f9e --- /dev/null +++ b/tests/test_heuristics.py @@ -0,0 +1,20 @@ +from scanner import heuristics + + +def test_calculate_score_with_multiple_findings(): + findings = [ + {"code": "exe_in_zip"}, + {"code": "pdf_token"}, + {"code": "url"}, + ] + score, label, reasons = heuristics.calculate_score(findings) + assert score >= 60 + assert label in {"Suspicious", "High"} + assert any("exe_in_zip" in reason for reason in reasons) + + +def test_calculate_score_safe_when_empty(): + score, label, reasons = heuristics.calculate_score([]) + assert score == 0 + assert label == "Safe" + assert reasons == [] diff --git a/tests/test_image_rules.py b/tests/test_image_rules.py new file mode 100644 index 0000000..5ed9858 --- /dev/null +++ b/tests/test_image_rules.py @@ -0,0 +1,13 @@ +from pathlib import Path + +from scanner.detectors.image_rules import analyze_image +from tests.utils_make_samples import make_png_with_appended + + +def test_png_appended_data(tmp_path: Path) -> None: + sample = tmp_path / "sample.png" + make_png_with_appended(sample) + with sample.open("rb") as handle: + findings = analyze_image(handle, strict=False) + rules = {finding["rule"] for finding in findings} + assert "png_appended_data" in rules diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..1bd90ce --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,12 @@ +from pathlib import Path + +from scanner.scanner_core import ScanConfig, scan + + +def test_scan_examples_benign_samples(): + sample_dir = Path(__file__).resolve().parent.parent / "examples" / "benign_samples" + results = scan(sample_dir, ScanConfig(max_file_size=5_000_000, threads=2)) + assert results + for result in results: + assert result.severity in {"Safe", "Caution"} + assert result.score < 50 diff --git a/tests/test_office_rules.py b/tests/test_office_rules.py new file mode 100644 index 0000000..2811b0b --- /dev/null +++ b/tests/test_office_rules.py @@ -0,0 +1,13 @@ +from pathlib import Path + +from scanner.detectors.office_rules import analyze_office +from tests.utils_make_samples import make_office_with_vba + + +def test_office_vba_detection(tmp_path: Path) -> None: + sample = tmp_path / "sample.docx" + make_office_with_vba(sample) + with sample.open("rb") as handle: + findings = analyze_office(handle, strict=False) + rules = {finding["rule"] for finding in findings} + assert "office_vba_project" in rules diff --git a/tests/test_pdf_rules.py b/tests/test_pdf_rules.py new file mode 100644 index 0000000..ef77c81 --- /dev/null +++ b/tests/test_pdf_rules.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from scanner.detectors.pdf_rules import analyze_pdf +from tests.utils_make_samples import make_pdf_with_js + + +def test_pdf_js(tmp_path: Path) -> None: + sample = tmp_path / "sample.pdf" + make_pdf_with_js(sample) + with sample.open("rb") as handle: + findings = analyze_pdf(handle, strict=True) + rules = {finding["rule"] for finding in findings} + assert "pdf_javascript" in rules + assert "pdf_auto_actions" in rules diff --git a/tests/test_zip_rules.py b/tests/test_zip_rules.py new file mode 100644 index 0000000..d436ba3 --- /dev/null +++ b/tests/test_zip_rules.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from scanner.detectors.zip_rules import analyze_zip +from tests.utils_make_samples import make_zip_with_double_ext + + +def test_zip_double_ext(tmp_path: Path) -> None: + sample = tmp_path / "sample.zip" + make_zip_with_double_ext(sample) + with sample.open("rb") as handle: + findings = analyze_zip(handle, strict=False) + rules = {finding["rule"] for finding in findings} + assert "zip_double_extension" in rules + assert "zip_susp_executable" in rules diff --git a/tests/utils_make_samples.py b/tests/utils_make_samples.py new file mode 100644 index 0000000..ac13284 --- /dev/null +++ b/tests/utils_make_samples.py @@ -0,0 +1,35 @@ +from pathlib import Path + +def write_file(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + +def make_pdf_with_js(path: Path) -> None: + """Create a minimal PDF payload that includes risky tokens.""" + write_file( + path, + b"%PDF-1.4\n1 0 obj\n<< /OpenAction 2 0 R /JavaScript 3 0 R >>\nendobj\n%%EOF", + ) + +def make_zip_with_double_ext(path: Path) -> None: + import io + import zipfile + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("homework.png.exe", b"fake") + archive.writestr("notes.txt", b"ok") + path.write_bytes(buffer.getvalue()) + +def make_office_with_vba(path: Path) -> None: + import io + import zipfile + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("word/vbaProject.bin", b"macro") + path.write_bytes(buffer.getvalue()) + +def make_png_with_appended(path: Path) -> None: + data = b"\x89PNG\r\n\x1a\n...IEND" + b"x" * 20 + write_file(path, data)