stackone-defender 0.7.4__tar.gz → 0.9.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.
- stackone_defender-0.9.0/.release-please-manifest.json +1 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/CHANGELOG.md +32 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/PKG-INFO +70 -18
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/README.md +69 -17
- stackone_defender-0.9.0/prof.out +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/pyproject.toml +1 -1
- stackone_defender-0.9.0/scripts/sync_conformance.sh +47 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/__init__.py +2 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/onnx_classifier.py +85 -2
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/patterns.py +6 -2
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier2_classifier.py +45 -5
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/config.py +6 -1
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/core/prompt_defense.py +328 -68
- stackone_defender-0.9.0/src/stackone_defender/core/sentence_cleaner.py +82 -0
- stackone_defender-0.9.0/src/stackone_defender/core/tier3_input.py +634 -0
- stackone_defender-0.9.0/src/stackone_defender/core/tool_result_sanitizer.py +460 -0
- stackone_defender-0.9.0/src/stackone_defender/sanitizers/__init__.py +15 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/encoding_detector.py +3 -1
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/types.py +51 -3
- stackone_defender-0.9.0/tests/conformance/README.md +82 -0
- stackone_defender-0.9.0/tests/conformance/VERSION +1 -0
- stackone_defender-0.9.0/tests/conformance/tier3-parse.json +176 -0
- stackone_defender-0.9.0/tests/conformance/tier3-policy.json +1742 -0
- stackone_defender-0.9.0/tests/conformance/tier3-prompt.json +342 -0
- stackone_defender-0.9.0/tests/conformance/tier3-redact.json +242 -0
- stackone_defender-0.9.0/tests/conformance/tier3-serialize.json +3794 -0
- stackone_defender-0.9.0/tests/conformance_known_failures.txt +97 -0
- stackone_defender-0.9.0/tests/test_conformance.py +357 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_integration.py +205 -8
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_onnx_classifier.py +3 -4
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_sanitizers.py +0 -194
- stackone_defender-0.9.0/tests/test_sentence_cleaner.py +87 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_tier2_classifier.py +94 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_tier3.py +287 -14
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/uv.lock +2 -2
- stackone_defender-0.7.4/.release-please-manifest.json +0 -1
- stackone_defender-0.7.4/src/stackone_defender/core/tool_result_sanitizer.py +0 -361
- stackone_defender-0.7.4/src/stackone_defender/sanitizers/__init__.py +0 -31
- stackone_defender-0.7.4/src/stackone_defender/sanitizers/pattern_remover.py +0 -113
- stackone_defender-0.7.4/src/stackone_defender/sanitizers/sanitizer.py +0 -243
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/.gitignore +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/.python-version +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/.release-please-config.json +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/LICENSE +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/__init__.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/pattern_detector.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/tier3_orchestrator.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/core/__init__.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/classifier_config.json +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/config.json +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/model_quantized.onnx +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer.json +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/models/minilm-multihead-v5/tokenizer_config.json +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/py.typed +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/leet_normalizer.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/normalizer.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sanitizers/role_stripper.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sfe/__init__.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sfe/model.ftz +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/sfe/preprocess.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/__init__.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/boundary.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/field_detection.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/utils/structure.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/__init__.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_pattern_detector.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_sfe.py +0 -0
- {stackone_defender-0.7.4 → stackone_defender-0.9.0}/tests/test_utils.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{".":"0.9.0"}
|
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.8.2...stackone-defender-v0.9.0) (2026-10-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **ENG-3500:** record-oriented, chunked Tier-3 review in tier3_only ([#35](https://github.com/StackOneHQ/defender-py/issues/35)) ([072d7ed](https://github.com/StackOneHQ/defender-py/commit/072d7ed7f0474afa5ffe87a66920d279ad7fafdd))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Miscellaneous Chores
|
|
12
|
+
|
|
13
|
+
* **ENG-3500:** release 0.9.0 ([#37](https://github.com/StackOneHQ/defender-py/issues/37)) ([aa4a4e3](https://github.com/StackOneHQ/defender-py/commit/aa4a4e39fcb7a7adc6d43fbab8d7b84817613dbc))
|
|
14
|
+
|
|
15
|
+
## [0.8.2](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.4...stackone-defender-v0.8.2) (2026-08-19)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **ENG-1084:** detect-and-gate + sentence-level cleaning — Tier 1 detects without mutating content (gate on `allowed`); `sanitized` is a sentence-cleaned copy of high/critical fields ([60b01d0](https://github.com/StackOneHQ/defender-py/commit/60b01d0117c04106a3017dc4bf536cce6cc7a6c4))
|
|
21
|
+
* **ENG-1084:** mark dropped runs with `[CONTENT SANITISED]` in the cleaned copy ([fdcdb94](https://github.com/StackOneHQ/defender-py/commit/fdcdb94e27698ac71a8dd8933d54ad47455be376))
|
|
22
|
+
* **ENG-1084:** `detected_field_count` threat-count signal; object-key scanning; Tier 2 availability (`require_tier2` / `tier2_available`) ([6f1b52a](https://github.com/StackOneHQ/defender-py/commit/6f1b52afd9d2932aab9462776c102d05e5bd9626))
|
|
23
|
+
* **ENG-1084:** NFKC-fold unicode before Tier 2 classification ([50741ee](https://github.com/StackOneHQ/defender-py/commit/50741eeb7c9d7e435a2a504d2f83c3bd9b2b8c04))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Bug Fixes
|
|
27
|
+
|
|
28
|
+
* **ENG-1084:** return large arrays in full — 0.7.4 truncated arrays >1000 items to 100 + a sentinel, dropping the rest; Tier 1 detection is now bounded by the `max_size` budget (flagged via `coverage_degraded`), never drops data ([26c7eec](https://github.com/StackOneHQ/defender-py/commit/26c7eecb10f55be0a9e7cb614a67629fa485d199))
|
|
29
|
+
* **ENG-1084:** scan strings inside risky array fields (`{"name": [INJ]}`) ([6f1b52a](https://github.com/StackOneHQ/defender-py/commit/6f1b52afd9d2932aab9462776c102d05e5bd9626))
|
|
30
|
+
* **ENG-1084:** evidence-driven encoding escalation; token-degeneracy (OOD) guard; decorative-run normalization ([f450992](https://github.com/StackOneHQ/defender-py/commit/f45099241e36ee4210daaf39f2573c2a88d3071b))
|
|
31
|
+
* **ENG-1084:** gate sentence-cleaning on aggregate risk; verbatim when nothing is dropped ([9e25335](https://github.com/StackOneHQ/defender-py/commit/9e2533576f90574f8bab5476728d79c5275b9060))
|
|
32
|
+
* **ENG-1084:** count objects/arrays once in size metrics + iterate `obj.items()` directly ([26c7eec](https://github.com/StackOneHQ/defender-py/commit/26c7eecb10f55be0a9e7cb614a67629fa485d199))
|
|
33
|
+
* **ENG-1084:** warmup fail-open + dict-subclass stripping ([f49ffcf](https://github.com/StackOneHQ/defender-py/commit/f49ffcf6e93e322623447d217994cc8215e02410))
|
|
34
|
+
|
|
3
35
|
## [0.7.4](https://github.com/StackOneHQ/defender-py/compare/stackone-defender-v0.7.3...stackone-defender-v0.7.4) (2026-08-13)
|
|
4
36
|
|
|
5
37
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: stackone-defender
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Indirect prompt injection defense for AI agents using tool calls
|
|
5
5
|
Project-URL: Homepage, https://github.com/StackOneHQ/stackone-defender
|
|
6
6
|
Project-URL: Repository, https://github.com/StackOneHQ/stackone-defender
|
|
@@ -116,10 +116,12 @@ else:
|
|
|
116
116
|
|
|
117
117
|
### Tier 1 — Pattern detection (sync, ~1 ms)
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
- **
|
|
122
|
-
- **
|
|
119
|
+
Detects (never rewrites) injection signals and records them as evidence:
|
|
120
|
+
|
|
121
|
+
- **Unicode normalization** (for matching only) — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
|
|
122
|
+
- **Role markers** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
|
|
123
|
+
- **Instruction-override patterns** — phrases like “ignore previous instructions”
|
|
124
|
+
- **Encoding detection** — decode-then-detect: a decoded payload is escalated only if it trips a real attack pattern
|
|
123
125
|
- **Boundary annotation (opt-in)** — `[UD-{id}]…[/UD-{id}]` wrappers when `annotate_boundary=True` (npm: `annotateBoundary`). Use `generate_boundary_instructions` from the package root in prompts when you enable wrapping.
|
|
124
126
|
|
|
125
127
|
### Tier 2 — ML classification (ONNX)
|
|
@@ -133,7 +135,7 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
|
|
|
133
135
|
### Optional SFE preprocessor
|
|
134
136
|
|
|
135
137
|
- `use_sfe=True` runs a field-level FastText pass to build a **classifier-only** view of the payload
|
|
136
|
-
- **Tier 1**
|
|
138
|
+
- **Tier 1** detects on the raw tool value; SFE drops are classifier-only and never remove fields from the returned `sanitized` payload
|
|
137
139
|
- **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
|
|
138
140
|
- Fails open if the runtime/model is unavailable: payload continues unfiltered
|
|
139
141
|
|
|
@@ -152,7 +154,7 @@ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. The
|
|
|
152
154
|
|
|
153
155
|
Two modes, selected via `defender_mode`:
|
|
154
156
|
- **`"cascade"`** (default): Tier 1 → Tier 2 → Tier 3, with Tier 3 invoked only when the Tier 2 effective score falls in the gray band (default `[0.3, 0.85)`). The Tier 3 verdict overrides Tier 2 on the escalated chunk — a `block` forces a block, an `allow` rescues it. Outside the band defender skips the round trip.
|
|
155
|
-
- **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still
|
|
157
|
+
- **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still runs detection; the returned `sanitized` payload is the original content.
|
|
156
158
|
|
|
157
159
|
Register a provider once at startup, then opt in per instance:
|
|
158
160
|
|
|
@@ -177,20 +179,36 @@ defense = create_prompt_defense(
|
|
|
177
179
|
tier3={
|
|
178
180
|
"provider": MyProvider(), # optional: overrides the registry for this instance
|
|
179
181
|
"escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
|
|
180
|
-
"max_text_length": 10000, #
|
|
182
|
+
"max_text_length": 10000, # max chars per provider call
|
|
183
|
+
"max_chunks": 5, # tier3_only: chunks reviewed per tool result
|
|
184
|
+
"on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
|
|
181
185
|
"block_threshold": 0.622, # optional; decide on score, not the model's word
|
|
182
186
|
},
|
|
183
187
|
)
|
|
184
188
|
```
|
|
185
189
|
|
|
190
|
+
**What the reviewer sees (`tier3_only`).** The tool result is serialized as `field: value` lines, one block per record, so a bare value keeps its field name (`status: create` rather than `create`, which reads like an instruction). Nested keys are dotted (`user.name: …`), list items are indexed (`items[0]: …`), and each line of a multi-line value keeps its prefix. A large list of only numbers collapses to `key: [N numbers]`. Keys are reviewed even when the value is a number, boolean, `None` or empty, because keys can carry injected text. Python-only types are reviewed too: mappings and dataclasses as objects, sets and other sequences as lists, UTF-8 `bytes` as text, and anything else as `str(value)`.
|
|
191
|
+
|
|
192
|
+
The serialized text is split into chunks of at most `max_text_length` characters (lengths count UTF-16 code units, as in the TypeScript package). Neighbouring chunks overlap by up to 500 characters, so an injection cut at a chunk boundary still appears whole. All chunks are reviewed in parallel, and any blocking chunk blocks the result. `result.tier3_chunk_summary` reports `chunks`, `blocked` and `blocking_chunk`.
|
|
193
|
+
|
|
194
|
+
**Oversize (`on_oversize`).** Up to about `max_chunks` chunks are reviewed per tool result. Content beyond that is oversize, and `coverage_degraded` is `True`. Blocking below requires `block_high_risk=True`.
|
|
195
|
+
- `"skip"` (default): review the content that fits and allow the rest. A fitting injection still blocks. This favors availability, so set `"block"` or `"scan_anyway"` to fail closed.
|
|
196
|
+
- `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
|
|
197
|
+
- `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
|
|
198
|
+
|
|
199
|
+
In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
|
|
200
|
+
|
|
201
|
+
A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_degraded` are `True`. With a verdict, `result.tier3.note` says how many chunks failed; it also gives the reason for an `on_oversize` block when the provider allowed. When every chunk fails there's no verdict, and `result.tier3.skip_reason` gives the first failure.
|
|
202
|
+
|
|
186
203
|
**Choosing the operating point (`block_threshold`).** By default the model's `decision` word is authoritative — but that word is its argmax, an implicit 0.5 cut that moves on its own whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead: the cut becomes an explicit config value — raise it to trade recall for fewer false positives, lower it for the reverse; `0.5` reproduces argmax. It requires a provider that reports `score` as P(block) (not "confidence in whichever decision I made" — those invert on allows). If `score` is missing or outside `[0, 1]` the verdict's `decision` is used instead and defender warns once, so a provider that cannot report a score degrades to the default rather than failing.
|
|
187
204
|
|
|
188
205
|
**Fail-open semantics.** A provider error or timeout records a `skip_reason` on `result.tier3`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows the request. `enable_tier3=True` with no registered provider falls back to the standard Tier 1 + Tier 2 cascade and logs one warning per instance — Tier 3 misconfiguration never silently disables defense. `result.tier3` carries the verdict (a `Tier3Verdict`, or a `Tier3Skip` when the provider ran but returned nothing usable) when Tier 3 runs, and is `None` when it doesn't.
|
|
189
206
|
|
|
190
207
|
### `allowed` vs `risk_level`
|
|
191
208
|
|
|
209
|
+
- **`DefenseResult.sanitized`** is a **sentence-level cleaned** copy of the tool result (high-scoring sentences dropped within high-risk fields, optionally `[UD-…]` boundary-wrapped). Cleaning is best-effort (capped by detection) — still gate on `allowed`. Set `sanitize_content=False` for pure detect-and-gate: `sanitized` is then the input verbatim.
|
|
192
210
|
- Use **`allowed`** for gating when `block_high_risk=True`: `False` means do not pass `sanitized` to the model as-is.
|
|
193
|
-
- **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"
|
|
211
|
+
- **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"low"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
|
|
194
212
|
|
|
195
213
|
| Level | Typical trigger |
|
|
196
214
|
|-------|------------------|
|
|
@@ -206,8 +224,9 @@ defense = create_prompt_defense(
|
|
|
206
224
|
defense = create_prompt_defense(
|
|
207
225
|
enable_tier1=True,
|
|
208
226
|
enable_tier2=True,
|
|
227
|
+
require_tier2=False, # True: raise if Tier 2 can't load (fail closed) instead of degrading to Tier 1
|
|
209
228
|
block_high_risk=False,
|
|
210
|
-
default_risk_level="
|
|
229
|
+
default_risk_level="low",
|
|
211
230
|
annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
|
|
212
231
|
tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
|
|
213
232
|
use_sfe=True, # optional: enable semantic field extractor preprocessing
|
|
@@ -229,18 +248,32 @@ from dataclasses import dataclass, field
|
|
|
229
248
|
|
|
230
249
|
@dataclass
|
|
231
250
|
class DefenseResult:
|
|
232
|
-
allowed: bool
|
|
233
|
-
risk_level: RiskLevel
|
|
234
|
-
sanitized: Any
|
|
235
|
-
detections: list[str]
|
|
236
|
-
fields_sanitized: list[str]
|
|
237
|
-
patterns_by_field: dict[str, list[str]]
|
|
251
|
+
allowed: bool # gating decision (respects block_high_risk)
|
|
252
|
+
risk_level: RiskLevel # diagnostic; max of Tier 1 / Tier 2
|
|
253
|
+
sanitized: Any # sentence-cleaned copy (input verbatim when sanitize_content=False); dropped runs leave a [CONTENT SANITISED] marker; best-effort, still gate on allowed
|
|
254
|
+
detections: list[str] # Tier 1 pattern names detected
|
|
255
|
+
fields_sanitized: list[str] # fields whose content the cleaner changed in sanitized (empty when sanitize_content=False or no Tier 2); for detections read detections/patterns_by_field
|
|
256
|
+
patterns_by_field: dict[str, list[str]] # patterns detected per field
|
|
257
|
+
detected_field_count: int # count of fields with a Tier-1 detection (keys of patterns_by_field); threat-count signal (fields_sanitized len no longer tracks this)
|
|
238
258
|
tier2_score: float | None = None
|
|
259
|
+
tier2_raw_score: float | None = None
|
|
260
|
+
tier2_aux_score: float | None = None # multi-head models only
|
|
261
|
+
tier2_multihead_blocked: bool | None = None
|
|
239
262
|
tier2_skip_reason: str | None = None
|
|
240
263
|
max_sentence: str | None = None
|
|
264
|
+
tier3: Tier3Result | None = None # present when Tier 3 ran
|
|
241
265
|
fields_dropped: list[str] = field(default_factory=list)
|
|
242
266
|
truncated_at_depth: bool | None = None
|
|
243
267
|
latency_ms: float = 0.0
|
|
268
|
+
# Cost telemetry — present only when the batched Tier 2 classifier ran
|
|
269
|
+
phase_timings: PhaseTimings | None = None # prepare / infer / aggregate ms
|
|
270
|
+
tier2_stats: Tier2Stats | None = None # string/chunk/unique counts, real/padded tokens
|
|
271
|
+
tier1_ms: float | None = None
|
|
272
|
+
cold_load: bool | None = None
|
|
273
|
+
# Operational signals
|
|
274
|
+
tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
|
|
275
|
+
coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
|
|
276
|
+
tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
|
|
244
277
|
```
|
|
245
278
|
|
|
246
279
|
### `defense.defend_tool_results(items)`
|
|
@@ -255,7 +288,7 @@ results = defense.defend_tool_results([
|
|
|
255
288
|
])
|
|
256
289
|
for r in results:
|
|
257
290
|
if not r.allowed:
|
|
258
|
-
print("Blocked:", ", ".join(r.
|
|
291
|
+
print("Blocked:", ", ".join(r.detections))
|
|
259
292
|
```
|
|
260
293
|
|
|
261
294
|
### `await defense.defend_tool_results_async(items)`
|
|
@@ -300,7 +333,7 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
|
|
|
300
333
|
|
|
301
334
|
## Risky field detection
|
|
302
335
|
|
|
303
|
-
Only **string** values under configured “risky” keys are scanned
|
|
336
|
+
Only **string** values under configured “risky” keys are Tier-1-scanned — including strings nested inside arrays/objects under those keys (e.g. `{"name": ["…"]}`). [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package. (Tier 2 scans all extracted strings regardless.)
|
|
304
337
|
|
|
305
338
|
| Tool pattern | Scanned fields |
|
|
306
339
|
|--------------|----------------|
|
|
@@ -320,6 +353,25 @@ uv sync --group dev
|
|
|
320
353
|
uv run pytest
|
|
321
354
|
```
|
|
322
355
|
|
|
356
|
+
### Parity with the TypeScript package
|
|
357
|
+
|
|
358
|
+
This package mirrors [`@stackone/defender`](https://github.com/StackOneHQ/defender). `tests/conformance/` is a pinned copy of its shared conformance fixtures: input → expected-output cases for Tier-3 serialization, prompt rendering, verdict parsing, error redaction and policy decisions. `tests/test_conformance.py` runs them against this package.
|
|
359
|
+
|
|
360
|
+
- `scripts/sync_conformance.sh <ref>` copies the fixtures at a TS tag or commit and records it in `tests/conformance/VERSION`. CI checks the copy matches (`--check`).
|
|
361
|
+
- Cases this package doesn't match yet are listed in `tests/conformance_known_failures.txt` and run as strict xfails. When a port makes one pass, remove its line.
|
|
362
|
+
- Port contract for the runner: a rejected prompt template and an unusable verdict reply raise `ValueError`; optional result fields are `None` when unset (not `False`).
|
|
363
|
+
|
|
364
|
+
Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
365
|
+
|
|
366
|
+
- Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
|
|
367
|
+
- TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
|
|
368
|
+
- Number rendering differs: `String(1.0)` is `"1"` but `str(1.0)` is `"1.0"`. Likewise `-0`, `1e-7` and `1e16` render differently in the two languages.
|
|
369
|
+
- `core/tier3_input.py` handles the three traps above for Tier-3 input. It works on a UTF-16 view of each string, orders keys like `Object.entries`, and renders numbers like `String(n)`. One deliberate difference: Python ints below 1e21 print exactly, where JS would round past 2**53.
|
|
370
|
+
- Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
|
|
371
|
+
- `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
|
|
372
|
+
- JS `trim()` removes a BOM, but `str.strip()` doesn't.
|
|
373
|
+
- `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
|
|
374
|
+
|
|
323
375
|
## License
|
|
324
376
|
|
|
325
377
|
Apache-2.0 — see [LICENSE](./LICENSE).
|
|
@@ -90,10 +90,12 @@ else:
|
|
|
90
90
|
|
|
91
91
|
### Tier 1 — Pattern detection (sync, ~1 ms)
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
- **
|
|
96
|
-
- **
|
|
93
|
+
Detects (never rewrites) injection signals and records them as evidence:
|
|
94
|
+
|
|
95
|
+
- **Unicode normalization** (for matching only) — homoglyph resistance (e.g. Cyrillic `а` → ASCII `a`)
|
|
96
|
+
- **Role markers** — `SYSTEM:`, `ASSISTANT:`, `<system>`, `[INST]`, etc.
|
|
97
|
+
- **Instruction-override patterns** — phrases like “ignore previous instructions”
|
|
98
|
+
- **Encoding detection** — decode-then-detect: a decoded payload is escalated only if it trips a real attack pattern
|
|
97
99
|
- **Boundary annotation (opt-in)** — `[UD-{id}]…[/UD-{id}]` wrappers when `annotate_boundary=True` (npm: `annotateBoundary`). Use `generate_boundary_instructions` from the package root in prompts when you enable wrapping.
|
|
98
100
|
|
|
99
101
|
### Tier 2 — ML classification (ONNX)
|
|
@@ -107,7 +109,7 @@ Packed-chunk MiniLM classifier (int8 ONNX ~22 MB, bundled):
|
|
|
107
109
|
### Optional SFE preprocessor
|
|
108
110
|
|
|
109
111
|
- `use_sfe=True` runs a field-level FastText pass to build a **classifier-only** view of the payload
|
|
110
|
-
- **Tier 1**
|
|
112
|
+
- **Tier 1** detects on the raw tool value; SFE drops are classifier-only and never remove fields from the returned `sanitized` payload
|
|
111
113
|
- **Tier 2** extracts strings from the SFE-filtered tree; `fields_dropped` lists paths omitted from that extraction (not removed from `sanitized`)
|
|
112
114
|
- Fails open if the runtime/model is unavailable: payload continues unfiltered
|
|
113
115
|
|
|
@@ -126,7 +128,7 @@ Authoritative LLM-based classification for the cases Tier 2 finds ambiguous. The
|
|
|
126
128
|
|
|
127
129
|
Two modes, selected via `defender_mode`:
|
|
128
130
|
- **`"cascade"`** (default): Tier 1 → Tier 2 → Tier 3, with Tier 3 invoked only when the Tier 2 effective score falls in the gray band (default `[0.3, 0.85)`). The Tier 3 verdict overrides Tier 2 on the escalated chunk — a `block` forces a block, an `allow` rescues it. Outside the band defender skips the round trip.
|
|
129
|
-
- **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still
|
|
131
|
+
- **`"tier3_only"`**: skip Tier 2; the block/allow decision is the Tier 3 verdict alone. Tier 1 still runs detection; the returned `sanitized` payload is the original content.
|
|
130
132
|
|
|
131
133
|
Register a provider once at startup, then opt in per instance:
|
|
132
134
|
|
|
@@ -151,20 +153,36 @@ defense = create_prompt_defense(
|
|
|
151
153
|
tier3={
|
|
152
154
|
"provider": MyProvider(), # optional: overrides the registry for this instance
|
|
153
155
|
"escalation_band": {"lower": 0.3, "upper": 0.85}, # cascade gray band; [lower, upper), defaults shown
|
|
154
|
-
"max_text_length": 10000, #
|
|
156
|
+
"max_text_length": 10000, # max chars per provider call
|
|
157
|
+
"max_chunks": 5, # tier3_only: chunks reviewed per tool result
|
|
158
|
+
"on_oversize": "skip", # tier3_only: "skip" | "block" | "scan_anyway"
|
|
155
159
|
"block_threshold": 0.622, # optional; decide on score, not the model's word
|
|
156
160
|
},
|
|
157
161
|
)
|
|
158
162
|
```
|
|
159
163
|
|
|
164
|
+
**What the reviewer sees (`tier3_only`).** The tool result is serialized as `field: value` lines, one block per record, so a bare value keeps its field name (`status: create` rather than `create`, which reads like an instruction). Nested keys are dotted (`user.name: …`), list items are indexed (`items[0]: …`), and each line of a multi-line value keeps its prefix. A large list of only numbers collapses to `key: [N numbers]`. Keys are reviewed even when the value is a number, boolean, `None` or empty, because keys can carry injected text. Python-only types are reviewed too: mappings and dataclasses as objects, sets and other sequences as lists, UTF-8 `bytes` as text, and anything else as `str(value)`.
|
|
165
|
+
|
|
166
|
+
The serialized text is split into chunks of at most `max_text_length` characters (lengths count UTF-16 code units, as in the TypeScript package). Neighbouring chunks overlap by up to 500 characters, so an injection cut at a chunk boundary still appears whole. All chunks are reviewed in parallel, and any blocking chunk blocks the result. `result.tier3_chunk_summary` reports `chunks`, `blocked` and `blocking_chunk`.
|
|
167
|
+
|
|
168
|
+
**Oversize (`on_oversize`).** Up to about `max_chunks` chunks are reviewed per tool result. Content beyond that is oversize, and `coverage_degraded` is `True`. Blocking below requires `block_high_risk=True`.
|
|
169
|
+
- `"skip"` (default): review the content that fits and allow the rest. A fitting injection still blocks. This favors availability, so set `"block"` or `"scan_anyway"` to fail closed.
|
|
170
|
+
- `"block"`: block without calling the provider. `tier3_chunk_summary` is `None` and `result.tier3.skip_reason` says why.
|
|
171
|
+
- `"scan_anyway"`: review what fits, then block on the unreviewed rest unless a chunk already blocked.
|
|
172
|
+
|
|
173
|
+
In `"skip"` and `"scan_anyway"`, `tier3_chunk_summary.oversize` is `True`.
|
|
174
|
+
|
|
175
|
+
A failed chunk is skipped (fail open), and `result.tier3.errored` and `coverage_degraded` are `True`. With a verdict, `result.tier3.note` says how many chunks failed; it also gives the reason for an `on_oversize` block when the provider allowed. When every chunk fails there's no verdict, and `result.tier3.skip_reason` gives the first failure.
|
|
176
|
+
|
|
160
177
|
**Choosing the operating point (`block_threshold`).** By default the model's `decision` word is authoritative — but that word is its argmax, an implicit 0.5 cut that moves on its own whenever the model is retrained. Set `tier3.block_threshold` to decide on `verdict.score` (P(block)) instead: the cut becomes an explicit config value — raise it to trade recall for fewer false positives, lower it for the reverse; `0.5` reproduces argmax. It requires a provider that reports `score` as P(block) (not "confidence in whichever decision I made" — those invert on allows). If `score` is missing or outside `[0, 1]` the verdict's `decision` is used instead and defender warns once, so a provider that cannot report a score degrades to the default rather than failing.
|
|
161
178
|
|
|
162
179
|
**Fail-open semantics.** A provider error or timeout records a `skip_reason` on `result.tier3`; in cascade defender falls back to the Tier 2 decision, in `tier3_only` it allows the request. `enable_tier3=True` with no registered provider falls back to the standard Tier 1 + Tier 2 cascade and logs one warning per instance — Tier 3 misconfiguration never silently disables defense. `result.tier3` carries the verdict (a `Tier3Verdict`, or a `Tier3Skip` when the provider ran but returned nothing usable) when Tier 3 runs, and is `None` when it doesn't.
|
|
163
180
|
|
|
164
181
|
### `allowed` vs `risk_level`
|
|
165
182
|
|
|
183
|
+
- **`DefenseResult.sanitized`** is a **sentence-level cleaned** copy of the tool result (high-scoring sentences dropped within high-risk fields, optionally `[UD-…]` boundary-wrapped). Cleaning is best-effort (capped by detection) — still gate on `allowed`. Set `sanitize_content=False` for pure detect-and-gate: `sanitized` is then the input verbatim.
|
|
166
184
|
- Use **`allowed`** for gating when `block_high_risk=True`: `False` means do not pass `sanitized` to the model as-is.
|
|
167
|
-
- **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"
|
|
185
|
+
- **`risk_level`** is diagnostic: it starts at `default_risk_level` (default `"low"`) and is **escalated** by Tier 1 / Tier 2 signals — not reduced. Use it for logging, not as the sole block signal unless you implement your own policy.
|
|
168
186
|
|
|
169
187
|
| Level | Typical trigger |
|
|
170
188
|
|-------|------------------|
|
|
@@ -180,8 +198,9 @@ defense = create_prompt_defense(
|
|
|
180
198
|
defense = create_prompt_defense(
|
|
181
199
|
enable_tier1=True,
|
|
182
200
|
enable_tier2=True,
|
|
201
|
+
require_tier2=False, # True: raise if Tier 2 can't load (fail closed) instead of degrading to Tier 1
|
|
183
202
|
block_high_risk=False,
|
|
184
|
-
default_risk_level="
|
|
203
|
+
default_risk_level="low",
|
|
185
204
|
annotate_boundary=False, # True: wrap risky strings with [UD-…] tags (npm: annotateBoundary)
|
|
186
205
|
tier2_fields=["subject", "body", "snippet"], # optional: scope Tier 2 to these JSON keys (default: all strings)
|
|
187
206
|
use_sfe=True, # optional: enable semantic field extractor preprocessing
|
|
@@ -203,18 +222,32 @@ from dataclasses import dataclass, field
|
|
|
203
222
|
|
|
204
223
|
@dataclass
|
|
205
224
|
class DefenseResult:
|
|
206
|
-
allowed: bool
|
|
207
|
-
risk_level: RiskLevel
|
|
208
|
-
sanitized: Any
|
|
209
|
-
detections: list[str]
|
|
210
|
-
fields_sanitized: list[str]
|
|
211
|
-
patterns_by_field: dict[str, list[str]]
|
|
225
|
+
allowed: bool # gating decision (respects block_high_risk)
|
|
226
|
+
risk_level: RiskLevel # diagnostic; max of Tier 1 / Tier 2
|
|
227
|
+
sanitized: Any # sentence-cleaned copy (input verbatim when sanitize_content=False); dropped runs leave a [CONTENT SANITISED] marker; best-effort, still gate on allowed
|
|
228
|
+
detections: list[str] # Tier 1 pattern names detected
|
|
229
|
+
fields_sanitized: list[str] # fields whose content the cleaner changed in sanitized (empty when sanitize_content=False or no Tier 2); for detections read detections/patterns_by_field
|
|
230
|
+
patterns_by_field: dict[str, list[str]] # patterns detected per field
|
|
231
|
+
detected_field_count: int # count of fields with a Tier-1 detection (keys of patterns_by_field); threat-count signal (fields_sanitized len no longer tracks this)
|
|
212
232
|
tier2_score: float | None = None
|
|
233
|
+
tier2_raw_score: float | None = None
|
|
234
|
+
tier2_aux_score: float | None = None # multi-head models only
|
|
235
|
+
tier2_multihead_blocked: bool | None = None
|
|
213
236
|
tier2_skip_reason: str | None = None
|
|
214
237
|
max_sentence: str | None = None
|
|
238
|
+
tier3: Tier3Result | None = None # present when Tier 3 ran
|
|
215
239
|
fields_dropped: list[str] = field(default_factory=list)
|
|
216
240
|
truncated_at_depth: bool | None = None
|
|
217
241
|
latency_ms: float = 0.0
|
|
242
|
+
# Cost telemetry — present only when the batched Tier 2 classifier ran
|
|
243
|
+
phase_timings: PhaseTimings | None = None # prepare / infer / aggregate ms
|
|
244
|
+
tier2_stats: Tier2Stats | None = None # string/chunk/unique counts, real/padded tokens
|
|
245
|
+
tier1_ms: float | None = None
|
|
246
|
+
cold_load: bool | None = None
|
|
247
|
+
# Operational signals
|
|
248
|
+
tier2_available: bool | None = None # False when Tier 2 enabled but failed to load
|
|
249
|
+
coverage_degraded: bool | None = None # True when Tier 1 detection or the Tier 3 review didn't cover everything
|
|
250
|
+
tier3_chunk_summary: Tier3ChunkSummary | None = None # tier3_only: chunks, blocked, blocking_chunk, oversize
|
|
218
251
|
```
|
|
219
252
|
|
|
220
253
|
### `defense.defend_tool_results(items)`
|
|
@@ -229,7 +262,7 @@ results = defense.defend_tool_results([
|
|
|
229
262
|
])
|
|
230
263
|
for r in results:
|
|
231
264
|
if not r.allowed:
|
|
232
|
-
print("Blocked:", ", ".join(r.
|
|
265
|
+
print("Blocked:", ", ".join(r.detections))
|
|
233
266
|
```
|
|
234
267
|
|
|
235
268
|
### `await defense.defend_tool_results_async(items)`
|
|
@@ -274,7 +307,7 @@ sanitized = run_tool_and_defend(gmail_api.get_message(msg_id), "gmail_get_messag
|
|
|
274
307
|
|
|
275
308
|
## Risky field detection
|
|
276
309
|
|
|
277
|
-
Only **string** values under configured “risky” keys are scanned
|
|
310
|
+
Only **string** values under configured “risky” keys are Tier-1-scanned — including strings nested inside arrays/objects under those keys (e.g. `{"name": ["…"]}`). [`RiskyFieldConfig`](https://github.com/StackOneHQ/stackone-defender/blob/main/src/stackone_defender/types.py) provides global names/patterns plus **`tool_overrides`** (wildcard tool names → field list), same idea as the npm package. (Tier 2 scans all extracted strings regardless.)
|
|
278
311
|
|
|
279
312
|
| Tool pattern | Scanned fields |
|
|
280
313
|
|--------------|----------------|
|
|
@@ -294,6 +327,25 @@ uv sync --group dev
|
|
|
294
327
|
uv run pytest
|
|
295
328
|
```
|
|
296
329
|
|
|
330
|
+
### Parity with the TypeScript package
|
|
331
|
+
|
|
332
|
+
This package mirrors [`@stackone/defender`](https://github.com/StackOneHQ/defender). `tests/conformance/` is a pinned copy of its shared conformance fixtures: input → expected-output cases for Tier-3 serialization, prompt rendering, verdict parsing, error redaction and policy decisions. `tests/test_conformance.py` runs them against this package.
|
|
333
|
+
|
|
334
|
+
- `scripts/sync_conformance.sh <ref>` copies the fixtures at a TS tag or commit and records it in `tests/conformance/VERSION`. CI checks the copy matches (`--check`).
|
|
335
|
+
- Cases this package doesn't match yet are listed in `tests/conformance_known_failures.txt` and run as strict xfails. When a port makes one pass, remove its line.
|
|
336
|
+
- Port contract for the runner: a rejected prompt template and an unusable verdict reply raise `ValueError`; optional result fields are `None` when unset (not `False`).
|
|
337
|
+
|
|
338
|
+
Traps when porting from TypeScript (the fixtures pin TS behavior):
|
|
339
|
+
|
|
340
|
+
- Lengths and slicing are in UTF-16 code units in TS, but `len()` counts code points.
|
|
341
|
+
- TS `Object.entries` puts integer-like keys first. `json.loads` keeps insertion order.
|
|
342
|
+
- Number rendering differs: `String(1.0)` is `"1"` but `str(1.0)` is `"1.0"`. Likewise `-0`, `1e-7` and `1e16` render differently in the two languages.
|
|
343
|
+
- `core/tier3_input.py` handles the three traps above for Tier-3 input. It works on a UTF-16 view of each string, orders keys like `Object.entries`, and renders numbers like `String(n)`. One deliberate difference: Python ints below 1e21 print exactly, where JS would round past 2**53.
|
|
344
|
+
- Python `re` has no `\p{Cf}`, and `unicodedata` versions differ across Python releases.
|
|
345
|
+
- `str.splitlines()` also splits on `\x0b`, `\x0c` and `\x1c`, but TS splits only on `\r`, `\n`, `\u2028`, `\u2029` and `\u0085`.
|
|
346
|
+
- JS `trim()` removes a BOM, but `str.strip()` doesn't.
|
|
347
|
+
- `json.loads` accepts `NaN`, but JS `JSON.parse` rejects it. Use `parse_constant` to reject it.
|
|
348
|
+
|
|
297
349
|
## License
|
|
298
350
|
|
|
299
351
|
Apache-2.0 — see [LICENSE](./LICENSE).
|
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Sync the shared conformance fixtures from the TypeScript package (StackOneHQ/defender).
|
|
3
|
+
#
|
|
4
|
+
# scripts/sync_conformance.sh <ref> copy conformance/ at <ref> (a tag like defender-v0.9.2, or a commit SHA)
|
|
5
|
+
# into tests/conformance/ and record <ref> in tests/conformance/VERSION
|
|
6
|
+
# scripts/sync_conformance.sh --check verify tests/conformance/ matches the ref in VERSION (used in CI)
|
|
7
|
+
set -euo pipefail
|
|
8
|
+
|
|
9
|
+
REPO="StackOneHQ/defender"
|
|
10
|
+
DEST="tests/conformance"
|
|
11
|
+
|
|
12
|
+
# A full commit SHA or a release tag only: a branch would float, and anything else may not be a TS ref.
|
|
13
|
+
valid_ref() {
|
|
14
|
+
[[ "$1" =~ ^[0-9a-f]{40}$ || "$1" =~ ^defender-v[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
fetch() {
|
|
18
|
+
local ref="$1" out="$2"
|
|
19
|
+
valid_ref "$ref" || { echo "invalid ref '${ref}': use a full 40-char commit SHA or a defender-vX.Y.Z tag" >&2; exit 2; }
|
|
20
|
+
# Whole archive (small: LFS models come through as pointers); portable across GNU and BSD tar.
|
|
21
|
+
curl -fsSL --retry 3 --retry-delay 2 --max-time 60 "https://codeload.github.com/${REPO}/tar.gz/${ref}" | tar -xz -C "$out" --strip-components=1
|
|
22
|
+
[ -d "$out/conformance" ] || { echo "no conformance/ folder at ${REPO}@${ref}" >&2; exit 1; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
cd "$(dirname "$0")/.."
|
|
26
|
+
tmp="$(mktemp -d)"
|
|
27
|
+
trap 'rm -rf "$tmp"' EXIT
|
|
28
|
+
|
|
29
|
+
if [ "${1:-}" = "--check" ]; then
|
|
30
|
+
ref="$(tr -d '[:space:]' < "$DEST/VERSION")"
|
|
31
|
+
fetch "$ref" "$tmp"
|
|
32
|
+
if diff -r --exclude VERSION "$tmp/conformance" "$DEST"; then
|
|
33
|
+
echo "tests/conformance matches ${REPO}@${ref}"
|
|
34
|
+
else
|
|
35
|
+
echo "tests/conformance differs from ${REPO}@${ref}: re-run scripts/sync_conformance.sh ${ref}" >&2
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
exit 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
ref="${1:?usage: $0 <ref> | --check}"
|
|
42
|
+
fetch "$ref" "$tmp"
|
|
43
|
+
rm -rf "$DEST"
|
|
44
|
+
mkdir -p "$DEST"
|
|
45
|
+
cp -R "$tmp/conformance/." "$DEST/"
|
|
46
|
+
echo "$ref" > "$DEST/VERSION"
|
|
47
|
+
echo "synced ${REPO}@${ref} → ${DEST}"
|
|
@@ -28,6 +28,7 @@ from .types import (
|
|
|
28
28
|
MultiheadConfig,
|
|
29
29
|
RiskLevel,
|
|
30
30
|
Tier1Result,
|
|
31
|
+
Tier3ChunkSummary,
|
|
31
32
|
Tier3Provider,
|
|
32
33
|
Tier3TokenUsage,
|
|
33
34
|
Tier3Verdict,
|
|
@@ -44,6 +45,7 @@ __all__ = [
|
|
|
44
45
|
"SfePredictor",
|
|
45
46
|
"SfePreprocessResult",
|
|
46
47
|
"Tier1Result",
|
|
48
|
+
"Tier3ChunkSummary",
|
|
47
49
|
"Tier3Provider",
|
|
48
50
|
"Tier3TokenUsage",
|
|
49
51
|
"Tier3Verdict",
|
|
@@ -75,7 +75,23 @@ class OnnxClassifier:
|
|
|
75
75
|
# function of the string alone, not its batch neighbours (deterministic).
|
|
76
76
|
_PAD_BUCKETS = (32, 64, 128, 256)
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
# Token-degeneracy (OOD) guard. Below this many content tokens, token-share
|
|
79
|
+
# is too coarse to mean anything (a 1-2 token row is trivially "dominated");
|
|
80
|
+
# matches the shortest decorative run that still false-fires post-collapse
|
|
81
|
+
# (``─``x3 -> 3 content tokens). Damp only when the row draws on at most
|
|
82
|
+
# _DEGENERACY_MAX_DISTINCT_TOKENS distinct tokens — padding an attack with
|
|
83
|
+
# copies of one token can push the share past 2/3 but cannot remove the
|
|
84
|
+
# attack's own vocabulary, so the distinct-token floor is a structural, not
|
|
85
|
+
# tuned, backstop against that bypass.
|
|
86
|
+
_DEGENERACY_MIN_CONTENT_TOKENS = 3
|
|
87
|
+
_DEGENERACY_MAX_DISTINCT_TOKENS = 4
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
model_path: str | None = None,
|
|
92
|
+
temperature_t: float | None = None,
|
|
93
|
+
degeneracy_max_token_share: float | None = None,
|
|
94
|
+
):
|
|
79
95
|
self._model_path = model_path or get_default_model_path()
|
|
80
96
|
self._session = None
|
|
81
97
|
self._tokenizer = None
|
|
@@ -83,6 +99,14 @@ class OnnxClassifier:
|
|
|
83
99
|
self._count_tokenizer = None
|
|
84
100
|
self._max_length = 256
|
|
85
101
|
self._load_failed = False
|
|
102
|
+
# Token-degeneracy guard threshold; > 1 disables. See _is_degenerate.
|
|
103
|
+
self._degeneracy_max_token_share = 2 / 3
|
|
104
|
+
if degeneracy_max_token_share is not None and math.isfinite(degeneracy_max_token_share):
|
|
105
|
+
self._degeneracy_max_token_share = float(degeneracy_max_token_share)
|
|
106
|
+
# Cached [UNK] id, resolved lazily from the loaded tokenizer. ``None`` =
|
|
107
|
+
# not yet resolved or no [UNK] concept; see _get_unk_token_id.
|
|
108
|
+
self._unk_token_id: int | None = None
|
|
109
|
+
self._unk_resolved = False
|
|
86
110
|
# Output mode is detected lazily from the logits shape on the first
|
|
87
111
|
# inference call. ``None`` until then.
|
|
88
112
|
self._output_mode: Literal["single", "multi"] | None = None
|
|
@@ -145,8 +169,10 @@ class OnnxClassifier:
|
|
|
145
169
|
import onnxruntime as ort
|
|
146
170
|
from tokenizers import Tokenizer
|
|
147
171
|
except ImportError as e:
|
|
172
|
+
# No warning here -- the ImportError propagates to the caller,
|
|
173
|
+
# which owns user-facing messaging (PromptDefense warns once per
|
|
174
|
+
# instance). Warning here logged a line on every failed call.
|
|
148
175
|
self._load_failed = True
|
|
149
|
-
_logger.warning("[defender] ONNX model failed to load: %s", e)
|
|
150
176
|
raise ImportError(
|
|
151
177
|
"ONNX dependencies not installed. Install with: pip install stackone-defender[onnx]"
|
|
152
178
|
) from e
|
|
@@ -198,6 +224,10 @@ class OnnxClassifier:
|
|
|
198
224
|
import numpy as np
|
|
199
225
|
|
|
200
226
|
encoding = self._tokenizer.encode(text)
|
|
227
|
+
# Fix 3: token-degeneracy guard — skip inference on off-distribution
|
|
228
|
+
# input; its mean-pooled score is arbitrary. Damp to a benign 0.
|
|
229
|
+
if self._is_degenerate(encoding.ids):
|
|
230
|
+
return 0.0, None
|
|
201
231
|
input_ids = np.array([encoding.ids], dtype=np.int64)
|
|
202
232
|
attention_mask = np.array([encoding.attention_mask], dtype=np.int64)
|
|
203
233
|
|
|
@@ -264,8 +294,61 @@ class OnnxClassifier:
|
|
|
264
294
|
for k, orig_idx in enumerate(idxs):
|
|
265
295
|
pairs[orig_idx] = chunk_pairs[k]
|
|
266
296
|
|
|
297
|
+
# Fix 3: token-degeneracy guard — damp off-distribution rows to a benign
|
|
298
|
+
# 0 so they drop out of any upstream max. Reuses the already-computed ids.
|
|
299
|
+
for i, enc in enumerate(encodings):
|
|
300
|
+
if self._is_degenerate(enc.ids):
|
|
301
|
+
pairs[i] = (0.0, None)
|
|
302
|
+
|
|
267
303
|
return cast(list[tuple[float, float | None]], pairs)
|
|
268
304
|
|
|
305
|
+
def _get_unk_token_id(self) -> int | None:
|
|
306
|
+
"""Resolve the tokenizer's ``[UNK]`` id, cached. Returns ``None`` when the
|
|
307
|
+
tokenizer has no ``[UNK]`` concept (the guard then skips its factor-3 check)."""
|
|
308
|
+
if self._unk_resolved:
|
|
309
|
+
return self._unk_token_id
|
|
310
|
+
self._unk_resolved = True
|
|
311
|
+
try:
|
|
312
|
+
self._unk_token_id = self._tokenizer.token_to_id("[UNK]")
|
|
313
|
+
except Exception:
|
|
314
|
+
self._unk_token_id = None
|
|
315
|
+
return self._unk_token_id
|
|
316
|
+
|
|
317
|
+
def _is_degenerate(self, ids: list[int]) -> bool:
|
|
318
|
+
"""Token-degeneracy (OOD) test over a tokenized row. Damps only when ALL
|
|
319
|
+
of these hold over the content tokens (excluding [CLS]/[SEP]):
|
|
320
|
+
|
|
321
|
+
1. the single most-frequent token covers >= ``degeneracy_max_token_share``,
|
|
322
|
+
2. the row draws on <= ``_DEGENERACY_MAX_DISTINCT_TOKENS`` distinct tokens, and
|
|
323
|
+
3. the dominant token is NOT [UNK].
|
|
324
|
+
|
|
325
|
+
Factor 2 blocks a padding attack; factor 3 blocks a homoglyph attack —
|
|
326
|
+
fullwidth / zero-width / other OOV chars collapse to repeated [UNK], the
|
|
327
|
+
signature of encoding evasion (more suspicious, not less), so those rows
|
|
328
|
+
are left to score rather than suppressed. Reuses the ids the model runs on.
|
|
329
|
+
"""
|
|
330
|
+
threshold = self._degeneracy_max_token_share
|
|
331
|
+
if not (0 < threshold <= 1): # disabled
|
|
332
|
+
return False
|
|
333
|
+
has_specials = len(ids) >= 2
|
|
334
|
+
content = ids[1:-1] if has_specials else ids
|
|
335
|
+
n = len(content)
|
|
336
|
+
if n < self._DEGENERACY_MIN_CONTENT_TOKENS:
|
|
337
|
+
return False
|
|
338
|
+
counts: dict[int, int] = {}
|
|
339
|
+
max_freq = 0
|
|
340
|
+
dominant_id = -1
|
|
341
|
+
for tok in content:
|
|
342
|
+
c = counts.get(tok, 0) + 1
|
|
343
|
+
counts[tok] = c
|
|
344
|
+
if c > max_freq:
|
|
345
|
+
max_freq = c
|
|
346
|
+
dominant_id = tok
|
|
347
|
+
if max_freq / n < threshold or len(counts) > self._DEGENERACY_MAX_DISTINCT_TOKENS:
|
|
348
|
+
return False
|
|
349
|
+
unk = self._get_unk_token_id()
|
|
350
|
+
return unk is None or dominant_id != unk
|
|
351
|
+
|
|
269
352
|
def _classify_batch_chunk_pair(
|
|
270
353
|
self, encodings: list, pad_to: int | None = None
|
|
271
354
|
) -> list[tuple[float, float | None]]:
|
{stackone_defender-0.7.4 → stackone_defender-0.9.0}/src/stackone_defender/classifiers/patterns.py
RENAMED
|
@@ -134,7 +134,7 @@ ENCODING_SUSPICIOUS_PATTERNS: list[PatternDefinition] = [
|
|
|
134
134
|
PatternDefinition("html_entity_abuse", re.compile(r"(?:&#\d{2,4};){4,}|(?:&#x[0-9a-fA-F]{2,4};){4,}", re.I), "encoding_suspicious", "medium", "HTML entity encoding (potential obfuscation)"),
|
|
135
135
|
PatternDefinition("rot13_mention", re.compile(r"rot13|caesar\s+cipher|decode\s+this", re.I), "encoding_suspicious", "medium", "Mention of ROT13 or similar encoding schemes"),
|
|
136
136
|
PatternDefinition("binary_string_encoding", re.compile(r"\b[01]{8}(?:\s+[01]{8}){2,}\b"), "encoding_suspicious", "medium", "Binary-encoded string (potential obfuscation)"),
|
|
137
|
-
PatternDefinition("morse_code_encoding", re.compile(r"(?:[.-]
|
|
137
|
+
PatternDefinition("morse_code_encoding", re.compile(r"(?:[.-]{1,8}\s){4,}[.-]{1,8}"), "encoding_suspicious", "low", "Morse code pattern (potential obfuscation)"),
|
|
138
138
|
PatternDefinition("leetspeak_injection", re.compile(r"1gn0r3|f0rg3t|byp4ss|syst3m|4dm1n|h4ck", re.I), "encoding_suspicious", "medium", "Leetspeak obfuscation of injection keywords"),
|
|
139
139
|
]
|
|
140
140
|
|
|
@@ -170,10 +170,14 @@ INDIRECT_INJECTION_PATTERNS: list[PatternDefinition] = [
|
|
|
170
170
|
# ``[config](https://.../system-setup)`` triggered. Real smuggled-
|
|
171
171
|
# instruction attacks include the full "ignore (all|the|previous|prior)"
|
|
172
172
|
# phrasing in the URL/anchor.
|
|
173
|
+
# Negated, bounded char classes ([^\]] / [^)]) instead of ``.*?`` so the
|
|
174
|
+
# regex is linear -- the lazy-dot form could backtrack on long unclosed
|
|
175
|
+
# link/URL spans (ReDoS-prone).
|
|
173
176
|
PatternDefinition(
|
|
174
177
|
"markdown_hidden_instruction",
|
|
175
178
|
re.compile(
|
|
176
|
-
r"\[
|
|
179
|
+
r"\[[^\]\n]{0,200}\]\([^)\n]{0,300}(?:ignore|disregard|forget|override)\W{1,8}"
|
|
180
|
+
r"(?:all|the|previous|prior)\W{1,8}[^)\n]{0,300}\)",
|
|
177
181
|
re.I,
|
|
178
182
|
),
|
|
179
183
|
"structural",
|