evadex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evadex/__init__.py +5 -0
- evadex/__main__.py +3 -0
- evadex/adapters/__init__.py +0 -0
- evadex/adapters/base.py +49 -0
- evadex/adapters/dlpscan/__init__.py +0 -0
- evadex/adapters/dlpscan/adapter.py +69 -0
- evadex/adapters/dlpscan/client.py +74 -0
- evadex/adapters/dlpscan/file_builder.py +112 -0
- evadex/adapters/dlpscan_cli/__init__.py +0 -0
- evadex/adapters/dlpscan_cli/adapter.py +56 -0
- evadex/cli/__init__.py +0 -0
- evadex/cli/app.py +15 -0
- evadex/cli/commands/__init__.py +0 -0
- evadex/cli/commands/scan.py +114 -0
- evadex/core/__init__.py +0 -0
- evadex/core/engine.py +81 -0
- evadex/core/registry.py +46 -0
- evadex/core/result.py +76 -0
- evadex/payloads/__init__.py +0 -0
- evadex/payloads/builtins.py +85 -0
- evadex/reporters/__init__.py +0 -0
- evadex/reporters/base.py +12 -0
- evadex/reporters/html_reporter.py +135 -0
- evadex/reporters/json_reporter.py +32 -0
- evadex/variants/__init__.py +0 -0
- evadex/variants/base.py +20 -0
- evadex/variants/delimiter.py +55 -0
- evadex/variants/encoding.py +150 -0
- evadex/variants/leetspeak.py +36 -0
- evadex/variants/regional_digits.py +70 -0
- evadex/variants/splitting.py +54 -0
- evadex/variants/structural.py +34 -0
- evadex/variants/unicode_encoding.py +100 -0
- evadex-0.1.0.dist-info/METADATA +412 -0
- evadex-0.1.0.dist-info/RECORD +39 -0
- evadex-0.1.0.dist-info/WHEEL +5 -0
- evadex-0.1.0.dist-info/entry_points.txt +2 -0
- evadex-0.1.0.dist-info/licenses/LICENSE +21 -0
- evadex-0.1.0.dist-info/top_level.txt +1 -0
evadex/__init__.py
ADDED
evadex/__main__.py
ADDED
|
File without changes
|
evadex/adapters/base.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from evadex.core.result import Payload, Variant, ScanResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AdapterError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class AdapterConfig:
|
|
13
|
+
base_url: str = "http://localhost:8080"
|
|
14
|
+
api_key: Optional[str] = None
|
|
15
|
+
timeout: float = 30.0
|
|
16
|
+
extra: dict = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_dict(cls, d: dict) -> "AdapterConfig":
|
|
20
|
+
extra = {k: v for k, v in d.items() if k not in ("base_url", "api_key", "timeout")}
|
|
21
|
+
return cls(
|
|
22
|
+
base_url=d.get("base_url", "http://localhost:8080"),
|
|
23
|
+
api_key=d.get("api_key"),
|
|
24
|
+
timeout=float(d.get("timeout", 30.0)),
|
|
25
|
+
extra=extra,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BaseAdapter(ABC):
|
|
30
|
+
name: str = "base"
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: "dict | AdapterConfig"):
|
|
33
|
+
if isinstance(config, dict):
|
|
34
|
+
self.config = AdapterConfig.from_dict(config)
|
|
35
|
+
else:
|
|
36
|
+
self.config = config
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
async def submit(self, payload: Payload, variant: Variant) -> ScanResult:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
async def setup(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
async def teardown(self):
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
async def health_check(self) -> bool:
|
|
49
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from evadex.adapters.base import BaseAdapter, AdapterConfig
|
|
2
|
+
from evadex.adapters.dlpscan.client import DlpscanClient
|
|
3
|
+
from evadex.adapters.dlpscan.file_builder import FileBuilder
|
|
4
|
+
from evadex.core.registry import register_adapter
|
|
5
|
+
from evadex.core.result import Payload, Variant, ScanResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register_adapter("dlpscan")
|
|
9
|
+
class DlpscanAdapter(BaseAdapter):
|
|
10
|
+
name = "dlpscan"
|
|
11
|
+
|
|
12
|
+
def __init__(self, config):
|
|
13
|
+
super().__init__(config)
|
|
14
|
+
self._client = DlpscanClient(
|
|
15
|
+
base_url=self.config.base_url,
|
|
16
|
+
api_key=self.config.api_key,
|
|
17
|
+
timeout=self.config.timeout,
|
|
18
|
+
)
|
|
19
|
+
# Key in response JSON that indicates detection. Configurable via adapter extra config.
|
|
20
|
+
self._detected_key = self.config.extra.get("response_detected_key", "detected")
|
|
21
|
+
|
|
22
|
+
async def setup(self):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
async def teardown(self):
|
|
26
|
+
await self._client.close()
|
|
27
|
+
|
|
28
|
+
async def health_check(self) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
await self._client.get_health()
|
|
31
|
+
return True
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
async def submit(self, payload: Payload, variant: Variant) -> ScanResult:
|
|
36
|
+
strategy = variant.strategy
|
|
37
|
+
if strategy == "text":
|
|
38
|
+
raw = await self._client.post_text(variant.value)
|
|
39
|
+
else:
|
|
40
|
+
data, mime = FileBuilder.build(variant.value, strategy)
|
|
41
|
+
filename = f"evadex_test.{strategy}"
|
|
42
|
+
raw = await self._client.upload_file(data, filename, mime)
|
|
43
|
+
|
|
44
|
+
detected = self._parse_response(raw)
|
|
45
|
+
return ScanResult(payload=payload, variant=variant, detected=detected, raw_response=raw)
|
|
46
|
+
|
|
47
|
+
def _parse_response(self, raw: dict) -> bool:
|
|
48
|
+
# Try configured key first
|
|
49
|
+
if self._detected_key in raw:
|
|
50
|
+
val = raw[self._detected_key]
|
|
51
|
+
if isinstance(val, bool):
|
|
52
|
+
return val
|
|
53
|
+
if isinstance(val, (int, float)):
|
|
54
|
+
return bool(val)
|
|
55
|
+
if isinstance(val, str):
|
|
56
|
+
return val.lower() in ('true', '1', 'yes', 'detected')
|
|
57
|
+
|
|
58
|
+
# Try common response shapes
|
|
59
|
+
for key in ("detected", "found", "matches", "findings", "alert", "flagged"):
|
|
60
|
+
if key in raw:
|
|
61
|
+
val = raw[key]
|
|
62
|
+
if isinstance(val, bool):
|
|
63
|
+
return val
|
|
64
|
+
if isinstance(val, list):
|
|
65
|
+
return len(val) > 0
|
|
66
|
+
if isinstance(val, (int, float)):
|
|
67
|
+
return bool(val)
|
|
68
|
+
|
|
69
|
+
return False
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from evadex.adapters.base import AdapterError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DlpscanClient:
|
|
7
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None, timeout: float = 30.0):
|
|
8
|
+
self.base_url = base_url.rstrip('/')
|
|
9
|
+
headers = {"Content-Type": "application/json"}
|
|
10
|
+
if api_key:
|
|
11
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
12
|
+
self._headers = headers
|
|
13
|
+
self._timeout = timeout
|
|
14
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
15
|
+
|
|
16
|
+
async def __aenter__(self):
|
|
17
|
+
self._client = httpx.AsyncClient(headers=self._headers, timeout=self._timeout)
|
|
18
|
+
return self
|
|
19
|
+
|
|
20
|
+
async def __aexit__(self, *args):
|
|
21
|
+
if self._client:
|
|
22
|
+
await self._client.aclose()
|
|
23
|
+
|
|
24
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
25
|
+
if self._client is None:
|
|
26
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
27
|
+
return self._client
|
|
28
|
+
|
|
29
|
+
async def post_text(self, text: str) -> dict:
|
|
30
|
+
client = await self._get_client()
|
|
31
|
+
# Exclude Content-Type so httpx sets it correctly for JSON
|
|
32
|
+
headers = {k: v for k, v in self._headers.items() if k != "Content-Type"}
|
|
33
|
+
try:
|
|
34
|
+
resp = await client.post(
|
|
35
|
+
f"{self.base_url}/scan",
|
|
36
|
+
json={"content": text},
|
|
37
|
+
headers=headers,
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp.json()
|
|
41
|
+
except httpx.HTTPStatusError as e:
|
|
42
|
+
raise AdapterError(f"HTTP {e.response.status_code}: {e.response.text}") from e
|
|
43
|
+
except httpx.RequestError as e:
|
|
44
|
+
raise AdapterError(f"Request failed: {e}") from e
|
|
45
|
+
|
|
46
|
+
async def upload_file(self, data: bytes, filename: str, mime_type: str) -> dict:
|
|
47
|
+
client = await self._get_client()
|
|
48
|
+
headers = {k: v for k, v in self._headers.items() if k != "Content-Type"}
|
|
49
|
+
try:
|
|
50
|
+
resp = await client.post(
|
|
51
|
+
f"{self.base_url}/scan/file",
|
|
52
|
+
files={"file": (filename, data, mime_type)},
|
|
53
|
+
headers=headers,
|
|
54
|
+
)
|
|
55
|
+
resp.raise_for_status()
|
|
56
|
+
return resp.json()
|
|
57
|
+
except httpx.HTTPStatusError as e:
|
|
58
|
+
raise AdapterError(f"HTTP {e.response.status_code}: {e.response.text}") from e
|
|
59
|
+
except httpx.RequestError as e:
|
|
60
|
+
raise AdapterError(f"Request failed: {e}") from e
|
|
61
|
+
|
|
62
|
+
async def get_health(self) -> dict:
|
|
63
|
+
client = await self._get_client()
|
|
64
|
+
try:
|
|
65
|
+
resp = await client.get(f"{self.base_url}/health")
|
|
66
|
+
resp.raise_for_status()
|
|
67
|
+
return resp.json()
|
|
68
|
+
except Exception as e:
|
|
69
|
+
raise AdapterError(f"Health check failed: {e}") from e
|
|
70
|
+
|
|
71
|
+
async def close(self):
|
|
72
|
+
if self._client:
|
|
73
|
+
await self._client.aclose()
|
|
74
|
+
self._client = None
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import io
|
|
2
|
+
from typing import Literal
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from docx import Document
|
|
6
|
+
HAS_DOCX = True
|
|
7
|
+
except ImportError:
|
|
8
|
+
HAS_DOCX = False
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from fpdf import FPDF
|
|
12
|
+
HAS_FPDF = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
HAS_FPDF = False
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import openpyxl
|
|
18
|
+
HAS_OPENPYXL = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_OPENPYXL = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
24
|
+
PDF_MIME = "application/pdf"
|
|
25
|
+
XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
26
|
+
|
|
27
|
+
NOISE_SENTENCES = [
|
|
28
|
+
"This document contains confidential business information.",
|
|
29
|
+
"Please handle with care and do not distribute.",
|
|
30
|
+
"For internal use only.",
|
|
31
|
+
"Authorized personnel only.",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileBuilder:
|
|
36
|
+
@staticmethod
|
|
37
|
+
def build(text: str, fmt: Literal["docx", "pdf", "xlsx"]) -> tuple[bytes, str]:
|
|
38
|
+
"""Build an in-memory document containing text. Returns (bytes, mime_type).
|
|
39
|
+
Never writes to disk — uses io.BytesIO only.
|
|
40
|
+
"""
|
|
41
|
+
if fmt == "docx":
|
|
42
|
+
return FileBuilder._build_docx(text), DOCX_MIME
|
|
43
|
+
elif fmt == "pdf":
|
|
44
|
+
return FileBuilder._build_pdf(text), PDF_MIME
|
|
45
|
+
elif fmt == "xlsx":
|
|
46
|
+
return FileBuilder._build_xlsx(text), XLSX_MIME
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(f"Unknown format: {fmt!r}")
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _build_docx(text: str) -> bytes:
|
|
52
|
+
if not HAS_DOCX:
|
|
53
|
+
raise RuntimeError("python-docx is required for DOCX generation")
|
|
54
|
+
doc = Document()
|
|
55
|
+
doc.add_paragraph(NOISE_SENTENCES[0])
|
|
56
|
+
doc.add_paragraph(NOISE_SENTENCES[1])
|
|
57
|
+
doc.add_paragraph(text)
|
|
58
|
+
doc.add_paragraph(NOISE_SENTENCES[2])
|
|
59
|
+
buf = io.BytesIO()
|
|
60
|
+
doc.save(buf)
|
|
61
|
+
return buf.getvalue()
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _build_pdf(text: str) -> bytes:
|
|
65
|
+
if not HAS_FPDF:
|
|
66
|
+
raise RuntimeError("fpdf2 is required for PDF generation")
|
|
67
|
+
pdf = FPDF()
|
|
68
|
+
pdf.add_page()
|
|
69
|
+
# Try DejaVu (bundled with fpdf2 >= 2.7.6) for full Unicode support.
|
|
70
|
+
# Fall back to Helvetica for ASCII-only content if DejaVu is unavailable.
|
|
71
|
+
try:
|
|
72
|
+
pdf.set_font("DejaVu", size=12)
|
|
73
|
+
except Exception:
|
|
74
|
+
# DejaVu not available — encode text to latin-1 safe subset
|
|
75
|
+
pdf.set_font("Helvetica", size=12)
|
|
76
|
+
text = text.encode("latin-1", errors="replace").decode("latin-1")
|
|
77
|
+
|
|
78
|
+
for sentence in NOISE_SENTENCES[:2]:
|
|
79
|
+
try:
|
|
80
|
+
pdf.cell(0, 10, sentence, new_x="LMARGIN", new_y="NEXT")
|
|
81
|
+
except Exception:
|
|
82
|
+
safe = sentence.encode("latin-1", errors="replace").decode("latin-1")
|
|
83
|
+
pdf.cell(0, 10, safe, new_x="LMARGIN", new_y="NEXT")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
pdf.cell(0, 10, text, new_x="LMARGIN", new_y="NEXT")
|
|
87
|
+
except Exception:
|
|
88
|
+
safe = text.encode("latin-1", errors="replace").decode("latin-1")
|
|
89
|
+
pdf.cell(0, 10, safe, new_x="LMARGIN", new_y="NEXT")
|
|
90
|
+
|
|
91
|
+
pdf.cell(0, 10, NOISE_SENTENCES[2], new_x="LMARGIN", new_y="NEXT")
|
|
92
|
+
|
|
93
|
+
buf = io.BytesIO()
|
|
94
|
+
pdf.output(buf)
|
|
95
|
+
return buf.getvalue()
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def _build_xlsx(text: str) -> bytes:
|
|
99
|
+
if not HAS_OPENPYXL:
|
|
100
|
+
raise RuntimeError("openpyxl is required for XLSX generation")
|
|
101
|
+
wb = openpyxl.Workbook()
|
|
102
|
+
ws = wb.active
|
|
103
|
+
ws['A1'] = NOISE_SENTENCES[0]
|
|
104
|
+
ws['A2'] = text
|
|
105
|
+
ws['A3'] = NOISE_SENTENCES[1]
|
|
106
|
+
ws['B1'] = "Document ID"
|
|
107
|
+
ws['B2'] = "12345"
|
|
108
|
+
ws['C1'] = "Classification"
|
|
109
|
+
ws['C2'] = "Confidential"
|
|
110
|
+
buf = io.BytesIO()
|
|
111
|
+
wb.save(buf)
|
|
112
|
+
return buf.getvalue()
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import os
|
|
5
|
+
from evadex.adapters.base import BaseAdapter
|
|
6
|
+
from evadex.adapters.dlpscan.file_builder import FileBuilder
|
|
7
|
+
from evadex.core.registry import register_adapter
|
|
8
|
+
from evadex.core.result import Payload, Variant, ScanResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@register_adapter("dlpscan-cli")
|
|
12
|
+
class DlpscanCliAdapter(BaseAdapter):
|
|
13
|
+
name = "dlpscan-cli"
|
|
14
|
+
|
|
15
|
+
def __init__(self, config):
|
|
16
|
+
super().__init__(config)
|
|
17
|
+
self._exe = self.config.extra.get("executable", "dlpscan")
|
|
18
|
+
|
|
19
|
+
async def submit(self, payload: Payload, variant: Variant) -> ScanResult:
|
|
20
|
+
strategy = variant.strategy
|
|
21
|
+
loop = asyncio.get_event_loop()
|
|
22
|
+
|
|
23
|
+
if strategy == "text":
|
|
24
|
+
raw = await loop.run_in_executor(None, self._scan_text, variant.value)
|
|
25
|
+
else:
|
|
26
|
+
data, _ = FileBuilder.build(variant.value, strategy)
|
|
27
|
+
raw = await loop.run_in_executor(None, self._scan_bytes, data, strategy)
|
|
28
|
+
|
|
29
|
+
detected = len(raw) > 0
|
|
30
|
+
return ScanResult(payload=payload, variant=variant, detected=detected, raw_response={"matches": raw})
|
|
31
|
+
|
|
32
|
+
def _scan_text(self, text: str) -> list:
|
|
33
|
+
suffix = ".txt"
|
|
34
|
+
return self._run_on_tempfile(text.encode("utf-8"), suffix)
|
|
35
|
+
|
|
36
|
+
def _scan_bytes(self, data: bytes, fmt: str) -> list:
|
|
37
|
+
suffix = f".{fmt}"
|
|
38
|
+
return self._run_on_tempfile(data, suffix)
|
|
39
|
+
|
|
40
|
+
def _run_on_tempfile(self, data: bytes, suffix: str) -> list:
|
|
41
|
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as f:
|
|
42
|
+
f.write(data)
|
|
43
|
+
path = f.name
|
|
44
|
+
try:
|
|
45
|
+
import subprocess
|
|
46
|
+
result = subprocess.run(
|
|
47
|
+
[self._exe, "-f", "json", path],
|
|
48
|
+
capture_output=True,
|
|
49
|
+
text=True,
|
|
50
|
+
timeout=self.config.timeout,
|
|
51
|
+
)
|
|
52
|
+
if result.returncode != 0:
|
|
53
|
+
raise RuntimeError(f"dlpscan exited {result.returncode}: {result.stderr.strip()}")
|
|
54
|
+
return json.loads(result.stdout or "[]")
|
|
55
|
+
finally:
|
|
56
|
+
os.unlink(path)
|
evadex/cli/__init__.py
ADDED
|
File without changes
|
evadex/cli/app.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from evadex.cli.commands.scan import scan
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(package_name="evadex")
|
|
10
|
+
def main():
|
|
11
|
+
"""evadex — DLP evasion test suite."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
main.add_command(scan)
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import click
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from evadex.core.registry import load_builtins, get_adapter, all_generators, get_generator
|
|
5
|
+
from evadex.core.engine import Engine
|
|
6
|
+
from evadex.core.result import Payload, PayloadCategory
|
|
7
|
+
from evadex.payloads.builtins import get_payloads, detect_category
|
|
8
|
+
from evadex.reporters.json_reporter import JsonReporter
|
|
9
|
+
from evadex.reporters.html_reporter import HtmlReporter
|
|
10
|
+
|
|
11
|
+
err_console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
STRATEGY_CHOICES = click.Choice(["text", "docx", "pdf", "xlsx"])
|
|
14
|
+
CATEGORY_CHOICES = click.Choice([c.value for c in PayloadCategory])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.command()
|
|
18
|
+
@click.option("--tool", "-t", default="dlpscan", show_default=True, help="DLP adapter to use")
|
|
19
|
+
@click.option("--input", "-i", "input_value", default=None,
|
|
20
|
+
help="Single value to test (if omitted, runs all built-ins)")
|
|
21
|
+
@click.option("--format", "-f", "fmt", type=click.Choice(["json", "html"]),
|
|
22
|
+
default="json", show_default=True)
|
|
23
|
+
@click.option("--output", "-o", default=None, help="Output file path (default: stdout)")
|
|
24
|
+
@click.option("--url", default="http://localhost:8080", show_default=True,
|
|
25
|
+
help="Adapter base URL")
|
|
26
|
+
@click.option("--api-key", default=None, envvar="EVADEX_API_KEY",
|
|
27
|
+
help="API key for adapter")
|
|
28
|
+
@click.option("--timeout", default=30.0, show_default=True, type=float,
|
|
29
|
+
help="Request timeout in seconds")
|
|
30
|
+
@click.option("--strategy", "strategies", multiple=True, type=STRATEGY_CHOICES,
|
|
31
|
+
help="Submission strategies to use (default: all). Repeat for multiple.")
|
|
32
|
+
@click.option("--concurrency", default=5, show_default=True, type=int,
|
|
33
|
+
help="Max concurrent requests")
|
|
34
|
+
@click.option("--category", "categories", multiple=True, type=CATEGORY_CHOICES,
|
|
35
|
+
help="Filter built-in payloads by category. Repeat for multiple.")
|
|
36
|
+
@click.option("--variant-group", "variant_groups", multiple=True,
|
|
37
|
+
help="Limit to specific generator names. Repeat for multiple.")
|
|
38
|
+
def scan(
|
|
39
|
+
tool, input_value, fmt, output, url, api_key, timeout,
|
|
40
|
+
strategies, concurrency, categories, variant_groups,
|
|
41
|
+
):
|
|
42
|
+
"""Run DLP evasion tests."""
|
|
43
|
+
load_builtins()
|
|
44
|
+
|
|
45
|
+
# Resolve strategies
|
|
46
|
+
active_strategies = list(strategies) if strategies else ["text", "docx", "pdf", "xlsx"]
|
|
47
|
+
|
|
48
|
+
# Resolve payloads
|
|
49
|
+
if input_value:
|
|
50
|
+
category = detect_category(input_value)
|
|
51
|
+
payloads = [Payload(
|
|
52
|
+
value=input_value,
|
|
53
|
+
category=category,
|
|
54
|
+
label=f"Custom ({category.value})",
|
|
55
|
+
)]
|
|
56
|
+
else:
|
|
57
|
+
filter_cats = {PayloadCategory(c) for c in categories} if categories else None
|
|
58
|
+
payloads = get_payloads(filter_cats)
|
|
59
|
+
|
|
60
|
+
if not payloads:
|
|
61
|
+
err_console.print("[red]No payloads to test.[/red]")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
# Resolve adapter
|
|
65
|
+
config = {"base_url": url, "api_key": api_key, "timeout": timeout}
|
|
66
|
+
try:
|
|
67
|
+
adapter = get_adapter(tool, config)
|
|
68
|
+
except KeyError as e:
|
|
69
|
+
err_console.print(f"[red]{e}[/red]")
|
|
70
|
+
sys.exit(1)
|
|
71
|
+
|
|
72
|
+
# Resolve generators
|
|
73
|
+
if variant_groups:
|
|
74
|
+
try:
|
|
75
|
+
generators = [get_generator(name) for name in variant_groups]
|
|
76
|
+
except KeyError as e:
|
|
77
|
+
err_console.print(f"[red]{e}[/red]")
|
|
78
|
+
sys.exit(1)
|
|
79
|
+
else:
|
|
80
|
+
generators = None # use all registered
|
|
81
|
+
|
|
82
|
+
# Run engine
|
|
83
|
+
err_console.print(
|
|
84
|
+
f"[dim]Running evadex scan against [bold]{tool}[/bold] at {url}...[/dim]"
|
|
85
|
+
)
|
|
86
|
+
engine = Engine(
|
|
87
|
+
adapter=adapter,
|
|
88
|
+
generators=generators,
|
|
89
|
+
concurrency=concurrency,
|
|
90
|
+
strategies=active_strategies,
|
|
91
|
+
)
|
|
92
|
+
results = engine.run(payloads)
|
|
93
|
+
|
|
94
|
+
# Summary
|
|
95
|
+
total = len(results)
|
|
96
|
+
passes = sum(1 for r in results if r.detected)
|
|
97
|
+
fails = total - passes
|
|
98
|
+
err_console.print(
|
|
99
|
+
f"[green]Done.[/green] {total} tests \u2014 "
|
|
100
|
+
f"[green]{passes} detected[/green], [red]{fails} evaded[/red]"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Report
|
|
104
|
+
reporter = HtmlReporter() if fmt == "html" else JsonReporter()
|
|
105
|
+
rendered = reporter.render(results)
|
|
106
|
+
|
|
107
|
+
if output:
|
|
108
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
109
|
+
f.write(rendered)
|
|
110
|
+
err_console.print(f"[dim]Report written to {output}[/dim]")
|
|
111
|
+
else:
|
|
112
|
+
sys.stdout.buffer.write(rendered.encode("utf-8"))
|
|
113
|
+
sys.stdout.buffer.write(b"\n")
|
|
114
|
+
sys.stdout.buffer.flush()
|
evadex/core/__init__.py
ADDED
|
File without changes
|
evadex/core/engine.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import AsyncIterator
|
|
4
|
+
from evadex.core.result import Payload, ScanResult
|
|
5
|
+
from evadex.adapters.base import BaseAdapter
|
|
6
|
+
from evadex.variants.base import BaseVariantGenerator
|
|
7
|
+
from evadex.core.registry import all_generators
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Engine:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
adapter: BaseAdapter,
|
|
14
|
+
generators: list[BaseVariantGenerator] | None = None,
|
|
15
|
+
concurrency: int = 5,
|
|
16
|
+
strategies: list[str] | None = None,
|
|
17
|
+
):
|
|
18
|
+
self.adapter = adapter
|
|
19
|
+
self.generators = generators # None = use all registered
|
|
20
|
+
self.concurrency = concurrency
|
|
21
|
+
self.strategies = strategies or ["text", "docx", "pdf", "xlsx"]
|
|
22
|
+
|
|
23
|
+
def run(self, payloads: list[Payload]) -> list[ScanResult]:
|
|
24
|
+
return asyncio.run(self._run_async_collect(payloads))
|
|
25
|
+
|
|
26
|
+
async def _run_async_collect(self, payloads: list[Payload]) -> list[ScanResult]:
|
|
27
|
+
results = []
|
|
28
|
+
async for r in self.run_async(payloads):
|
|
29
|
+
results.append(r)
|
|
30
|
+
return results
|
|
31
|
+
|
|
32
|
+
async def run_async(self, payloads: list[Payload]) -> AsyncIterator[ScanResult]:
|
|
33
|
+
generators = self.generators if self.generators is not None else all_generators()
|
|
34
|
+
sem = asyncio.Semaphore(self.concurrency)
|
|
35
|
+
tasks = []
|
|
36
|
+
|
|
37
|
+
for payload in payloads:
|
|
38
|
+
for gen in generators:
|
|
39
|
+
# Check applicable_categories
|
|
40
|
+
if hasattr(gen, 'applicable_categories') and gen.applicable_categories is not None:
|
|
41
|
+
if payload.category not in gen.applicable_categories:
|
|
42
|
+
continue
|
|
43
|
+
for variant in gen.generate(payload.value):
|
|
44
|
+
for strategy in self.strategies:
|
|
45
|
+
tasks.append(self._run_one(sem, payload, variant, strategy))
|
|
46
|
+
|
|
47
|
+
await self.adapter.setup()
|
|
48
|
+
try:
|
|
49
|
+
for coro in asyncio.as_completed(tasks):
|
|
50
|
+
result = await coro
|
|
51
|
+
yield result
|
|
52
|
+
finally:
|
|
53
|
+
await self.adapter.teardown()
|
|
54
|
+
|
|
55
|
+
async def _run_one(
|
|
56
|
+
self, sem: asyncio.Semaphore, payload: Payload, variant, strategy: str
|
|
57
|
+
) -> ScanResult:
|
|
58
|
+
from evadex.core.result import Variant, ScanResult
|
|
59
|
+
|
|
60
|
+
# Clone variant with strategy
|
|
61
|
+
v = Variant(
|
|
62
|
+
value=variant.value,
|
|
63
|
+
generator=variant.generator,
|
|
64
|
+
technique=variant.technique,
|
|
65
|
+
transform_name=variant.transform_name,
|
|
66
|
+
strategy=strategy,
|
|
67
|
+
)
|
|
68
|
+
async with sem:
|
|
69
|
+
start = time.perf_counter()
|
|
70
|
+
try:
|
|
71
|
+
result = await self.adapter.submit(payload, v)
|
|
72
|
+
result.duration_ms = (time.perf_counter() - start) * 1000
|
|
73
|
+
return result
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return ScanResult(
|
|
76
|
+
payload=payload,
|
|
77
|
+
variant=v,
|
|
78
|
+
detected=False,
|
|
79
|
+
error=str(e),
|
|
80
|
+
duration_ms=(time.perf_counter() - start) * 1000,
|
|
81
|
+
)
|
evadex/core/registry.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
_GENERATORS: dict = {}
|
|
2
|
+
_ADAPTERS: dict = {}
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def register_generator(name: str):
|
|
6
|
+
def decorator(cls):
|
|
7
|
+
_GENERATORS[name] = cls
|
|
8
|
+
return cls
|
|
9
|
+
return decorator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_adapter(name: str):
|
|
13
|
+
def decorator(cls):
|
|
14
|
+
_ADAPTERS[name] = cls
|
|
15
|
+
return cls
|
|
16
|
+
return decorator
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_generator(name: str):
|
|
20
|
+
if name not in _GENERATORS:
|
|
21
|
+
raise KeyError(f"No generator registered: {name!r}")
|
|
22
|
+
return _GENERATORS[name]()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_adapter(name: str, config=None):
|
|
26
|
+
if name not in _ADAPTERS:
|
|
27
|
+
raise KeyError(f"No adapter registered: {name!r}. Available: {list(_ADAPTERS)}")
|
|
28
|
+
return _ADAPTERS[name](config or {})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def all_generators():
|
|
32
|
+
return [cls() for cls in _GENERATORS.values()]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_builtins():
|
|
36
|
+
# Import all variant modules so their @register_generator decorators fire
|
|
37
|
+
import evadex.variants.unicode_encoding
|
|
38
|
+
import evadex.variants.delimiter
|
|
39
|
+
import evadex.variants.splitting
|
|
40
|
+
import evadex.variants.leetspeak
|
|
41
|
+
import evadex.variants.regional_digits
|
|
42
|
+
import evadex.variants.structural
|
|
43
|
+
import evadex.variants.encoding
|
|
44
|
+
# Import adapters
|
|
45
|
+
import evadex.adapters.dlpscan.adapter
|
|
46
|
+
import evadex.adapters.dlpscan_cli.adapter
|