Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a7c93f7
feat: add Blaspable Eloquent model trait for automatic profanity dete…
deemonic Feb 12, 2026
78b4d1e
refactor: remove legacy v3 source code
deemonic Feb 12, 2026
882f22c
feat: add v4 core engine with driver-based architecture
deemonic Feb 12, 2026
0242d3d
feat: add v4 Laravel integration layer
deemonic Feb 12, 2026
5b64f05
chore: update config and autoload for v4 namespace
deemonic Feb 12, 2026
fc5daa1
test: update test suite for v4 API
deemonic Feb 12, 2026
0b56eae
docs: rewrite README for v4
deemonic Feb 12, 2026
cf7ecbe
feat: add middleware alias, Blade directive, and Str/Stringable macros
deemonic Feb 12, 2026
853c6d5
refactor: flatten Laravel/ namespace into root package namespace
deemonic Feb 13, 2026
a0e0b2f
feat: add phonetic detection driver using metaphone + Levenshtein
deemonic Feb 13, 2026
62d83b9
docs: document phonetic driver in README
deemonic Feb 13, 2026
0b19ab4
feat: add pipeline driver to chain multiple detection drivers
deemonic Feb 13, 2026
9639506
feat: add non-English severity maps and result caching
deemonic Feb 13, 2026
1a2dd69
fix: add high severity tiers and sync severity maps with profanities …
deemonic Feb 13, 2026
b88f128
fix: address pre-existing code review issues
deemonic Feb 13, 2026
3d092a3
fix: address follow-up review comments
deemonic Feb 13, 2026
477f398
fix: use mb_strtolower for false positives and stabilize dedup sort i…
deemonic Feb 14, 2026
412806d
fix: replace str_word_count with multibyte-safe word counting
deemonic Feb 14, 2026
cc92dad
fix: add UTF-8 guard to PatternDriver and use original text in Phonet…
deemonic Feb 14, 2026
c31117c
fix: cap regex separator repetition to prevent PCRE JIT stack overflow
deemonic Feb 15, 2026
bab9047
fix: eliminate nested quantifiers in separator regex to prevent JIT s…
deemonic Feb 17, 2026
a67ccd8
docs: update README with --detail flag and separator limit note
deemonic Feb 18, 2026
4e25fdf
merge: resolve composer.json conflict with main (Laravel 13 compat)
deemonic Mar 26, 2026
2728501
fix: address CodeRabbit review findings on PR #48
deemonic Mar 26, 2026
a03f977
fix: address remaining CodeRabbit review findings on PR #48
deemonic Mar 26, 2026
d94740b
fix: harden pipeline self-reference guard with case normalization and…
deemonic Mar 26, 2026
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
5 changes: 1 addition & 4 deletions config/languages/french.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
'severity' => [
'mild' => [
'crotte', 'crottes', 'caca', 'cacas', 'zut',
'mince', 'flûte', 'flute', 'punaise',
'punaise',
'idiot', 'idiots', 'idiote', 'idiotes',
'bête', 'bete', 'bêtes', 'betes',
'sot', 'sots', 'sotte', 'sottes',
Expand Down Expand Up @@ -1537,9 +1537,6 @@
'réfrigérations',
'refrigerations',
'zut',
'mince',
'flûte',
'flute',
'punaise',
],

Expand Down
2 changes: 1 addition & 1 deletion config/languages/german.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
'bekloppt', 'bekloppte', 'bekloppter', 'beklopptes',
'schwanz', 'pimmel',
'hintern', 'po', 'popo',
'schwul', 'schwuler', 'schwule', 'schwules',
],
'high' => [
'scheiße', 'scheisse', 'ficken', 'fick', 'gefickt',
Expand All @@ -27,7 +28,6 @@
'vögeln', 'voegeln', 'bumsen',
],
'extreme' => [
'schwul', 'schwuler', 'schwule', 'schwules',
'tunte', 'tuntig',
'kampflesbe', 'kampflesben',
'kanake', 'kanaken',
Expand Down
14 changes: 14 additions & 0 deletions src/BlaspManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,20 @@ public function createPipelineDriver(): DriverInterface
$config = $this->app['config']->get('blasp.drivers.pipeline', []);
$driverNames = $config['drivers'] ?? ['regex', 'phonetic'];

if (!is_array($driverNames)) {
throw new InvalidArgumentException('blasp.drivers.pipeline.drivers must be an array of driver names.');
}

foreach ($driverNames as $name) {
if (!is_string($name) || trim($name) === '') {
throw new InvalidArgumentException('Each pipeline driver name must be a non-empty string.');
}

if (strtolower(trim($name)) === 'pipeline') {
throw new InvalidArgumentException('Pipeline driver cannot contain itself. Remove "pipeline" from blasp.drivers.pipeline.drivers.');
}
}

$resolvedDrivers = array_map(
fn (string $name) => $this->resolveDriver($name),
$driverNames,
Expand Down
6 changes: 4 additions & 2 deletions src/BlaspServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Blaspsoft\Blasp\Core\Dictionary;

class BlaspServiceProvider extends ServiceProvider
{
public function boot(): void
Expand Down Expand Up @@ -53,6 +51,10 @@ public function register(): void
protected function registerValidationRule(): void
{
$this->app['validator']->extend('blasp_check', function ($attribute, $value, $parameters) {
if (!is_string($value) || $value === '') {
return true;
}

$language = $parameters[0] ?? config('blasp.language', config('blasp.default_language', 'english'));

$manager = $this->app->make('blasp');
Expand Down
3 changes: 2 additions & 1 deletion src/Blaspable.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,13 @@ public function blaspResult(string $attribute): ?Result

public static function withoutBlaspChecking(Closure $callback): mixed
{
$previousState = static::$blaspCheckingDisabled;
static::$blaspCheckingDisabled = true;

try {
return $callback();
} finally {
static::$blaspCheckingDisabled = false;
static::$blaspCheckingDisabled = $previousState;
}
}
}
6 changes: 3 additions & 3 deletions src/Core/Matchers/PhoneticMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ public function __construct(
private float $maxDistanceRatio = 0.6,
private array $phoneticFalsePositives = [],
) {
$this->phoneticFalsePositives = array_map('strtolower', $this->phoneticFalsePositives);
$this->phoneticFalsePositives = array_map(fn($fp) => mb_strtolower($fp, 'UTF-8'), $this->phoneticFalsePositives);
$this->buildIndex($profanities);
}

private function buildIndex(array $profanities): void
{
foreach ($profanities as $word) {
$lower = strtolower($word);
$lower = mb_strtolower($word, 'UTF-8');
if (mb_strlen($lower, 'UTF-8') < $this->minWordLength) {
continue;
}
Expand Down Expand Up @@ -62,7 +62,7 @@ public function match(string $word): ?string

foreach ($this->index[$code] as $profanity) {
$distance = levenshtein($lower, $profanity);
$maxLen = max(strlen($lower), strlen($profanity));
$maxLen = max(mb_strlen($lower, 'UTF-8'), mb_strlen($profanity, 'UTF-8'));
$threshold = (int) ceil($this->maxDistanceRatio * $maxLen);

if ($distance <= $threshold && $distance < $bestDistance) {
Expand Down
18 changes: 9 additions & 9 deletions src/Drivers/PatternDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
}
}

// Apply severity filter before dedup so shorter high-severity matches aren't swallowed
$minimumSeverity = $options['severity'] ?? null;
if ($minimumSeverity instanceof Severity) {
$matchedWords = array_values(array_filter(
$matchedWords,
fn(MatchedWord $w) => $w->severity->isAtLeast($minimumSeverity)
));
}

// Deduplicate overlapping matches (longest-first already recorded)
usort($matchedWords, fn($a, $b) => $a->position - $b->position ?: $b->length - $a->length);
$deduplicated = [];
Expand All @@ -69,15 +78,6 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
}
$matchedWords = $deduplicated;

// Apply severity filter
$minimumSeverity = $options['severity'] ?? null;
if ($minimumSeverity instanceof Severity) {
$matchedWords = array_values(array_filter(
$matchedWords,
fn(MatchedWord $w) => $w->severity->isAtLeast($minimumSeverity)
));
}

// Rebuild cleanText from surviving matches (right-to-left)
$cleanText = $text;
$sorted = $matchedWords;
Expand Down
2 changes: 1 addition & 1 deletion src/Drivers/PipelineDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
$reversed = array_reverse($kept);
foreach ($reversed as $match) {
$replacement = $mask->mask($match->text, $match->length);
$cleanText = mb_substr($cleanText, 0, $match->position) . $replacement . mb_substr($cleanText, $match->position + $match->length);
$cleanText = mb_substr($cleanText, 0, $match->position, 'UTF-8') . $replacement . mb_substr($cleanText, $match->position + $match->length, null, 'UTF-8');
}

// 5. Recalculate score from merged matches
Expand Down
37 changes: 31 additions & 6 deletions src/Drivers/RegexDriver.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,17 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
$normalizedString = $normalizer->normalize($text);
$originalNormalized = preg_replace('/\s+/', ' ', $normalizedString);

// Immutable copy for position lookups — never mutated
$immutableNormalized = $originalNormalized;

$matchedWords = [];
$uniqueMap = [];
$profanitiesCount = 0;
$continue = true;

// Track masked character ranges so we don't re-match them
$maskedRanges = [];

while ($continue) {
$continue = false;
$normalizedString = preg_replace('/\s+/', ' ', $normalizedString);
Expand All @@ -59,6 +65,19 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
$length = mb_strlen($match[0], 'UTF-8');
$matchedText = $match[0];

// Skip if this range overlaps with an already-masked range
$matchEnd = $start + $length;
$alreadyMasked = false;
foreach ($maskedRanges as [$mStart, $mEnd]) {
if ($start < $mEnd && $matchEnd > $mStart) {
$alreadyMasked = true;
break;
}
}
if ($alreadyMasked) {
continue;
}

// Check word boundary spanning (filter uses byte-level operations)
if ($this->filter->isSpanningWordBoundary($matchedText, $normalizedString, $byteStart)) {
continue;
Expand All @@ -73,7 +92,7 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
$fullWord = $this->filter->getFullWordContext($normalizedString, $byteStart, $byteLength);

// Check pure alpha substring against original (unmasked) normalized
$originalFullWord = $this->filter->getFullWordContext($originalNormalized, $byteStart, $byteLength);
$originalFullWord = $this->filter->getFullWordContext($immutableNormalized, $byteStart, $byteLength);
if ($this->compoundDetector->isPureAlphaSubstring($matchedText, $originalFullWord, $profanity, $profanityExpressions)) {
continue;
}
Expand All @@ -86,14 +105,20 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
$continue = true;

// Mask in normalizedString only (needed for loop termination)
$normalizedString = mb_substr($normalizedString, 0, $start) . str_repeat('*', mb_strlen($match[0], 'UTF-8')) .
mb_substr($normalizedString, $start + mb_strlen($match[0], 'UTF-8'));
$normalizedString = mb_substr($normalizedString, 0, $start) . str_repeat('*', $length) .
mb_substr($normalizedString, $start + $length);

// Track match
// Record masked range using character positions from immutable string
$maskedRanges[] = [$start, $matchEnd];

// Track match — use position derived from immutable normalized string
$profanitiesCount++;

// Get the original text at this position from the original input
$originalMatchText = mb_substr($text, $start, $length);

$matchedWords[] = new MatchedWord(
text: $matchedText,
text: $originalMatchText,
base: $profanity,
severity: $dictionary->getSeverity($profanity),
position: $start,
Expand All @@ -109,7 +134,7 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
}
}

// Apply severity filter if set
// Apply severity filter before masking so low-severity matches don't suppress overlapping ones
$minimumSeverity = $options['severity'] ?? null;
if ($minimumSeverity instanceof Severity) {
$matchedWords = array_values(array_filter(
Expand Down
6 changes: 3 additions & 3 deletions src/Middleware/CheckProfanity.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ public function handle(Request $request, Closure $next, ?string $action = null,
$fields = config('blasp.middleware.fields', ['*']);
$except = config('blasp.middleware.except', ['password', 'email', '_token']);

$input = $request->except($except);

if ($fields !== ['*']) {
$input = $request->only($fields);
$input = collect($request->only($fields))->except($except)->all();
} else {
$input = $request->except($except);
}

$textFields = $this->extractTextFields($input);
Expand Down
10 changes: 9 additions & 1 deletion src/PendingCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,14 @@ protected function trackCacheKey(string $key): void
$cache = $this->getCache();
$keys = $cache->get('blasp_result_cache_keys', []);
$keys[] = $key;
$cache->forever('blasp_result_cache_keys', array_unique($keys));
$keys = array_unique($keys);

// Evict oldest keys when exceeding the configured limit
$maxKeys = config('blasp.cache.max_tracked_keys', 1000);
if (count($keys) > $maxKeys) {
$keys = array_slice($keys, -$maxKeys);
}
Comment on lines +321 to +327
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refresh key recency before dedupe/trim to avoid stale cache survivors.

array_unique() keeps the first occurrence, so repeated keys are not moved to the “recent” end. With FIFO-style trimming, actively reused keys can be evicted from blasp_result_cache_keys, and then Dictionary::clearCache() may miss clearing them.

🔧 Proposed fix
-        $keys[] = $key;
-        $keys = array_unique($keys);
+        // Move key to most-recent position while preserving uniqueness
+        $keys = array_values(array_filter($keys, static fn ($k) => $k !== $key));
+        $keys[] = $key;

         // Evict oldest keys when exceeding the configured limit
-        $maxKeys = config('blasp.cache.max_tracked_keys', 1000);
+        $maxKeys = max(1, (int) config('blasp.cache.max_tracked_keys', 1000));
         if (count($keys) > $maxKeys) {
             $keys = array_slice($keys, -$maxKeys);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/PendingCheck.php` around lines 321 - 327, The current use of
array_unique($keys) keeps the first occurrence so frequently reused keys stay at
the front and may be FIFO-evicted; to refresh recency before dedupe/trim, dedupe
while preserving the last occurrence instead of the first: transform $keys so
duplicates are removed but the most recent appearance is kept (e.g. reverse
$keys, call array_unique, then reverse back or use a keyed map to keep last),
then apply the existing max-tracked-keys trimming using the same $maxKeys from
config('blasp.cache.max_tracked_keys', 1000); update the code around $keys,
array_unique and array_slice in PendingCheck (the block manipulating $keys) to
perform this reverse-dedupe-restore sequence so hot keys are considered recent
and not evicted.


$cache->forever('blasp_result_cache_keys', $keys);
}
}
2 changes: 1 addition & 1 deletion tests/StrMacroTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,6 @@ public function test_stringable_clean_profanity_returns_stringable_instance()

public function test_stringable_clean_profanity_returns_clean_text_unchanged()
{
$this->assertSame('hello', Str::of('hello')->cleanProfanity()->toString());
$this->assertSame('hello', (string) Str::of('hello')->cleanProfanity());
}
}
Loading