shadowshield 0.4.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.
Files changed (55) hide show
  1. shadowshield/__init__.py +124 -0
  2. shadowshield/cli.py +153 -0
  3. shadowshield/config/__init__.py +15 -0
  4. shadowshield/config/default.yaml +95 -0
  5. shadowshield/core/__init__.py +46 -0
  6. shadowshield/core/canary.py +90 -0
  7. shadowshield/core/config.py +242 -0
  8. shadowshield/core/engine.py +255 -0
  9. shadowshield/core/session.py +138 -0
  10. shadowshield/core/shield.py +359 -0
  11. shadowshield/core/types.py +222 -0
  12. shadowshield/detectors/__init__.py +64 -0
  13. shadowshield/detectors/alignment.py +125 -0
  14. shadowshield/detectors/anomaly.py +125 -0
  15. shadowshield/detectors/base.py +136 -0
  16. shadowshield/detectors/canary.py +51 -0
  17. shadowshield/detectors/data/attack_corpus.txt +83 -0
  18. shadowshield/detectors/encoding.py +69 -0
  19. shadowshield/detectors/exfiltration.py +142 -0
  20. shadowshield/detectors/jailbreak.py +106 -0
  21. shadowshield/detectors/llm_check.py +107 -0
  22. shadowshield/detectors/pii.py +116 -0
  23. shadowshield/detectors/prompt_injection.py +385 -0
  24. shadowshield/detectors/transformer.py +115 -0
  25. shadowshield/detectors/vector.py +137 -0
  26. shadowshield/eval/__init__.py +31 -0
  27. shadowshield/eval/data/builtin_benchmark.jsonl +75 -0
  28. shadowshield/eval/dataset.py +120 -0
  29. shadowshield/eval/harness.py +206 -0
  30. shadowshield/integrations/__init__.py +19 -0
  31. shadowshield/integrations/agentdojo.py +142 -0
  32. shadowshield/middleware/__init__.py +20 -0
  33. shadowshield/middleware/base.py +84 -0
  34. shadowshield/middleware/decorators.py +39 -0
  35. shadowshield/middleware/langchain.py +83 -0
  36. shadowshield/middleware/openai.py +88 -0
  37. shadowshield/plugins/__init__.py +6 -0
  38. shadowshield/plugins/base.py +37 -0
  39. shadowshield/plugins/manager.py +69 -0
  40. shadowshield/py.typed +0 -0
  41. shadowshield/responders/__init__.py +22 -0
  42. shadowshield/responders/base.py +40 -0
  43. shadowshield/responders/blocker.py +50 -0
  44. shadowshield/responders/isolator.py +58 -0
  45. shadowshield/responders/rate_limiter.py +94 -0
  46. shadowshield/responders/sanitizer.py +53 -0
  47. shadowshield/utils/__init__.py +23 -0
  48. shadowshield/utils/logging.py +89 -0
  49. shadowshield/utils/scoring.py +42 -0
  50. shadowshield/utils/text.py +175 -0
  51. shadowshield-0.4.0.dist-info/METADATA +517 -0
  52. shadowshield-0.4.0.dist-info/RECORD +55 -0
  53. shadowshield-0.4.0.dist-info/WHEEL +4 -0
  54. shadowshield-0.4.0.dist-info/entry_points.txt +2 -0
  55. shadowshield-0.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,89 @@
1
+ """Structured logging + an append-only JSONL audit sink.
2
+
3
+ ShadowShield uses :mod:`structlog` so every security event is a structured
4
+ record (easy to ship to a SIEM) rather than an opaque string. The audit sink is
5
+ deliberately simple — newline-delimited JSON — so it works everywhere with no
6
+ infrastructure.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import structlog
18
+
19
+ from ..core.config import LoggingConfig
20
+
21
+ _CONFIGURED = False
22
+
23
+
24
+ def configure_logging(config: LoggingConfig) -> Any:
25
+ """Configure structlog once and return a bound ShadowShield logger.
26
+
27
+ Logs are written to **stderr** (never stdout) so ShadowShield can sit on a
28
+ stdin/stdout model pipeline without corrupting it.
29
+ """
30
+ global _CONFIGURED
31
+ if not _CONFIGURED:
32
+ structlog.configure(
33
+ processors=[
34
+ structlog.contextvars.merge_contextvars,
35
+ structlog.processors.add_log_level,
36
+ structlog.processors.TimeStamper(fmt="iso", utc=True),
37
+ structlog.processors.JSONRenderer(),
38
+ ],
39
+ wrapper_class=structlog.make_filtering_bound_logger(
40
+ getattr(logging, config.level, logging.INFO)
41
+ ),
42
+ # stderr, not stdout — keep stdout clean for the host application.
43
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
44
+ cache_logger_on_first_use=True,
45
+ )
46
+ _CONFIGURED = True
47
+ return structlog.get_logger("shadowshield")
48
+
49
+
50
+ class AuditLog:
51
+ """Append-only JSONL audit trail for scan decisions.
52
+
53
+ Each call to :meth:`record` writes exactly one JSON object on its own line.
54
+ When ``redact_payloads`` is set (the default), raw offending text is never
55
+ written — only a short, truncated preview — so the audit log can't itself
56
+ become a data-leak vector.
57
+ """
58
+
59
+ def __init__(self, config: LoggingConfig) -> None:
60
+ self._config = config
61
+ self._path = Path(config.audit_path) if config.audit_path else None
62
+ if self._path is not None:
63
+ self._path.parent.mkdir(parents=True, exist_ok=True)
64
+ self._logger = configure_logging(config)
65
+
66
+ @property
67
+ def redact(self) -> bool:
68
+ return self._config.redact_payloads
69
+
70
+ def record(self, event: dict[str, Any], *, notable: bool = True) -> None:
71
+ """Emit a security event to structlog and (if configured) the JSONL file.
72
+
73
+ ``notable`` events (a threat was found / the payload wasn't clean) are
74
+ logged at INFO; clean scans are logged at DEBUG so the default INFO level
75
+ stays quiet on normal traffic. The JSONL audit file, when configured,
76
+ records **every** decision regardless — it's the complete trail.
77
+ """
78
+ if not self._config.enabled:
79
+ return
80
+ if notable:
81
+ self._logger.info("shadowshield.scan", **event)
82
+ else:
83
+ self._logger.debug("shadowshield.scan", **event)
84
+ if self._path is not None:
85
+ try:
86
+ with self._path.open("a", encoding="utf-8") as fh:
87
+ fh.write(json.dumps(event, ensure_ascii=False, default=str) + "\n")
88
+ except OSError as exc: # never let audit IO break the request path
89
+ self._logger.warning("shadowshield.audit_write_failed", error=str(exc))
@@ -0,0 +1,42 @@
1
+ """Aggregation of many per-detector threats into one payload-level verdict.
2
+
3
+ The aggregation philosophy is **noisy-or with severity weighting**: independent
4
+ detectors each raise the overall suspicion, but no single weak signal can be
5
+ washed out by averaging. A confident HIGH from one detector should dominate even
6
+ if ten other detectors stayed silent — averaging would dilute it, which is the
7
+ wrong failure mode for security.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..core.types import Severity, Threat
13
+ from .text import truncate # noqa: F401 (re-exported for convenience)
14
+
15
+
16
+ def aggregate_score(threats: list[Threat], weights: dict[str, float] | None = None) -> float:
17
+ """Combine weighted detector scores via a noisy-or.
18
+
19
+ Each threat contributes ``p_i = clamp(score * weight)``. The combined
20
+ probability that *something* is wrong is ``1 - Π(1 - p_i)`` — monotonic,
21
+ bounded in ``[0, 1]``, and order-independent. More corroborating detectors
22
+ push the score up; one strong detector already pushes it high.
23
+ """
24
+ if not threats:
25
+ return 0.0
26
+ weights = weights or {}
27
+ complement = 1.0
28
+ for t in threats:
29
+ w = weights.get(t.detector, 1.0)
30
+ p = max(0.0, min(1.0, t.score * w))
31
+ complement *= 1.0 - p
32
+ return 1.0 - complement
33
+
34
+
35
+ def aggregate_severity(threats: list[Threat], score: float) -> Severity:
36
+ """The payload severity is the max of the strongest finding and the
37
+ score-implied band — so a pile of LOW findings that together score HIGH is
38
+ treated as HIGH, and a lone CRITICAL is never downgraded by a low aggregate.
39
+ """
40
+ by_threat = max((t.severity for t in threats), default=Severity.NONE)
41
+ by_score = Severity.from_score(score)
42
+ return max(by_threat, by_score)
@@ -0,0 +1,175 @@
1
+ """Text normalisation and decoding helpers.
2
+
3
+ Many prompt-injection evasions rely on the *rendering* of text differing from
4
+ its *bytes*: zero-width characters splitting keywords, homoglyphs, bidirectional
5
+ overrides, fullwidth Unicode, or payloads hidden in base64/hex. The detectors
6
+ operate on a normalised view produced here so a single attack can't dodge every
7
+ detector by changing its surface form.
8
+
9
+ Nothing in this module *decides* anything — it only exposes the hidden surface.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import binascii
16
+ import re
17
+ import unicodedata
18
+ from dataclasses import dataclass
19
+
20
+ # Characters used to hide / split / reorder text. Stripping these collapses many
21
+ # obfuscation tricks back to their visible meaning.
22
+ INVISIBLE_CHARS = (
23
+ "​‌‍‎‏" # zero-width space/joiners, LRM/RLM
24
+ "‪‫‬‭‮" # bidirectional embeddings/overrides
25
+ "⁠⁡⁢⁣⁤" # word joiner / invisible operators
26
+ "⁦⁧⁨⁩" # isolates
27
+ "" # BOM / zero-width no-break space
28
+ )
29
+ _INVISIBLE_RE = re.compile(f"[{re.escape(INVISIBLE_CHARS)}]")
30
+
31
+ # Homoglyph confusables -> ASCII. Small, high-value subset (Cyrillic/Greek
32
+ # look-alikes commonly used to spell "ignore", "system", etc.).
33
+ _CONFUSABLES = {
34
+ "а": "a",
35
+ "е": "e",
36
+ "о": "o",
37
+ "р": "p",
38
+ "с": "c",
39
+ "х": "x",
40
+ "у": "y",
41
+ "і": "i",
42
+ "ѕ": "s",
43
+ "Ѕ": "s",
44
+ "ј": "j",
45
+ "ԁ": "d",
46
+ "ո": "n",
47
+ "ⅼ": "l",
48
+ "ɡ": "g",
49
+ "һ": "h",
50
+ "Α": "A",
51
+ "Β": "B",
52
+ "Ε": "E",
53
+ "Ζ": "Z",
54
+ "Η": "H",
55
+ "Ι": "I",
56
+ "Κ": "K",
57
+ "Μ": "M",
58
+ "Ν": "N",
59
+ "Ο": "O",
60
+ "Ρ": "P",
61
+ "Τ": "T",
62
+ "Υ": "Y",
63
+ "Χ": "X",
64
+ }
65
+
66
+ # Lookarounds (not \b) so trailing '=' padding is captured — '=' is a non-word
67
+ # char, so \b after it would drop the padding and break the length check.
68
+ _B64_RE = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{16,}={0,2}(?![A-Za-z0-9+/=])")
69
+ _HEX_RE = re.compile(r"\b(?:0x)?[0-9a-fA-F]{16,}\b")
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class NormalizedText:
74
+ """The result of :func:`normalize`.
75
+
76
+ Attributes:
77
+ original: The text exactly as received.
78
+ normalized: Lower-noise view — invisibles stripped, NFKC-folded,
79
+ confusables mapped, whitespace collapsed. Detectors match on this.
80
+ had_invisible: Whether any invisible/bidi control chars were present
81
+ (a signal in its own right).
82
+ had_confusables: Whether any homoglyph substitution happened.
83
+ """
84
+
85
+ original: str
86
+ normalized: str
87
+ had_invisible: bool
88
+ had_confusables: bool
89
+
90
+
91
+ def normalize(text: str) -> NormalizedText:
92
+ """Produce a normalised, de-obfuscated view of ``text``.
93
+
94
+ Order matters: strip invisibles first (so they can't survive folding), then
95
+ NFKC-normalise (collapses fullwidth/compatibility forms), then map known
96
+ confusables, then collapse runs of whitespace.
97
+ """
98
+ had_invisible = bool(_INVISIBLE_RE.search(text))
99
+ stripped = _INVISIBLE_RE.sub("", text)
100
+
101
+ folded = unicodedata.normalize("NFKC", stripped)
102
+
103
+ had_confusables = any(ch in _CONFUSABLES for ch in folded)
104
+ if had_confusables:
105
+ folded = "".join(_CONFUSABLES.get(ch, ch) for ch in folded)
106
+
107
+ collapsed = re.sub(r"\s+", " ", folded).strip()
108
+ return NormalizedText(
109
+ original=text,
110
+ normalized=collapsed,
111
+ had_invisible=had_invisible,
112
+ had_confusables=had_confusables,
113
+ )
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class DecodedSegment:
118
+ """A successfully decoded hidden segment found inside text."""
119
+
120
+ encoding: str # "base64" | "hex"
121
+ source: str # the encoded substring
122
+ decoded: str # the decoded, printable text
123
+ span: tuple[int, int]
124
+
125
+
126
+ def _is_mostly_printable(data: bytes, threshold: float = 0.85) -> bool:
127
+ if not data:
128
+ return False
129
+ printable = sum(1 for b in data if 0x09 <= b <= 0x7E)
130
+ return printable / len(data) >= threshold
131
+
132
+
133
+ def extract_encoded_segments(text: str, *, min_decoded_len: int = 6) -> list[DecodedSegment]:
134
+ """Find and decode base64/hex blobs that resolve to readable text.
135
+
136
+ Only segments that decode to *mostly printable* ASCII are returned — random
137
+ high-entropy strings (hashes, ids) decode to noise and are ignored, which
138
+ keeps the false-positive rate low.
139
+ """
140
+ out: list[DecodedSegment] = []
141
+
142
+ for m in _B64_RE.finditer(text):
143
+ token = m.group(0)
144
+ # base64 length must be a multiple of 4 to be valid
145
+ if len(token) % 4 != 0:
146
+ continue
147
+ try:
148
+ raw = base64.b64decode(token, validate=True)
149
+ except (binascii.Error, ValueError):
150
+ continue
151
+ if _is_mostly_printable(raw):
152
+ decoded = raw.decode("ascii", errors="replace")
153
+ if len(decoded.strip()) >= min_decoded_len:
154
+ out.append(DecodedSegment("base64", token, decoded, m.span()))
155
+
156
+ for m in _HEX_RE.finditer(text):
157
+ token = m.group(0).removeprefix("0x")
158
+ if len(token) % 2 != 0:
159
+ continue
160
+ try:
161
+ raw = bytes.fromhex(token)
162
+ except ValueError:
163
+ continue
164
+ if _is_mostly_printable(raw):
165
+ decoded = raw.decode("ascii", errors="replace")
166
+ if len(decoded.strip()) >= min_decoded_len:
167
+ out.append(DecodedSegment("hex", m.group(0), decoded, m.span()))
168
+
169
+ return out
170
+
171
+
172
+ def truncate(text: str, limit: int = 120) -> str:
173
+ """Shorten text for log lines without leaking the whole payload."""
174
+ text = text.replace("\n", "\\n")
175
+ return text if len(text) <= limit else text[: limit - 1] + "…"