Changelog¶
All notable changes to wardcat will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]¶
[1.2.1] — 2026-09-23¶
Added¶
-
Strict mode.
with_strict()(YAMLstrict: true) turns a degraded scan into aDegradedScanErrorinstead of a result with warnings — at build time for a model that did not load, at scan time for a backend that went down, and fromscan_batch, which otherwise files the error underscan_error. The exception carries the warnings and the partial result. For pipelines where a document stored with names in it is worse than no document. -
classify(): the sensitivity judgement with its categories.Wardcat.classify(text)returns aSensitivityVerdict—sensitive, thecategoriesfound (pii,credentials,financial,health,special_category,business_confidential) and the model's one-linereason— so a policy can route on the kind. An answer that cannot be read is sensitive with the categoryunknown. It is its own prompt, not the source ofis_sensitive(): on the 100-text sensitivity benchmark with qwen3:14b it makes 1 false alarm againstis_sensitive()'s 11 and names the right category every time, but misses 8 sensitive texts against 1 (mostly confidential business plans) and takes 17 s a text against 2 s. Use it to route what the gate stops, not as the gate. Its prompt names placeholders, format examples, public service numbers and reference numbers as not sensitive; theis_sensitive()prompt is unchanged, since a change to the gate ships only with its own measurement. -
scan_batchruns batched layers in one pass. SpaCy NER sees the whole list throughnlp.pipe; the LLM layer runs per text in the worker pool, bounded at the backend bywith_llm(max_concurrency=4)— acrossscan_batchthreads and async chunk fan-out alike, where the async batch previously fanned out one request per item. Results are unchanged, per-item errors still land inscan_error, and strict mode still raises. A detector that batches implementsBaseDetector.detect_many. Measured on 200 samples of the Presidio corpus with regex +en_core_web_lg: 1.43× the throughput of a loop overscan(), identical output. -
An opt-in
HIGH_ENTROPY_STRINGdetector. A long base64-shaped run with the entropy of a generated key, or a hex digest longer than a git SHA, with no prefix and no keyword to go on. Off unless enabled and scored0.70, under the default floor, so it acts only with its ownmin_confidence. Measured with it on: no hit on the 100 hard cases, none on the 1,500-sample Presidio corpus, 117 on the 2,891 Gretel finance documents — 18 of them API keys Gretel labels as such, most of the rest unlabelled wallet addresses and UUIDs, a few EDI segments. Base64 with/is not matched, so URL paths are not candidates. -
A
surrogateaction. A realistic stand-in of the same shape instead of a placeholder: a name from the guard's locale (with_locale("tr"), YAMLlocale), an e-mail on a reserved domain, a phone number keeping its country and area code, a Luhn-valid card, a mod-97-valid IBAN, a checksum-valid TC number, a TEST-NET IP. Deterministic per salt, unique within a scan, and reversible throughrestore(). A type without a generator falls back totokenizeand the violation says so. Surrogates look real; the security guide says what that costs. -
Presets.
with_preset("kvkk")(also"gdpr","pci_dss","hipaa_lite","secrets_only"; YAMLpreset:) enables a starting policy modelled on a data-protection regime — an entity → action mapping that switches no layer on and claims no compliance. Each one documents what it leaves out.Wardcat.supported_presets()lists them. -
A
wardcatcommand.wardcat scan [file|-]with--preset,--entity TYPE[=ACTION],--config,--ner,--llm,--strictand--json;wardcat check-config policy.yamlruns every validation including the ReDoS screen;wardcat entitieslists the types. Exit codes say clean, found, config error or degraded. The salt is read from an environment variable named by--salt-env, never from an argument, and nothing found is ever printed. -
Every violation names the layer that found it.
Violation.source(andDetectedSpan.source) is"regex","ner","llm","denylist","propagation"or"custom", and is carried throughredacted(),reapply()and the restore report. ALayerenum is accepted wherever a layer name was (add_entity(..., layers=[Layer.LLM]),supported_entities(Layer.NER)). The benchmark now breaks TP/FP down by layer. -
Where a replacement sits in the output.
Violation.sanitized_startandsanitized_endlocate the replacement insanitized_text, for highlighting in a UI;start/endkeep pointing at the original. -
A confidence floor per entity.
add_entity(..., min_confidence=0.6), theadd_entitiesspec form, andentities.X.min_confidencein YAML let one weak-checksum type through while the rest keep the global floor.entity_policy(detailed=True)reports it. -
A circuit breaker on the LLM backend. After
circuit_failuresconsecutive failures (default 3) the layer is skipped without a call forcircuit_cooldownseconds (default 30), then tried once. A backend outage no longer costs every scan the full request timeout. Skipped scans carry a warning naming the open circuit;is_sensitive()raisesCircuitOpen.circuit_failures=0disables it. -
A labelled phone number is found in any national format, with no
phone_regions. The built-in pattern refuses a bare905-674-3793or0490 75 40 81, rightly: a digit run is an order number as often as a phone number. The label settles it —Phone:,Mobile:,Fax:,call me at …,telefon: …,Handy:, or781 1704 officein a signature block — so a number next to one is now reported, at0.90. The value must hold 7–15 digits and must not be a date; phrases that merely tend to precede a number ("messages to", "not answering at") are deliberately not cues. -
Troy cards. Turkey's domestic card scheme (
9792 …) was not in the card pattern, so a Luhn-valid Troy number went undetected. It is matched now, and still has to pass Luhn. -
A TC number written in groups,
111 654 670 34. Only the 3-3-3-2 grouping forms and printouts use, and the checksum still decides. -
An SSN without dashes, when it is labelled.
social security number 412 76 9038andSSN: 412769038are found at0.90. An unlabelled nine-digit run is still refused.
Changed¶
-
README. Rewritten to a one-screen introduction — install, a first scan that runs offline, why wardcat, links — that defers to docs.wardcat.com. Nothing was dropped: the entity tables moved to a new entity types page, the limitations table to known limitations, the repository layout and test commands to
CONTRIBUTING.md, and the remaining paragraphs into the guide pages they belong to. A test runs every README example and resolves every link, so the two cannot drift apart. -
Literal denylist values are matched in one pass. Ten thousand names cost one scan of the text rather than ten thousand; the spans found are the same as before (a test keeps the old search as the oracle). A value listed under two entity types keeps the first and logs a warning.
Security¶
-
No log line carries a value from the scanned text. The NER filters logged the span they dropped, the regex layer logged a match that failed its checksum, and the LLM layer logged the model's raw reply — all at
DEBUG, all containing the PII being scanned. Values are now logged as their entity type and length only. A test scans PII atDEBUGand asserts none of it reached the log, and a static check walks everyloggercall for a text-bearing argument. -
The ReDoS check on your own regex patterns now works.
custom_patternswere run against one pathological input in a thread with a 0.5 s timeout. Python'sreholds the GIL for a whole match and cannot be interrupted, so the timeout could not fire until the match was over: on(a+)+$the check hung configuration loading outright, and it passed(\d+)+$and(\w+\s?)+$because it only ever tried a run ofa.
Patterns are now screened when they are configured. The parse tree is searched
for a variable-width repeat nested in another repeat, or alternation inside a
repeat that can consume the same text; each suspect is then run against inputs
built from its shape in a child interpreter that the kernel kills after one
second. Most patterns have no suspect shape and never start a process. A
pattern shown to be catastrophic raises ConfigError — so a configuration that
loaded before, or hung, can now refuse to load, and only for that reason.
-
Denylist patterns get the same screening, from YAML and from
add_denylist(). YAML patterns were only compiled;add_denylist()checked nothing at all, so(a+)+$was accepted and a 28-character input then took 9 seconds per scan. Invalid regex added throughadd_denylist()is refused now too, where it used to be skipped on every scan without a word. Patterns are compiled once rather than on every scan. -
The scan-time timeout around custom patterns is removed. It bounded nothing, for the same reason, and discarded the matches of any pattern that ran past two seconds.
Fixed¶
- NER spans are cut at the edges where models run over. Of a span that
crosses a line break — into the next field in an address block
(
Anna Josefsen\nAddress), back over a heading (Renewals Team\nDalton Inc.) — the line with the most capitalised words is kept, a legal form winning for an organisation. A person's name that runs into markup or a record delimiter (`Carolyn Hill