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.
Files changed (94) hide show
  1. {evadex-3.0.2/src/evadex.egg-info → evadex-3.2.0}/PKG-INFO +5 -1
  2. {evadex-3.0.2 → evadex-3.2.0}/README.md +4 -0
  3. {evadex-3.0.2 → evadex-3.2.0}/pyproject.toml +1 -1
  4. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan_cli/adapter.py +10 -1
  5. evadex-3.2.0/src/evadex/archive.py +264 -0
  6. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/app.py +4 -0
  7. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/falsepos.py +20 -0
  8. evadex-3.2.0/src/evadex/cli/commands/history.py +134 -0
  9. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/scan.py +47 -1
  10. evadex-3.2.0/src/evadex/cli/commands/trend.py +222 -0
  11. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/config.py +10 -0
  12. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/filler.py +20 -0
  13. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/pdf_writer.py +13 -1
  14. {evadex-3.0.2 → evadex-3.2.0/src/evadex.egg-info}/PKG-INFO +5 -1
  15. {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/SOURCES.txt +3 -0
  16. {evadex-3.0.2 → evadex-3.2.0}/LICENSE +0 -0
  17. {evadex-3.0.2 → evadex-3.2.0}/setup.cfg +0 -0
  18. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/__init__.py +0 -0
  19. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/__main__.py +0 -0
  20. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/__init__.py +0 -0
  21. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/base.py +0 -0
  22. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/__init__.py +0 -0
  23. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/adapter.py +0 -0
  24. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/client.py +0 -0
  25. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan/file_builder.py +0 -0
  26. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/dlpscan_cli/__init__.py +0 -0
  27. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/presidio/__init__.py +0 -0
  28. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/presidio/adapter.py +0 -0
  29. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/adapters/presidio/client.py +0 -0
  30. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/audit.py +0 -0
  31. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/__init__.py +0 -0
  32. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/__init__.py +0 -0
  33. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/compare.py +0 -0
  34. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/generate.py +0 -0
  35. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/init.py +0 -0
  36. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/list_payloads.py +0 -0
  37. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/cli/commands/list_techniques.py +0 -0
  38. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/__init__.py +0 -0
  39. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/engine.py +0 -0
  40. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/registry.py +0 -0
  41. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/core/result.py +0 -0
  42. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/falsepos/__init__.py +0 -0
  43. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/falsepos/generators.py +0 -0
  44. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/__init__.py +0 -0
  45. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/regression_writer.py +0 -0
  46. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/report.py +0 -0
  47. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/feedback/suggestions.py +0 -0
  48. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/__init__.py +0 -0
  49. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/generator.py +0 -0
  50. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/__init__.py +0 -0
  51. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/csv_writer.py +0 -0
  52. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/docx_writer.py +0 -0
  53. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/txt_writer.py +0 -0
  54. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/generate/writers/xlsx_writer.py +0 -0
  55. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/payloads/__init__.py +0 -0
  56. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/payloads/builtins.py +0 -0
  57. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/__init__.py +0 -0
  58. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/base.py +0 -0
  59. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/compare_html_reporter.py +0 -0
  60. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/compare_reporter.py +0 -0
  61. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/html_reporter.py +0 -0
  62. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/reporters/json_reporter.py +0 -0
  63. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/__init__.py +0 -0
  64. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/base.py +0 -0
  65. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ca_corporate.py +0 -0
  66. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ca_drivers_licences.py +0 -0
  67. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ca_health_cards.py +0 -0
  68. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/credit_card.py +0 -0
  69. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/email.py +0 -0
  70. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/iban.py +0 -0
  71. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/phone.py +0 -0
  72. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/ramq.py +0 -0
  73. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/registry.py +0 -0
  74. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/sin.py +0 -0
  75. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/synthetic/validators.py +0 -0
  76. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/__init__.py +0 -0
  77. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/base.py +0 -0
  78. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/bidirectional.py +0 -0
  79. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/context_injection.py +0 -0
  80. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/delimiter.py +0 -0
  81. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/encoding.py +0 -0
  82. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/encoding_chains.py +0 -0
  83. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/leetspeak.py +0 -0
  84. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/morse_code.py +0 -0
  85. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/regional_digits.py +0 -0
  86. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/soft_hyphen.py +0 -0
  87. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/splitting.py +0 -0
  88. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/structural.py +0 -0
  89. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/unicode_encoding.py +0 -0
  90. {evadex-3.0.2 → evadex-3.2.0}/src/evadex/variants/unicode_whitespace.py +0 -0
  91. {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/dependency_links.txt +0 -0
  92. {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/entry_points.txt +0 -0
  93. {evadex-3.0.2 → evadex-3.2.0}/src/evadex.egg-info/requires.txt +0 -0
  94. {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.2
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "evadex"
7
- version = "3.0.2"
7
+ version = "3.2.0"
8
8
  description = "Comprehensive DLP evasion test suite — scanner-agnostic, file-aware"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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
- matches = await loop.run_in_executor(None, self._scan_text, variant.value)
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: