Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,12 @@ To update your global installation to the later version:
```bash
cd /path/to/guarddoc
git pull
uv tool install --force .
uv tool install --reinstall .

#if it's not working uninstall:
uv tool uninstall guarddoc
#and then
uv tool install .
```


Expand Down Expand Up @@ -287,7 +292,10 @@ Aby zaktualizować wersje lokalnego guarddoc'a:
```bash
cd /path/to/guarddoc
git pull
uv tool install --force .
uv tool install --reinstall .
#jeżeli nie działa to odinstaluj:
uv tool uninstall guarddoc
uv tool install .
```

---
Expand Down Expand Up @@ -346,7 +354,7 @@ guarddoc config set --rules-dir /sciezka/do/regul

# Wyświetl aktualne ustawienia i ścieżkę pliku konfiguracyjnego
guarddoc config show

```
---

## Testowanie i Jakość Kodu
Expand Down
54 changes: 43 additions & 11 deletions src/guarddoc/core/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,53 @@ class Language(str, Enum):
Language.PL: "Plik ma rozszerzenie '{ext}', ale jego wewnętrzna struktura to plik wykonywalny/skrypt ({mime}).",
Language.EN: "The file has extension '{ext}', but its internal structure is an executable/script ({mime}).",
},
"PDF-JS-TITLE": {
Language.PL: "Wykryto skrypt JavaScript w pliku PDF",
Language.EN: "JavaScript script detected in PDF file",
"PDF-JAVASCRIPT-TITLE": {
Language.PL: "Wykryto kod JavaScript wewnątrz pliku PDF",
Language.EN: "Embedded JavaScript detected in PDF",
},
"PDF-JS-DESC": {
Language.PL: "Plik PDF zawiera obiekty /JS lub /JavaScript pozwalające na wykonywanie kodu.",
Language.EN: "PDF file contains /JS or /JavaScript objects allowing code execution.",
"PDF-JAVASCRIPT-DESC": {
Language.PL: "Plik PDF zawiera osadzony kod JavaScript (/JavaScript), który może wykonywać złośliwe akcje w czytniku PDF.",
Language.EN: "PDF contains embedded JavaScript (/JavaScript), which may execute arbitrary actions in PDF viewers.",
},
"PDF-JS-INLINE-TITLE": {
Language.PL: "Wykryto skrypt /JS w dokumencie PDF",
Language.EN: "Inline /JS script action detected in PDF",
},
"PDF-JS-INLINE-DESC": {
Language.PL: "Strumień akcji PDF zawiera zdefiniowany skrypt /JS.",
Language.EN: "PDF action stream contains inline /JS script definition.",
},
"PDF-LAUNCH-TITLE": {
Language.PL: "Wykryto automatyczne uruchamianie procesu (/Launch)",
Language.EN: "Process execution action (/Launch) detected in PDF",
},
"PDF-LAUNCH-DESC": {
Language.PL: "Plik PDF próbuje uruchomić zewnętrzny program lub skrypt za pomocą instrukcji /Launch.",
Language.EN: "PDF attempts to execute external programs or commands via /Launch action.",
},
"PDF-OPENACTION-TITLE": {
Language.PL: "Wykryto automatyczną akcję uruchamiania (/OpenAction)",
Language.EN: "Automated launch action detected (/OpenAction)",
Language.PL: "Wykryto akcję automatyczną (/OpenAction)",
Language.EN: "Automatic trigger action (/OpenAction) detected",
},
"PDF-OPEN-ACTION-DESC": {
Language.PL: "Dokument zawiera instrukcję /OpenAction uruchamianą natychmiast po otwarciu pliku.",
Language.EN: "Document contains /OpenAction trigger executed automatically on open.",
},
"PDF-AA-TITLE": {
Language.PL: "Wykryto dodatkowe akcje zdarzeń (/AA)",
Language.EN: "Additional Actions (/AA) triggers detected",
},
"PDF-ADDITIONAL-ACTIONS-DESC": {
Language.PL: "Dokument definiuje akcje powiązane ze zdarzeniami strony lub pól formularza (/AA).",
Language.EN: "Document defines additional event-based action triggers (/AA).",
},
"PDF-EMBEDDED-EXE-TITLE": {
Language.PL: "Osadzony plik wykonywalny wewnątrz PDF",
Language.EN: "Embedded executable inside PDF",
},
"PDF-OPENACTION-DESC": {
Language.PL: "Plik PDF wywołuje akcje natychmiast po otwarciu dokumentu.",
Language.EN: "PDF file triggers actions immediately upon opening.",
"PDF-EMBEDDED-EXE-DESC": {
Language.PL: "W strukturze PDF wykryto załączony bezpośrednio plik wykonywalny .exe.",
Language.EN: "Directly embedded .exe file detected in PDF structure.",
},
"TEXT-RTLO-TITLE": {
Language.PL: "Wykryto atak Unicode RTLO (Right-To-Left Override)",
Expand Down
100 changes: 67 additions & 33 deletions src/guarddoc/scanners/pdf.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,96 @@
from __future__ import annotations

import re
from pathlib import Path
from typing import ClassVar

from guarddoc.core.i18n import Language, get_text
from guarddoc.core.models import Severity, Threat
from guarddoc.scanners.base import BaseScanner


class PdfScanner(BaseScanner):
"""Scanner analyzing PDF object structure for dangerous active content and triggers."""
"""Scanner for PDF files to detect suspicious structures, scripts, and embedded exploits."""

PDF_EXTENSIONS: ClassVar[set[str]] = {".pdf"}

MALICIOUS_PATTERNS: ClassVar[dict[str, tuple[re.Pattern[bytes], str, Severity]]] = {
"PDF-JAVASCRIPT": (
re.compile(rb"/JavaScript\b", re.IGNORECASE),
"PDF-JAVASCRIPT-TITLE",
Severity.HIGH,
),
"PDF-JS-INLINE": (
re.compile(rb"/JS\b", re.IGNORECASE),
"PDF-JS-INLINE-TITLE",
Severity.HIGH,
),
"PDF-LAUNCH-ACTION": (
re.compile(rb"/Launch\b", re.IGNORECASE),
"PDF-LAUNCH-TITLE",
Severity.CRITICAL,
),
"PDF-OPEN-ACTION": (
re.compile(rb"/OpenAction\b", re.IGNORECASE),
"PDF-OPENACTION-TITLE",
Severity.HIGH,
),
"PDF-ADDITIONAL-ACTIONS": (
re.compile(rb"/AA\b", re.IGNORECASE),
"PDF-AA-TITLE",
Severity.HIGH,
),
}

name: str = "PdfScanner"
description: str = (
"Detects embedded JavaScript, OpenAction triggers, and suspicious PDF objects"
)
@property
def name(self) -> str:
return "PdfScanner"

def is_supported(self, file_path: Path, mime_type: str = "unknown") -> bool:
"""Checks if the file is a PDF based on extension or MIME type."""
return file_path.suffix.lower() == ".pdf" or "pdf" in mime_type.lower()
def is_supported(self, file_path: Path, mime_type: str | None) -> bool:
ext = file_path.suffix.lower()
mime = (mime_type or "").lower()
return ext in self.PDF_EXTENSIONS or "pdf" in mime

def scan(
self,
file_path: Path,
mime_type: str = "unknown",
mime_type: str | None = None,
lang: Language = Language.PL,
) -> list[Threat]:
"""Scans PDF bytes for active content (/JavaScript, /JS) and automated actions (/OpenAction, /AA)."""
threats: list[Threat] = []

if not self.is_supported(file_path, mime_type):
return threats

try:
content = file_path.read_bytes()
except Exception: # noqa: BLE001
raw_content = file_path.read_bytes()
except OSError:
return threats

# Detect embedded JavaScript elements
if b"/JS" in content or b"/JavaScript" in content:
threats.append(
Threat(
rule_id="PDF-EMBEDDED-JS",
scanner_name=self.name,
title=get_text("PDF-JS-TITLE", lang=lang),
description=get_text("PDF-JS-DESC", lang=lang),
severity=Severity.HIGH,
has_js = bool(
re.search(rb"/JavaScript\b", raw_content, re.IGNORECASE)
or re.search(rb"/JS\b", raw_content, re.IGNORECASE)
)

for rule_id, (pattern, title_key, severity) in self.MALICIOUS_PATTERNS.items():
if pattern.search(raw_content):
if rule_id == "PDF-OPEN-ACTION" and b"/GoTo" in raw_content and not has_js:
continue

threats.append(
Threat(
scanner_name=self.name,
rule_id=rule_id,
title=get_text(title_key, lang=lang),
description=get_text(f"{rule_id}-DESC", lang=lang),
severity=severity,
context={"matched_rule": rule_id},
)
)
)

# Detect automatic launch actions on document open
if b"/OpenAction" in content or b"/AA" in content:
if re.search(rb"/Type\s*/Filespec.*\.exe", raw_content, re.IGNORECASE):
threats.append(
Threat(
rule_id="PDF-OPEN-ACTION",
scanner_name=self.name,
title=get_text("PDF-OPENACTION-TITLE", lang=lang),
description=get_text("PDF-OPENACTION-DESC", lang=lang),
severity=Severity.HIGH,
rule_id="PDF-EMBEDDED-EXECUTABLE",
title=get_text("PDF-EMBEDDED-EXE-TITLE", lang=lang),
description=get_text("PDF-EMBEDDED-EXE-DESC", lang=lang),
severity=Severity.CRITICAL,
)
)

Expand Down
59 changes: 46 additions & 13 deletions tests/test_pdf_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,43 +8,76 @@
def test_pdf_scanner_detects_embedded_javascript(tmp_path: Path) -> None:
pdf_file = tmp_path / "sample_js.pdf"
pdf_file.write_bytes(
b"%PDF-1.4\n1 0 obj\n<< /Type /Action /S /JavaScript /JS (app.alert(1);) >>\nendobj"
b"%PDF-1.4\n1 0 obj\n<< /Type /Action /S /JavaScript /JS (app.alert(1);) >>\nendobj\n%%EOF"
)

scanner = PdfScanner()
assert scanner.is_supported(pdf_file, "application/pdf")

threats = scanner.scan(pdf_file, mime_type="application/pdf", lang=Language.EN)
assert len(threats) == 1
assert threats[0].rule_id == "PDF-EMBEDDED-JS"
assert threats[0].severity == Severity.HIGH
assert threats[0].scanner_name == "PdfScanner"
assert len(threats) >= 1

rule_ids = [t.rule_id for t in threats]
assert any("PDF-JAVASCRIPT" in r or "PDF-JS" in r or "PDF-EMBEDDED-JS" in r for r in rule_ids)
assert all(t.scanner_name == "PdfScanner" for t in threats)
assert any(t.severity in (Severity.HIGH, Severity.CRITICAL) for t in threats)


def test_pdf_scanner_detects_openaction_and_aa(tmp_path: Path) -> None:
pdf_file = tmp_path / "sample_auto.pdf"
pdf_file.write_bytes(b"%PDF-1.7\n<< /Type /Catalog /OpenAction 2 0 R /AA << >> >>")
pdf_file.write_bytes(b"%PDF-1.7\n<< /Type /Catalog /OpenAction 2 0 R /AA << >> >>\n%%EOF")

scanner = PdfScanner()
threats = scanner.scan(pdf_file, mime_type="application/pdf", lang=Language.PL)
assert len(threats) == 1
assert threats[0].rule_id == "PDF-OPEN-ACTION"
assert threats[0].severity == Severity.HIGH

assert len(threats) >= 1
rule_ids = [t.rule_id for t in threats]
assert any(
"OPENACTION" in r or "OPEN-ACTION" in r or "ADDITIONAL-ACTIONS" in r for r in rule_ids
)
assert all(t.scanner_name == "PdfScanner" for t in threats)


def test_pdf_scanner_allows_benign_overleaf_latex_pdf(tmp_path: Path) -> None:
"""Overleaf LaTeX PDFs use /OpenAction with /GoTo for initial page view and should be safe."""
latex_pdf = tmp_path / "overleaf_paper.pdf"
latex_pdf.write_bytes(
b"%PDF-1.5\n"
b"1 0 obj\n<< /Type /Catalog /OpenAction [ 3 0 R /Fit ] /Pages 2 0 R >>\nendobj\n"
b"2 0 obj\n<< /Type /Pages /Count 1 /Kids [ 3 0 R ] >>\nendobj\n"
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>\nendobj\n"
b"4 0 obj\n<< /Length 12 >>\nstream\n/GoTo /Fit\nendstream\nendobj\n"
b"%%EOF"
)

scanner = PdfScanner()
threats = scanner.scan(latex_pdf, mime_type="application/pdf", lang=Language.EN)
assert len(threats) == 0


def test_pdf_scanner_clean_pdf(tmp_path: Path) -> None:
clean_pdf = tmp_path / "clean.pdf"
clean_pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj")
clean_pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n%%EOF")

scanner = PdfScanner()
threats = scanner.scan(clean_pdf, mime_type="application/pdf", lang=Language.EN)
assert len(threats) == 0


def test_pdf_scanner_handles_none_mime_type(tmp_path: Path) -> None:
pdf_file = tmp_path / "document.pdf"
pdf_file.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n%%EOF")

scanner = PdfScanner()
# Ensure checking support and scanning with mime_type=None doesn't raise TypeError
assert scanner.is_supported(pdf_file, None) is True
threats = scanner.scan(pdf_file, mime_type=None, lang=Language.EN)
assert len(threats) == 0


def test_pdf_scanner_skips_non_pdf(tmp_path: Path) -> None:
txt_file = tmp_path / "doc.txt"
txt_file.write_text("Hello World with /JS inside text")
txt_file.write_text("Hello World with /JS and /OpenAction inside text", encoding="utf-8")

scanner = PdfScanner()
threats = scanner.scan(txt_file, mime_type="text/plain", lang=Language.EN)
assert len(threats) == 0
assert scanner.is_supported(txt_file, "text/plain") is False
Loading