Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Brazilian-Portuguese localization, selectable via a new `LlmGuard.Config`
`:languages` option (default `[:en]`, so existing behavior is unchanged):
- PT-BR prompt-injection and jailbreak patterns.
- Brazilian PII detection — CPF, CNPJ (check-digit validated), CEP, phone.
- `LlmGuard.Locale` behaviour + `LlmGuard.Locales` registry, so adding a
language is one module plus one registry entry, with no detector changes.

### Fixed
- PII detection and redaction now keep byte-aligned offsets on UTF-8 input, so
accented text no longer shifts or drops matches.

## [0.3.1] - 2025-12-28

### Changed
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ User Input
- Credit card numbers (98% with Luhn validation)
- IP addresses (85-90% confidence)
- URLs (90% confidence)
- Brazilian (via `languages: [:pt_br]`): CPF, CNPJ (check-digit validated), CEP, phone

### Languages

Detection is English by default. Set `languages: [:en, :pt_br]` (default `[:en]`)
to also match Brazilian-Portuguese injection/jailbreak phrasings and Brazilian
PII. The English base always runs; locale packs add patterns on top of it.

### Coming Soon
- Harmful content (violence, hate speech, etc.)
Expand Down Expand Up @@ -171,6 +178,9 @@ config = LlmGuard.Config.new(
data_leakage_prevention: false, # Coming soon
content_moderation: false, # Coming soon

# Languages whose detection patterns and PII are active (default: [:en])
languages: [:en, :pt_br],

# Thresholds
confidence_threshold: 0.7,
max_input_length: 10_000,
Expand Down
6 changes: 4 additions & 2 deletions lib/llm_guard.ex
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ defmodule LlmGuard do
pipeline_config = %{
early_termination: true,
confidence_threshold: config.confidence_threshold,
caching: Config.caching_config(config)
caching: Config.caching_config(config),
languages: config.languages
}

case Pipeline.run(sanitized, detectors, pipeline_config) do
Expand Down Expand Up @@ -201,7 +202,8 @@ defmodule LlmGuard do
pipeline_config = %{
early_termination: true,
confidence_threshold: config.confidence_threshold,
caching: Config.caching_config(config)
caching: Config.caching_config(config),
languages: config.languages
}

case Pipeline.run(output, detectors, pipeline_config) do
Expand Down
16 changes: 16 additions & 0 deletions lib/llm_guard/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ defmodule LlmGuard.Config do
- `:max_input_length` - Maximum input length in characters (default: `10_000`)
- `:max_output_length` - Maximum output length in characters (default: `10_000`)

### Languages
- `:languages` - Locales whose detection patterns and PII scanners are active
(default: `[:en]`). Supported: `:en`, `:pt_br`. Default behavior is unchanged.

### Custom Detectors
- `:enabled_detectors` - List of custom detector modules to enable (default: `[]`)

Expand Down Expand Up @@ -77,6 +81,7 @@ defmodule LlmGuard.Config do
max_input_length: pos_integer(),
max_output_length: pos_integer(),
enabled_detectors: [atom()],
languages: [atom()],
rate_limiting: map() | nil,
audit_logging: map() | nil,
caching: map() | nil,
Expand All @@ -92,6 +97,7 @@ defmodule LlmGuard.Config do
max_input_length: 10_000,
max_output_length: 10_000,
enabled_detectors: [],
languages: [:en],
rate_limiting: nil,
audit_logging: nil,
caching: nil,
Expand Down Expand Up @@ -336,6 +342,7 @@ defmodule LlmGuard.Config do
validate_max_length!(:max_input_length, config.max_input_length)
validate_max_length!(:max_output_length, config.max_output_length)
validate_enabled_detectors!(config.enabled_detectors)
validate_languages!(config.languages)
:ok
end

Expand All @@ -362,6 +369,15 @@ defmodule LlmGuard.Config do
end
end

defp validate_languages!(languages) do
supported = LlmGuard.Locales.supported()

unless is_list(languages) and Enum.all?(languages, &(&1 in supported)) do
raise ArgumentError,
"languages must be a list of #{inspect(supported)}, got: #{inspect(languages)}"
end
end

defp maybe_add(list, item, true), do: [item | list]
defp maybe_add(list, _item, false), do: list

Expand Down
3 changes: 2 additions & 1 deletion lib/llm_guard/detectors/data_leakage.ex
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ defmodule LlmGuard.Detectors.DataLeakage do
else
confidence_threshold = Keyword.get(opts, :confidence_threshold, 0.7)
pii_types = Keyword.get(opts, :pii_types, nil)
languages = Keyword.get(opts, :languages, [:en])

# Scan for PII
entities =
Expand All @@ -65,7 +66,7 @@ defmodule LlmGuard.Detectors.DataLeakage do
PIIScanner.scan_by_type(input, type)
end)
else
PIIScanner.scan(input)
PIIScanner.scan(input, languages)
end

if Enum.empty?(entities) do
Expand Down
23 changes: 10 additions & 13 deletions lib/llm_guard/detectors/data_leakage/pii_redactor.ex
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIRedactor do
# Apply redaction to each entity
Enum.reduce(sorted_entities, text, fn entity, acc_text ->
redacted_value = apply_strategy(entity, strategy, opts)

# Replace the PII in text
before = String.slice(acc_text, 0, entity.start_pos)
after_text = String.slice(acc_text, entity.end_pos..-1//1)

before <> redacted_value <> after_text
replace_at(acc_text, entity, redacted_value)
end)
end

Expand Down Expand Up @@ -170,19 +165,21 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIRedactor do
Enum.reduce(sorted_entities, {text, mapping}, fn entity, {acc_text, acc_mapping} ->
redacted_value = apply_strategy(entity, strategy, opts)

# Add to mapping
new_mapping = Map.put(acc_mapping, entity.value, redacted_value)

# Replace in text
before = String.slice(acc_text, 0, entity.start_pos)
after_text = String.slice(acc_text, entity.end_pos..-1//1)

{before <> redacted_value <> after_text, new_mapping}
{replace_at(acc_text, entity, redacted_value), new_mapping}
end)

{redacted_text, final_mapping}
end

# Entity offsets are byte-based (from Regex `:index`), so splice on bytes to stay
# aligned when multibyte characters precede the match. Entities are applied
# right-to-left, keeping earlier offsets valid as the text is rewritten.
defp replace_at(text, %{start_pos: start_pos, end_pos: end_pos}, replacement) do
binary_part(text, 0, start_pos) <>
replacement <> binary_part(text, end_pos, byte_size(text) - end_pos)
end

# Private strategy application functions

defp apply_strategy(entity, :mask, opts) do
Expand Down
91 changes: 73 additions & 18 deletions lib/llm_guard/detectors/data_leakage/pii_scanner.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do
:email
"""

alias LlmGuard.Locales

@type pii_type ::
:email
| :phone
| :ssn
| :credit_card
| :ip_address
| :url
| :cpf
| :cnpj
| :cep
| :br_phone

@type pii_entity :: %{
type: pii_type(),
Expand Down Expand Up @@ -98,15 +104,29 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do
[%{type: :email, value: "test@example.com", confidence: 0.95, ...}]
"""
@spec scan(String.t()) :: [pii_entity()]
def scan(text) when is_binary(text) do
[]
|> scan_emails(text)
|> scan_phones(text)
|> scan_ssn(text)
|> scan_credit_cards(text)
|> scan_ip_addresses(text)
|> scan_urls(text)
|> Enum.sort_by(& &1.start_pos)
@spec scan(String.t(), [atom()]) :: [pii_entity()]
def scan(text, languages \\ [:en]) when is_binary(text) do
base =
[]
|> scan_emails(text)
|> scan_phones(text)
|> scan_ssn(text)
|> scan_credit_cards(text)
|> scan_ip_addresses(text)
|> scan_urls(text)

entities =
case Locales.pii_specs(languages) do
[] ->
base

specs ->
# Drop base matches (e.g. US-phone regex) that overlap a confirmed region entity.
region = deduplicate_overlapping(Enum.flat_map(specs, &scan_spec(&1, text)))
Enum.reject(base, fn e -> Enum.any?(region, &overlapping?(e, &1)) end) ++ region
end

Enum.sort_by(entities, & &1.start_pos)
end

@doc """
Expand All @@ -128,7 +148,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do
:credit_card -> scan_credit_cards([], text)
:ip_address -> scan_ip_addresses([], text)
:url -> scan_urls([], text)
_ -> []
other -> scan_region_type(text, other)
end
end

Expand Down Expand Up @@ -157,7 +177,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

new_entities =
Enum.map(matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)

%{
type: :email,
Expand All @@ -179,7 +199,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do
Enum.map(matches, fn match_list ->
# Take the first (full) match, ignore capture groups
{start, length} = hd(match_list)
value = String.slice(text, start, length)
value = binary_part(text, start, length)

%{
type: :phone,
Expand All @@ -201,7 +221,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

formatted_entities =
Enum.map(formatted_matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)

# Check if it's obviously invalid (000-00-0000, etc.)
if obviously_invalid_ssn?(value) do
Expand All @@ -225,7 +245,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

unformatted_entities =
Enum.map(unformatted_matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)
context = get_context(text, start, 20)

if valid_ssn_format?(value) and ssn_context?(context) do
Expand All @@ -248,7 +268,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

new_entities =
Enum.map(matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)
normalized = String.replace(value, ~r/[-\s]/, "")

if valid_credit_card?(normalized) do
Expand Down Expand Up @@ -281,7 +301,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

ipv4_entities =
Enum.map(ipv4_matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)

if valid_ipv4?(value) do
%{
Expand All @@ -300,7 +320,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

ipv6_entities =
Enum.map(ipv6_matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)

%{
type: :ip_address,
Expand All @@ -319,7 +339,7 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do

new_entities =
Enum.map(matches, fn [{start, length}] ->
value = String.slice(text, start, length)
value = binary_part(text, start, length)

%{
type: :url,
Expand All @@ -333,6 +353,41 @@ defmodule LlmGuard.Detectors.DataLeakage.PIIScanner do
entities ++ new_entities
end

# Region PII scanning, driven by the locale packs' specs. A candidate becomes an entity
# only when its spec's validator confirms it, so bare digit runs are not reported as PII.

defp scan_region_type(text, type) do
case Locales.pii_spec(type) do
nil -> []
spec -> scan_spec(spec, text)
end
end

defp scan_spec(%{type: type, regex: regex, validate: validate, confidence: confidence}, text) do
regex
|> Regex.scan(text, return: :index)
|> Enum.map(fn [{start, length} | _] ->
# Regex index offsets are byte-based, so slice on bytes. String.slice would
# misalign when multibyte (accented) characters precede the match.
value = binary_part(text, start, length)

if validate.(value) do
%{
type: type,
value: value,
confidence: confidence,
start_pos: start,
end_pos: start + length
}
end
end)
|> Enum.reject(&is_nil/1)
end

defp overlapping?(a, b) do
ranges_overlap?({a.start_pos, a.end_pos}, {b.start_pos, b.end_pos})
end

# Validation helpers

defp calculate_phone_confidence(phone) do
Expand Down
16 changes: 13 additions & 3 deletions lib/llm_guard/detectors/jailbreak.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ defmodule LlmGuard.Detectors.Jailbreak do

@behaviour LlmGuard.Detector

alias LlmGuard.Locales

# Known jailbreak personas
@jailbreak_personas [
"DAN",
Expand Down Expand Up @@ -138,6 +140,7 @@ defmodule LlmGuard.Detectors.Jailbreak do
@impl true
def detect(input, opts \\ []) do
threshold = Keyword.get(opts, :confidence_threshold, 0.7)
languages = Keyword.get(opts, :languages, [:en])

# Handle empty input
if input == "" do
Expand All @@ -147,7 +150,7 @@ defmodule LlmGuard.Detectors.Jailbreak do
normalized = String.downcase(input)

# Layer 1: Pattern matching
pattern_matches = detect_patterns(input, normalized)
pattern_matches = detect_patterns(input, normalized, languages)

# Layer 2: Encoding detection
encoding_matches = detect_encodings(input, normalized)
Expand Down Expand Up @@ -191,8 +194,15 @@ defmodule LlmGuard.Detectors.Jailbreak do

# Private helper functions

defp detect_patterns(input, normalized) do
Enum.flat_map(patterns(), fn {category, pattern_list} ->
# Base categories plus enabled locale packs, merged per category.
defp pattern_set(languages) do
Map.merge(patterns(), Locales.jailbreak_patterns(languages), fn _category, base, extra ->
base ++ extra
end)
end

defp detect_patterns(input, normalized, languages) do
Enum.flat_map(pattern_set(languages), fn {category, pattern_list} ->
Enum.flat_map(pattern_list, fn pattern ->
if Regex.match?(pattern, input) or Regex.match?(pattern, normalized) do
[{:pattern, category, 1.0}]
Expand Down
Loading