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.
- shadowshield/__init__.py +124 -0
- shadowshield/cli.py +153 -0
- shadowshield/config/__init__.py +15 -0
- shadowshield/config/default.yaml +95 -0
- shadowshield/core/__init__.py +46 -0
- shadowshield/core/canary.py +90 -0
- shadowshield/core/config.py +242 -0
- shadowshield/core/engine.py +255 -0
- shadowshield/core/session.py +138 -0
- shadowshield/core/shield.py +359 -0
- shadowshield/core/types.py +222 -0
- shadowshield/detectors/__init__.py +64 -0
- shadowshield/detectors/alignment.py +125 -0
- shadowshield/detectors/anomaly.py +125 -0
- shadowshield/detectors/base.py +136 -0
- shadowshield/detectors/canary.py +51 -0
- shadowshield/detectors/data/attack_corpus.txt +83 -0
- shadowshield/detectors/encoding.py +69 -0
- shadowshield/detectors/exfiltration.py +142 -0
- shadowshield/detectors/jailbreak.py +106 -0
- shadowshield/detectors/llm_check.py +107 -0
- shadowshield/detectors/pii.py +116 -0
- shadowshield/detectors/prompt_injection.py +385 -0
- shadowshield/detectors/transformer.py +115 -0
- shadowshield/detectors/vector.py +137 -0
- shadowshield/eval/__init__.py +31 -0
- shadowshield/eval/data/builtin_benchmark.jsonl +75 -0
- shadowshield/eval/dataset.py +120 -0
- shadowshield/eval/harness.py +206 -0
- shadowshield/integrations/__init__.py +19 -0
- shadowshield/integrations/agentdojo.py +142 -0
- shadowshield/middleware/__init__.py +20 -0
- shadowshield/middleware/base.py +84 -0
- shadowshield/middleware/decorators.py +39 -0
- shadowshield/middleware/langchain.py +83 -0
- shadowshield/middleware/openai.py +88 -0
- shadowshield/plugins/__init__.py +6 -0
- shadowshield/plugins/base.py +37 -0
- shadowshield/plugins/manager.py +69 -0
- shadowshield/py.typed +0 -0
- shadowshield/responders/__init__.py +22 -0
- shadowshield/responders/base.py +40 -0
- shadowshield/responders/blocker.py +50 -0
- shadowshield/responders/isolator.py +58 -0
- shadowshield/responders/rate_limiter.py +94 -0
- shadowshield/responders/sanitizer.py +53 -0
- shadowshield/utils/__init__.py +23 -0
- shadowshield/utils/logging.py +89 -0
- shadowshield/utils/scoring.py +42 -0
- shadowshield/utils/text.py +175 -0
- shadowshield-0.4.0.dist-info/METADATA +517 -0
- shadowshield-0.4.0.dist-info/RECORD +55 -0
- shadowshield-0.4.0.dist-info/WHEEL +4 -0
- shadowshield-0.4.0.dist-info/entry_points.txt +2 -0
- 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] + "…"
|