evadex 3.0.2__tar.gz → 3.2.0__tar.gz
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-3.0.2/src/evadex.egg-info → evadex-3.2.0}/PKG-INFO +5 -1
- {evadex-3.0.2 → evadex-3.2.0}/README.md +4 -0
- {evadex-3.0.2 → evadex-3.2.0}/pyproject.toml +1 -1
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan_cli/adapter.py +10 -1
- evadex-3.2.0/src/evadex/archive.py +264 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/app.py +4 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/falsepos.py +20 -0
- evadex-3.2.0/src/evadex/cli/commands/history.py +134 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/scan.py +47 -1
- evadex-3.2.0/src/evadex/cli/commands/trend.py +222 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/config.py +10 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/filler.py +20 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/pdf_writer.py +13 -1
- {evadex-3.0.2 → evadex-3.2.0/src/evadex.egg-info}/PKG-INFO +5 -1
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/SOURCES.txt +3 -0
- {evadex-3.0.2 → evadex-3.2.0}/LICENSE +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/setup.cfg +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/__main__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/base.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/adapter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/client.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/file_builder.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan_cli/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/presidio/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/presidio/adapter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/presidio/client.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/audit.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/compare.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/generate.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/init.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/list_payloads.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/list_techniques.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/engine.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/registry.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/result.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/falsepos/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/falsepos/generators.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/regression_writer.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/report.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/suggestions.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/generator.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/csv_writer.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/docx_writer.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/txt_writer.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/xlsx_writer.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/payloads/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/payloads/builtins.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/base.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/compare_html_reporter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/compare_reporter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/html_reporter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/json_reporter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/base.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ca_corporate.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ca_drivers_licences.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ca_health_cards.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/credit_card.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/email.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/iban.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/phone.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ramq.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/registry.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/sin.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/validators.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/__init__.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/base.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/bidirectional.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/context_injection.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/delimiter.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/encoding.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/encoding_chains.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/leetspeak.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/morse_code.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/regional_digits.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/soft_hyphen.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/splitting.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/structural.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/unicode_encoding.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/unicode_whitespace.py +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/dependency_links.txt +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/entry_points.txt +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/requires.txt +0 -0
- {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: evadex
|
|
3
|
-
Version: 3.0
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Comprehensive DLP evasion test suite — scanner-agnostic, file-aware
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/tbustenk/evadex
|
|
@@ -724,6 +724,10 @@ evadex scan [OPTIONS]
|
|
|
724
724
|
| `--audit-log` | *(off)* | Append a one-line JSON audit record for this run to a file. Parent directories are created if they do not exist. Can also be set via `audit_log` in `evadex.yaml`. |
|
|
725
725
|
| `--feedback-report` | *(off)* | Save a structured JSON feedback report to PATH. Contains per-technique evasion counts with example variant values, actionable fix suggestions, and the generated regression test code as a string field. Always written when specified, even if there are no evasions. |
|
|
726
726
|
| `--require-context` | off | Pass `--require-context` to dlpscan-rs: only flag matches when surrounding keywords are present. Reduces false positives but may reduce detection rate for obfuscated variants lacking keyword context. Requires `--cmd-style rust`. Can also be set via `require_context` in `evadex.yaml`. |
|
|
727
|
+
| `--wrap-context` | **auto (rust)** | Embed every variant value in a realistic keyword sentence before submission. **Automatically enabled when `--cmd-style rust` is used** — dlpscan-rs requires surrounding context keywords to flag most matches; submitting a bare value produces artificially low detection rates. Pass `--no-wrap-context` to suppress. Can also be set via `wrap_context` in `evadex.yaml`. |
|
|
728
|
+
| `--no-wrap-context` | off | Explicitly disable context wrapping even when `--cmd-style rust` is active. |
|
|
729
|
+
|
|
730
|
+
> **Note for dlpscan-rs users:** dlpscan-rs requires surrounding context keywords (e.g. "credit card", "SSN", "IBAN") to be present near a matched value before it will flag it. Submitting a bare value like `4532015112830366` without context will produce a false *no-match* — the scanner can see the number but will not fire without contextual evidence. `evadex scan` auto-enables `--wrap-context` when `--cmd-style rust` is used so every variant is embedded in a realistic business sentence. Use `--no-wrap-context` only if you are deliberately testing bare-value behaviour or your dlpscan-rs build is configured with context matching disabled.
|
|
727
731
|
|
|
728
732
|
### `evadex generate`
|
|
729
733
|
|
|
@@ -684,6 +684,10 @@ evadex scan [OPTIONS]
|
|
|
684
684
|
| `--audit-log` | *(off)* | Append a one-line JSON audit record for this run to a file. Parent directories are created if they do not exist. Can also be set via `audit_log` in `evadex.yaml`. |
|
|
685
685
|
| `--feedback-report` | *(off)* | Save a structured JSON feedback report to PATH. Contains per-technique evasion counts with example variant values, actionable fix suggestions, and the generated regression test code as a string field. Always written when specified, even if there are no evasions. |
|
|
686
686
|
| `--require-context` | off | Pass `--require-context` to dlpscan-rs: only flag matches when surrounding keywords are present. Reduces false positives but may reduce detection rate for obfuscated variants lacking keyword context. Requires `--cmd-style rust`. Can also be set via `require_context` in `evadex.yaml`. |
|
|
687
|
+
| `--wrap-context` | **auto (rust)** | Embed every variant value in a realistic keyword sentence before submission. **Automatically enabled when `--cmd-style rust` is used** — dlpscan-rs requires surrounding context keywords to flag most matches; submitting a bare value produces artificially low detection rates. Pass `--no-wrap-context` to suppress. Can also be set via `wrap_context` in `evadex.yaml`. |
|
|
688
|
+
| `--no-wrap-context` | off | Explicitly disable context wrapping even when `--cmd-style rust` is active. |
|
|
689
|
+
|
|
690
|
+
> **Note for dlpscan-rs users:** dlpscan-rs requires surrounding context keywords (e.g. "credit card", "SSN", "IBAN") to be present near a matched value before it will flag it. Submitting a bare value like `4532015112830366` without context will produce a false *no-match* — the scanner can see the number but will not fire without contextual evidence. `evadex scan` auto-enables `--wrap-context` when `--cmd-style rust` is used so every variant is embedded in a realistic business sentence. Use `--no-wrap-context` only if you are deliberately testing bare-value behaviour or your dlpscan-rs build is configured with context matching disabled.
|
|
687
691
|
|
|
688
692
|
### `evadex generate`
|
|
689
693
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import json
|
|
3
|
+
import random
|
|
3
4
|
import subprocess
|
|
4
5
|
import tempfile
|
|
5
6
|
import os
|
|
@@ -7,6 +8,7 @@ from evadex.adapters.base import BaseAdapter
|
|
|
7
8
|
from evadex.adapters.dlpscan.file_builder import FileBuilder
|
|
8
9
|
from evadex.core.registry import register_adapter
|
|
9
10
|
from evadex.core.result import Payload, Variant, ScanResult
|
|
11
|
+
from evadex.generate.filler import get_keyword_sentence
|
|
10
12
|
|
|
11
13
|
# cmd_style="python" → dlpscan -f json <file> → bare list of matches
|
|
12
14
|
# cmd_style="rust" → dlpscan --format json scan <file> → list of file objects with nested matches
|
|
@@ -20,6 +22,7 @@ class DlpscanCliAdapter(BaseAdapter):
|
|
|
20
22
|
super().__init__(config)
|
|
21
23
|
self._exe = self.config.extra.get("executable", "dlpscan")
|
|
22
24
|
self._cmd_style = self.config.extra.get("cmd_style", "python")
|
|
25
|
+
self._wrap_context = self.config.extra.get("wrap_context", False)
|
|
23
26
|
|
|
24
27
|
async def health_check(self) -> bool:
|
|
25
28
|
try:
|
|
@@ -39,7 +42,13 @@ class DlpscanCliAdapter(BaseAdapter):
|
|
|
39
42
|
loop = asyncio.get_running_loop()
|
|
40
43
|
|
|
41
44
|
if strategy == "text":
|
|
42
|
-
|
|
45
|
+
text = variant.value
|
|
46
|
+
if self._wrap_context:
|
|
47
|
+
# Embed the variant value in a realistic keyword sentence so that
|
|
48
|
+
# dlpscan-rs context requirements are satisfied. The original
|
|
49
|
+
# variant.value is preserved in the result for reporting.
|
|
50
|
+
text = get_keyword_sentence(random.Random(), payload.category, text)
|
|
51
|
+
matches = await loop.run_in_executor(None, self._scan_text, text)
|
|
43
52
|
else:
|
|
44
53
|
data, _ = FileBuilder.build(variant.value, strategy)
|
|
45
54
|
matches = await loop.run_in_executor(None, self._scan_bytes, data, strategy)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Results archive — auto-saves timestamped scan/falsepos runs and maintains
|
|
2
|
+
results/audit.jsonl. All public functions swallow exceptions so that a disk
|
|
3
|
+
or permission failure never affects a scan run's exit code or output.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
16
|
+
try:
|
|
17
|
+
_VERSION = _pkg_version("evadex")
|
|
18
|
+
except PackageNotFoundError:
|
|
19
|
+
_VERSION = "unknown"
|
|
20
|
+
except ImportError:
|
|
21
|
+
_VERSION = "unknown"
|
|
22
|
+
|
|
23
|
+
# All archive files live under <cwd>/results/
|
|
24
|
+
_RESULTS_DIR = Path("results")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Internal helpers ───────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _ensure_dirs() -> Path:
|
|
30
|
+
"""Create results directory structure if needed and return it."""
|
|
31
|
+
for sub in ("scans", "falsepos", "comparisons"):
|
|
32
|
+
(_RESULTS_DIR / sub).mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return _RESULTS_DIR
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_label(s: str) -> str:
|
|
37
|
+
"""Sanitise label string for use in a filename (max 40 chars)."""
|
|
38
|
+
cleaned = "".join(c if (c.isalnum() or c in "-_.") else "_" for c in (s or ""))
|
|
39
|
+
return cleaned[:40] or "unlabelled"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Public helpers ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def get_commit_hash() -> Optional[str]:
|
|
45
|
+
"""Return the short git commit hash of HEAD, or None if unavailable."""
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["git", "rev-parse", "--short", "HEAD"],
|
|
49
|
+
capture_output=True, text=True, timeout=5,
|
|
50
|
+
)
|
|
51
|
+
if result.returncode == 0:
|
|
52
|
+
return result.stdout.strip() or None
|
|
53
|
+
except Exception:
|
|
54
|
+
pass
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Archive functions ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def archive_scan(
|
|
61
|
+
rendered_json: str,
|
|
62
|
+
scanner_label: str,
|
|
63
|
+
*,
|
|
64
|
+
ts: Optional[datetime] = None,
|
|
65
|
+
) -> Path:
|
|
66
|
+
"""Save *rendered_json* to results/scans/ with a timestamped name.
|
|
67
|
+
|
|
68
|
+
Never overwrites an existing file. Returns the path written.
|
|
69
|
+
Silently returns a placeholder Path on any error.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
ts = ts or datetime.now(timezone.utc)
|
|
73
|
+
stamp = ts.strftime("%Y%m%dT%H%M%SZ")
|
|
74
|
+
name = f"scan_{stamp}_{_safe_label(scanner_label)}.json"
|
|
75
|
+
path = _ensure_dirs() / "scans" / name
|
|
76
|
+
if not path.exists():
|
|
77
|
+
path.write_text(rendered_json, encoding="utf-8")
|
|
78
|
+
return path
|
|
79
|
+
except Exception:
|
|
80
|
+
return _RESULTS_DIR / "scans" / "error.json"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def archive_falsepos(
|
|
84
|
+
report: dict,
|
|
85
|
+
scanner_label: str = "",
|
|
86
|
+
*,
|
|
87
|
+
ts: Optional[datetime] = None,
|
|
88
|
+
) -> Path:
|
|
89
|
+
"""Save *report* to results/falsepos/ with a timestamped name.
|
|
90
|
+
|
|
91
|
+
Never overwrites an existing file. Returns the path written.
|
|
92
|
+
Silently returns a placeholder Path on any error.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
ts = ts or datetime.now(timezone.utc)
|
|
96
|
+
stamp = ts.strftime("%Y%m%dT%H%M%SZ")
|
|
97
|
+
name = f"falsepos_{stamp}_{_safe_label(scanner_label)}.json"
|
|
98
|
+
path = _ensure_dirs() / "falsepos" / name
|
|
99
|
+
if not path.exists():
|
|
100
|
+
path.write_text(
|
|
101
|
+
json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
102
|
+
)
|
|
103
|
+
return path
|
|
104
|
+
except Exception:
|
|
105
|
+
return _RESULTS_DIR / "falsepos" / "error.json"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def append_results_audit(entry: dict) -> None:
|
|
109
|
+
"""Append one JSON line to results/audit.jsonl. Silently ignores errors."""
|
|
110
|
+
try:
|
|
111
|
+
audit_path = _ensure_dirs() / "audit.jsonl"
|
|
112
|
+
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
113
|
+
with open(audit_path, "a", encoding="utf-8") as f:
|
|
114
|
+
f.write(line)
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Audit entry builders ───────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
def build_scan_audit_entry(
|
|
122
|
+
*,
|
|
123
|
+
scanner_label: str,
|
|
124
|
+
tool: str,
|
|
125
|
+
categories: list[str],
|
|
126
|
+
strategies: list[str],
|
|
127
|
+
total: int,
|
|
128
|
+
passes: int,
|
|
129
|
+
fails: int,
|
|
130
|
+
pass_rate: float,
|
|
131
|
+
archive_file: str,
|
|
132
|
+
commit_hash: Optional[str] = None,
|
|
133
|
+
ts: Optional[datetime] = None,
|
|
134
|
+
) -> dict:
|
|
135
|
+
return {
|
|
136
|
+
"timestamp": (ts or datetime.now(timezone.utc)).isoformat(),
|
|
137
|
+
"type": "scan",
|
|
138
|
+
"evadex_version": _VERSION,
|
|
139
|
+
"scanner_label": scanner_label,
|
|
140
|
+
"tool": tool,
|
|
141
|
+
"categories": categories,
|
|
142
|
+
"strategies": strategies,
|
|
143
|
+
"total": total,
|
|
144
|
+
"pass": passes,
|
|
145
|
+
"fail": fails,
|
|
146
|
+
"pass_rate": pass_rate,
|
|
147
|
+
"commit_hash": commit_hash,
|
|
148
|
+
"archive_file": archive_file,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_falsepos_audit_entry(
|
|
153
|
+
*,
|
|
154
|
+
tool: str,
|
|
155
|
+
categories: list[str],
|
|
156
|
+
total_tested: int,
|
|
157
|
+
total_flagged: int,
|
|
158
|
+
fp_rate: float,
|
|
159
|
+
archive_file: str,
|
|
160
|
+
commit_hash: Optional[str] = None,
|
|
161
|
+
scanner_label: str = "",
|
|
162
|
+
ts: Optional[datetime] = None,
|
|
163
|
+
) -> dict:
|
|
164
|
+
return {
|
|
165
|
+
"timestamp": (ts or datetime.now(timezone.utc)).isoformat(),
|
|
166
|
+
"type": "falsepos",
|
|
167
|
+
"evadex_version": _VERSION,
|
|
168
|
+
"scanner_label": scanner_label,
|
|
169
|
+
"tool": tool,
|
|
170
|
+
"categories": categories,
|
|
171
|
+
"total_tested": total_tested,
|
|
172
|
+
"total_flagged": total_flagged,
|
|
173
|
+
"fp_rate": fp_rate,
|
|
174
|
+
"commit_hash": commit_hash,
|
|
175
|
+
"archive_file": archive_file,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ── Backfill ───────────────────────────────────────────────────────────────────
|
|
180
|
+
|
|
181
|
+
def backfill_from_directory(directory: str = ".") -> int:
|
|
182
|
+
"""Scan *directory* for existing evadex result JSON files and add them
|
|
183
|
+
to results/audit.jsonl. Skips files already inside results/.
|
|
184
|
+
|
|
185
|
+
Returns the number of entries added.
|
|
186
|
+
"""
|
|
187
|
+
added = 0
|
|
188
|
+
for path in sorted(Path(directory).glob("*.json")):
|
|
189
|
+
if path.name.startswith("."):
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
193
|
+
except Exception:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# ── Scan result: has 'meta' with 'pass_rate' and 'results' list ──────
|
|
197
|
+
if isinstance(data, dict) and "meta" in data and "results" in data:
|
|
198
|
+
meta = data.get("meta", {})
|
|
199
|
+
if not isinstance(meta, dict) or "pass_rate" not in meta:
|
|
200
|
+
continue
|
|
201
|
+
ts_str = meta.get("timestamp", "")
|
|
202
|
+
try:
|
|
203
|
+
ts = datetime.fromisoformat(ts_str)
|
|
204
|
+
except Exception:
|
|
205
|
+
ts = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
206
|
+
|
|
207
|
+
scanner_label = meta.get("scanner", "")
|
|
208
|
+
stamp = ts.strftime("%Y%m%dT%H%M%SZ")
|
|
209
|
+
dest_name = f"scan_{stamp}_{_safe_label(scanner_label)}.json"
|
|
210
|
+
dest = _ensure_dirs() / "scans" / dest_name
|
|
211
|
+
if not dest.exists():
|
|
212
|
+
shutil.copy2(path, dest)
|
|
213
|
+
|
|
214
|
+
entry = {
|
|
215
|
+
"timestamp": ts.isoformat(),
|
|
216
|
+
"type": "scan",
|
|
217
|
+
"evadex_version": meta.get("evadex_version", "unknown"),
|
|
218
|
+
"scanner_label": scanner_label,
|
|
219
|
+
"tool": "dlpscan-cli",
|
|
220
|
+
"categories": list(meta.get("summary_by_category", {}).keys()),
|
|
221
|
+
"strategies": [],
|
|
222
|
+
"total": meta.get("total", 0),
|
|
223
|
+
"pass": meta.get("pass", 0),
|
|
224
|
+
"fail": meta.get("fail", 0),
|
|
225
|
+
"pass_rate": meta.get("pass_rate", 0.0),
|
|
226
|
+
"commit_hash": None,
|
|
227
|
+
"archive_file": str(dest),
|
|
228
|
+
"_backfilled_from": str(path),
|
|
229
|
+
}
|
|
230
|
+
append_results_audit(entry)
|
|
231
|
+
added += 1
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# ── Falsepos result: has 'total_tested' and 'overall_false_positive_rate' ──
|
|
235
|
+
if (
|
|
236
|
+
isinstance(data, dict)
|
|
237
|
+
and "total_tested" in data
|
|
238
|
+
and "overall_false_positive_rate" in data
|
|
239
|
+
):
|
|
240
|
+
ts = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)
|
|
241
|
+
stamp = ts.strftime("%Y%m%dT%H%M%SZ")
|
|
242
|
+
dest_name = f"falsepos_{stamp}_{_safe_label(data.get('tool', ''))}.json"
|
|
243
|
+
dest = _ensure_dirs() / "falsepos" / dest_name
|
|
244
|
+
if not dest.exists():
|
|
245
|
+
shutil.copy2(path, dest)
|
|
246
|
+
|
|
247
|
+
entry = {
|
|
248
|
+
"timestamp": ts.isoformat(),
|
|
249
|
+
"type": "falsepos",
|
|
250
|
+
"evadex_version": "unknown",
|
|
251
|
+
"scanner_label": "",
|
|
252
|
+
"tool": data.get("tool", ""),
|
|
253
|
+
"categories": list(data.get("by_category", {}).keys()),
|
|
254
|
+
"total_tested": data.get("total_tested", 0),
|
|
255
|
+
"total_flagged": data.get("total_flagged", 0),
|
|
256
|
+
"fp_rate": data.get("overall_false_positive_rate", 0.0),
|
|
257
|
+
"commit_hash": None,
|
|
258
|
+
"archive_file": str(dest),
|
|
259
|
+
"_backfilled_from": str(path),
|
|
260
|
+
}
|
|
261
|
+
append_results_audit(entry)
|
|
262
|
+
added += 1
|
|
263
|
+
|
|
264
|
+
return added
|
|
@@ -8,6 +8,8 @@ from evadex.cli.commands.list_payloads import list_payloads
|
|
|
8
8
|
from evadex.cli.commands.list_techniques import list_techniques
|
|
9
9
|
from evadex.cli.commands.init import init_cmd
|
|
10
10
|
from evadex.cli.commands.falsepos import falsepos
|
|
11
|
+
from evadex.cli.commands.history import history
|
|
12
|
+
from evadex.cli.commands.trend import trend
|
|
11
13
|
|
|
12
14
|
# Ensure stdout/stderr use UTF-8 on Windows so that Rich tables with Unicode
|
|
13
15
|
# box-drawing characters and special symbols render without codec errors.
|
|
@@ -31,3 +33,5 @@ main.add_command(list_payloads)
|
|
|
31
33
|
main.add_command(list_techniques)
|
|
32
34
|
main.add_command(init_cmd)
|
|
33
35
|
main.add_command(falsepos)
|
|
36
|
+
main.add_command(history)
|
|
37
|
+
main.add_command(trend)
|
|
@@ -235,6 +235,26 @@ def falsepos(
|
|
|
235
235
|
"by_category": by_category,
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
# ── Archive: save timestamped copy and append to audit.jsonl ─────────────
|
|
239
|
+
from evadex.archive import (
|
|
240
|
+
archive_falsepos, append_results_audit,
|
|
241
|
+
build_falsepos_audit_entry, get_commit_hash,
|
|
242
|
+
)
|
|
243
|
+
_scanner_label = f"{tool}:{mode_label}" if mode_label != "baseline (no context)" else tool
|
|
244
|
+
_archive_path = archive_falsepos(report, scanner_label=_scanner_label)
|
|
245
|
+
_commit = get_commit_hash()
|
|
246
|
+
_audit_entry = build_falsepos_audit_entry(
|
|
247
|
+
tool=tool,
|
|
248
|
+
categories=active_cats,
|
|
249
|
+
total_tested=total_tested,
|
|
250
|
+
total_flagged=total_flagged,
|
|
251
|
+
fp_rate=overall_rate,
|
|
252
|
+
archive_file=str(_archive_path),
|
|
253
|
+
commit_hash=_commit,
|
|
254
|
+
scanner_label=_scanner_label,
|
|
255
|
+
)
|
|
256
|
+
append_results_audit(_audit_entry)
|
|
257
|
+
|
|
238
258
|
# ── Output ────────────────────────────────────────────────────────────────
|
|
239
259
|
if fmt == "json" or output:
|
|
240
260
|
rendered = json.dumps(report, indent=2)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""evadex history — show past scan and falsepos run results."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
err_console = Console(stderr=True)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_audit(audit_path: Path) -> list[dict]:
|
|
17
|
+
if not audit_path.exists():
|
|
18
|
+
return []
|
|
19
|
+
entries = []
|
|
20
|
+
for line in audit_path.read_text(encoding="utf-8").splitlines():
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if not line:
|
|
23
|
+
continue
|
|
24
|
+
try:
|
|
25
|
+
entries.append(json.loads(line))
|
|
26
|
+
except json.JSONDecodeError:
|
|
27
|
+
continue
|
|
28
|
+
return entries
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fmt_rate(entry: dict) -> str:
|
|
32
|
+
if entry.get("type") == "scan":
|
|
33
|
+
rate = entry.get("pass_rate")
|
|
34
|
+
if rate is None:
|
|
35
|
+
return "—"
|
|
36
|
+
return f"{rate:.1f}%"
|
|
37
|
+
elif entry.get("type") == "falsepos":
|
|
38
|
+
rate = entry.get("fp_rate")
|
|
39
|
+
if rate is None:
|
|
40
|
+
return "—"
|
|
41
|
+
return f"{rate:.1f}% FP"
|
|
42
|
+
return "—"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fmt_total(entry: dict) -> str:
|
|
46
|
+
if entry.get("type") == "scan":
|
|
47
|
+
return str(entry.get("total", "—"))
|
|
48
|
+
elif entry.get("type") == "falsepos":
|
|
49
|
+
return str(entry.get("total_tested", "—"))
|
|
50
|
+
return "—"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _fmt_date(ts: str) -> str:
|
|
54
|
+
"""Shorten ISO timestamp to a readable date+time."""
|
|
55
|
+
if not ts:
|
|
56
|
+
return "—"
|
|
57
|
+
# e.g. "2026-04-14T12:34:56.789+00:00" → "2026-04-14 12:34"
|
|
58
|
+
try:
|
|
59
|
+
date_part, time_part = ts[:19].split("T")
|
|
60
|
+
return f"{date_part} {time_part[:5]}"
|
|
61
|
+
except Exception:
|
|
62
|
+
return ts[:16]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command("history")
|
|
66
|
+
@click.option("--last", default=20, show_default=True, type=int,
|
|
67
|
+
help="Number of most recent entries to show.")
|
|
68
|
+
@click.option("--type", "entry_type", default=None,
|
|
69
|
+
type=click.Choice(["scan", "falsepos"]),
|
|
70
|
+
help="Filter by entry type.")
|
|
71
|
+
@click.option("--results-dir", default="results", show_default=True,
|
|
72
|
+
help="Path to results directory (must contain audit.jsonl).")
|
|
73
|
+
def history(last: int, entry_type: Optional[str], results_dir: str) -> None:
|
|
74
|
+
"""Show history of past scan and false positive runs.
|
|
75
|
+
|
|
76
|
+
\b
|
|
77
|
+
Examples:
|
|
78
|
+
evadex history
|
|
79
|
+
evadex history --last 10
|
|
80
|
+
evadex history --type scan
|
|
81
|
+
evadex history --type falsepos
|
|
82
|
+
"""
|
|
83
|
+
audit_path = Path(results_dir) / "audit.jsonl"
|
|
84
|
+
entries = _load_audit(audit_path)
|
|
85
|
+
|
|
86
|
+
if not entries:
|
|
87
|
+
err_console.print(
|
|
88
|
+
f"[yellow]No audit entries found in {audit_path}.[/yellow]\n"
|
|
89
|
+
"Run [bold]evadex scan[/bold] or [bold]evadex falsepos[/bold] to start "
|
|
90
|
+
"building history, or use [bold]evadex history --results-dir PATH[/bold] "
|
|
91
|
+
"to point to a different results directory."
|
|
92
|
+
)
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
if entry_type:
|
|
96
|
+
entries = [e for e in entries if e.get("type") == entry_type]
|
|
97
|
+
|
|
98
|
+
# Most-recent first, then truncate
|
|
99
|
+
entries = list(reversed(entries))[:last]
|
|
100
|
+
|
|
101
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
|
|
102
|
+
table.add_column("Date", style="dim", min_width=16)
|
|
103
|
+
table.add_column("Type", style="cyan", min_width=8)
|
|
104
|
+
table.add_column("Scanner Label", style="", min_width=18, overflow="fold")
|
|
105
|
+
table.add_column("Total", style="", min_width=6, justify="right")
|
|
106
|
+
table.add_column("Rate", style="", min_width=10, justify="right")
|
|
107
|
+
table.add_column("Commit", style="dim", min_width=8)
|
|
108
|
+
|
|
109
|
+
for e in entries:
|
|
110
|
+
rate_str = _fmt_rate(e)
|
|
111
|
+
entry_t = e.get("type", "?")
|
|
112
|
+
|
|
113
|
+
if entry_t == "scan":
|
|
114
|
+
rate = e.get("pass_rate", 0)
|
|
115
|
+
colour = "green" if rate >= 80 else ("yellow" if rate >= 60 else "red")
|
|
116
|
+
else:
|
|
117
|
+
rate = e.get("fp_rate", 0)
|
|
118
|
+
colour = "red" if rate >= 50 else ("yellow" if rate >= 20 else "green")
|
|
119
|
+
|
|
120
|
+
table.add_row(
|
|
121
|
+
_fmt_date(e.get("timestamp", "")),
|
|
122
|
+
entry_t,
|
|
123
|
+
e.get("scanner_label", "") or "—",
|
|
124
|
+
_fmt_total(e),
|
|
125
|
+
f"[{colour}]{rate_str}[/{colour}]",
|
|
126
|
+
e.get("commit_hash") or "—",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
console = Console()
|
|
130
|
+
console.print(table)
|
|
131
|
+
console.print(
|
|
132
|
+
f"\n[dim]Showing {len(entries)} entr{'y' if len(entries) == 1 else 'ies'} "
|
|
133
|
+
f"from {audit_path}[/dim]"
|
|
134
|
+
)
|
|
@@ -273,13 +273,21 @@ def _print_summary(results, err_console):
|
|
|
273
273
|
help="Pass --require-context to dlpscan-rs: only flag matches when surrounding "
|
|
274
274
|
"keywords are present. Reduces false positives but may also reduce detection "
|
|
275
275
|
"rate for variants lacking keyword context. Requires --cmd-style rust.")
|
|
276
|
+
@click.option("--wrap-context", "wrap_context", is_flag=True, default=False,
|
|
277
|
+
help="Embed every variant value in a realistic keyword sentence before submission. "
|
|
278
|
+
"dlpscan-rs requires surrounding context words to flag matches — submitting a "
|
|
279
|
+
"bare value produces misleading (artificially low) detection rates. "
|
|
280
|
+
"Automatically enabled when --cmd-style rust is used. "
|
|
281
|
+
"Pass --no-wrap-context to disable.")
|
|
282
|
+
@click.option("--no-wrap-context", "no_wrap_context", is_flag=True, default=False,
|
|
283
|
+
help="Explicitly disable context wrapping even when --cmd-style rust is active.")
|
|
276
284
|
def scan(
|
|
277
285
|
ctx,
|
|
278
286
|
config_path, tool, input_value, fmt, output, url, api_key, timeout,
|
|
279
287
|
strategies, concurrency, categories, variant_groups, include_heuristic,
|
|
280
288
|
scanner_label, executable, cmd_style, min_detection_rate,
|
|
281
289
|
save_baseline, compare_baseline, audit_log, feedback_report,
|
|
282
|
-
require_context,
|
|
290
|
+
require_context, wrap_context, no_wrap_context,
|
|
283
291
|
):
|
|
284
292
|
"""Run DLP evasion tests."""
|
|
285
293
|
load_builtins()
|
|
@@ -340,6 +348,20 @@ def scan(
|
|
|
340
348
|
audit_log = cfg.audit_log
|
|
341
349
|
if _is_default("require_context") and cfg.require_context is not None:
|
|
342
350
|
require_context = cfg.require_context
|
|
351
|
+
if not wrap_context and not no_wrap_context and cfg.wrap_context is not None:
|
|
352
|
+
wrap_context = cfg.wrap_context
|
|
353
|
+
|
|
354
|
+
# ── Auto-enable wrap_context for dlpscan-rs ───────────────────────────────
|
|
355
|
+
# dlpscan-rs requires surrounding context keywords to fire most rules.
|
|
356
|
+
# Submitting a bare value (no sentence context) produces artificially low
|
|
357
|
+
# detection rates that do not reflect real-world scanner behaviour.
|
|
358
|
+
# When --cmd-style rust is active and the user has not explicitly opted out
|
|
359
|
+
# with --no-wrap-context, enable context wrapping automatically.
|
|
360
|
+
effective_cmd_style = cmd_style or "python"
|
|
361
|
+
if not no_wrap_context and not wrap_context and effective_cmd_style == "rust":
|
|
362
|
+
wrap_context = True
|
|
363
|
+
if no_wrap_context:
|
|
364
|
+
wrap_context = False
|
|
343
365
|
|
|
344
366
|
# ── Heuristic warning ─────────────────────────────────────────────────────
|
|
345
367
|
if include_heuristic:
|
|
@@ -386,6 +408,8 @@ def scan(
|
|
|
386
408
|
config["cmd_style"] = cmd_style
|
|
387
409
|
if require_context:
|
|
388
410
|
config["require_context"] = True
|
|
411
|
+
if wrap_context:
|
|
412
|
+
config["wrap_context"] = True
|
|
389
413
|
try:
|
|
390
414
|
adapter = get_adapter(tool, config)
|
|
391
415
|
except KeyError as e:
|
|
@@ -460,6 +484,28 @@ def scan(
|
|
|
460
484
|
reporter = HtmlReporter() if fmt == "html" else JsonReporter(scanner_label=scanner_label)
|
|
461
485
|
rendered = reporter.render(results)
|
|
462
486
|
|
|
487
|
+
# ── Archive: save timestamped copy and append to audit.jsonl ─────────────
|
|
488
|
+
if fmt == "json":
|
|
489
|
+
from evadex.archive import (
|
|
490
|
+
archive_scan, append_results_audit,
|
|
491
|
+
build_scan_audit_entry, get_commit_hash,
|
|
492
|
+
)
|
|
493
|
+
_archive_path = archive_scan(rendered, scanner_label)
|
|
494
|
+
_commit = get_commit_hash()
|
|
495
|
+
_audit_entry = build_scan_audit_entry(
|
|
496
|
+
scanner_label=scanner_label,
|
|
497
|
+
tool=tool,
|
|
498
|
+
categories=list(categories) if categories else [],
|
|
499
|
+
strategies=active_strategies,
|
|
500
|
+
total=total,
|
|
501
|
+
passes=passes,
|
|
502
|
+
fails=fails,
|
|
503
|
+
pass_rate=pass_rate,
|
|
504
|
+
archive_file=str(_archive_path),
|
|
505
|
+
commit_hash=_commit,
|
|
506
|
+
)
|
|
507
|
+
append_results_audit(_audit_entry)
|
|
508
|
+
|
|
463
509
|
if output:
|
|
464
510
|
try:
|
|
465
511
|
with open(output, "w", encoding="utf-8") as f:
|