Skip to content

Commit b88f128

Browse files
deemonicclaude
andcommitted
fix: address pre-existing code review issues
- Dictionary: sanitize language parameter to prevent path traversal via loadLanguageConfig(), forLanguage(), and forLanguages() - TestCommand: rename --verbose to --detail to avoid conflict with Symfony Console's built-in -v|--verbose flag - PatternDriver, PhoneticDriver, RegexDriver: convert PREG_OFFSET_CAPTURE byte offsets to character offsets for correct multibyte string handling - PatternDriver, PhoneticDriver, RegexDriver: apply severity filter before masking so low-severity words aren't masked in cleanText when filtered out - Blasp facade: throw RuntimeException in assertChecked() and assertCheckedTimes() when fake() hasn't been called, instead of silently passing - Profanity rule: convert static factory methods to instance methods with __callStatic for backward compat, enabling chaining like Profanity::in('spanish')->severity(Severity::High) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1a2dd69 commit b88f128

File tree

7 files changed

+84
-38
lines changed

7 files changed

+84
-38
lines changed

src/Console/TestCommand.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
class TestCommand extends Command
88
{
9-
protected $signature = 'blasp:test {text} {--lang= : Language to check against} {--verbose}';
9+
protected $signature = 'blasp:test {text} {--lang= : Language to check against} {--detail}';
1010
protected $description = 'Test profanity detection on a given text';
1111

1212
public function handle(): void
@@ -34,7 +34,7 @@ public function handle(): void
3434
]
3535
);
3636

37-
if ($this->option('verbose')) {
37+
if ($this->option('detail')) {
3838
$this->newLine();
3939
$this->info('Matched words:');
4040
$rows = [];

src/Core/Dictionary.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ public function __construct(
7979

8080
public static function forLanguage(string $language, array $options = []): self
8181
{
82+
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $language)) {
83+
return new self(
84+
profanities: [],
85+
falsePositives: [],
86+
separators: [],
87+
substitutions: [],
88+
severityMap: [],
89+
normalizer: new EnglishNormalizer(),
90+
language: $language,
91+
);
92+
}
93+
8294
$config = self::loadLanguageConfig($language);
8395
$globalConfig = self::loadGlobalConfig();
8496

@@ -120,6 +132,9 @@ public static function forLanguages(array $languages, array $options = []): self
120132
$substitutions = $globalConfig['substitutions'] ?? [];
121133

122134
foreach ($languages as $language) {
135+
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $language)) {
136+
continue;
137+
}
123138
$config = self::loadLanguageConfig($language);
124139
$allProfanities = array_merge($allProfanities, $config['profanities'] ?? []);
125140
$allFalsePositives = array_merge($allFalsePositives, $config['false_positives'] ?? []);
@@ -236,6 +251,10 @@ public static function getAvailableLanguages(): array
236251

237252
public static function loadLanguageConfig(string $language): array
238253
{
254+
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $language)) {
255+
return ['profanities' => [], 'false_positives' => []];
256+
}
257+
239258
$possiblePaths = [
240259
config_path("languages/{$language}.php"),
241260
__DIR__ . "/../../config/languages/{$language}.php",

src/Drivers/PatternDriver.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
1919
}
2020

2121
$matchedWords = [];
22-
$cleanText = $text;
2322
$lowerText = mb_strtolower($text, 'UTF-8');
2423
$profanities = $dictionary->getProfanities();
2524
$falsePositives = array_map('strtolower', $dictionary->getFalsePositives());
@@ -33,7 +32,7 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
3332

3433
if (preg_match_all($pattern, $lowerText, $matches, PREG_OFFSET_CAPTURE)) {
3534
foreach ($matches[0] as $match) {
36-
$start = $match[1];
35+
$start = mb_strlen(substr($lowerText, 0, $match[1]), 'UTF-8');
3736
$length = mb_strlen($match[0], 'UTF-8');
3837
$originalMatch = mb_substr($text, $start, $length);
3938

@@ -42,9 +41,6 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
4241
continue;
4342
}
4443

45-
$replacement = $mask->mask($originalMatch, $length);
46-
$cleanText = mb_substr($cleanText, 0, $start) . $replacement . mb_substr($cleanText, $start + $length);
47-
4844
$matchedWords[] = new MatchedWord(
4945
text: $originalMatch,
5046
base: $profanity,
@@ -66,6 +62,17 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
6662
));
6763
}
6864

65+
// Rebuild cleanText from surviving matches (right-to-left)
66+
$cleanText = $text;
67+
$sorted = $matchedWords;
68+
usort($sorted, fn($a, $b) => $b->position - $a->position);
69+
foreach ($sorted as $word) {
70+
$replacement = $mask->mask($word->text, $word->length);
71+
$cleanText = mb_substr($cleanText, 0, $word->position)
72+
. $replacement
73+
. mb_substr($cleanText, $word->position + $word->length);
74+
}
75+
6976
$totalWords = max(1, str_word_count($text));
7077
$scoreValue = Score::calculate($matchedWords, $totalWords);
7178

src/Drivers/PhoneticDriver.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,10 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
6767
$tokens = $matches[0] ?? [];
6868

6969
$matchedWords = [];
70-
$cleanText = $text;
7170

7271
foreach ($tokens as $token) {
7372
$word = $token[0];
74-
$start = $token[1];
73+
$start = mb_strlen(substr($normalized, 0, $token[1]), 'UTF-8');
7574
$length = mb_strlen($word, 'UTF-8');
7675

7776
// Skip dictionary false positives
@@ -89,9 +88,6 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
8988
continue;
9089
}
9190

92-
$replacement = $mask->mask($word, $length);
93-
$cleanText = mb_substr($cleanText, 0, $start) . $replacement . mb_substr($cleanText, $start + $length);
94-
9591
$matchedWords[] = new MatchedWord(
9692
text: $word,
9793
base: $baseWord,
@@ -111,6 +107,17 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
111107
));
112108
}
113109

110+
// Rebuild cleanText from surviving matches (right-to-left)
111+
$cleanText = $text;
112+
$sorted = $matchedWords;
113+
usort($sorted, fn($a, $b) => $b->position - $a->position);
114+
foreach ($sorted as $word) {
115+
$replacement = $mask->mask($word->text, $word->length);
116+
$cleanText = mb_substr($cleanText, 0, $word->position)
117+
. $replacement
118+
. mb_substr($cleanText, $word->position + $word->length);
119+
}
120+
114121
$totalWords = max(1, str_word_count($text));
115122
$scoreValue = Score::calculate($matchedWords, $totalWords);
116123

src/Drivers/RegexDriver.php

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,7 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
3636
uksort($profanityExpressions, fn($a, $b) => strlen($b) - strlen($a));
3737

3838
$normalizer = $dictionary->getNormalizer();
39-
$workingCleanString = $text;
40-
$normalizedString = $normalizer->normalize($workingCleanString);
39+
$normalizedString = $normalizer->normalize($text);
4140
$originalNormalized = preg_replace('/\s+/', ' ', $normalizedString);
4241

4342
$matchedWords = [];
@@ -48,14 +47,13 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
4847
while ($continue) {
4948
$continue = false;
5049
$normalizedString = preg_replace('/\s+/', ' ', $normalizedString);
51-
$workingCleanString = preg_replace('/\s+/', ' ', $workingCleanString);
5250

5351
foreach ($profanityExpressions as $profanity => $expression) {
5452
preg_match_all($expression, $normalizedString, $matches, PREG_OFFSET_CAPTURE);
5553

5654
if (!empty($matches[0])) {
5755
foreach ($matches[0] as $match) {
58-
$start = $match[1];
56+
$start = mb_strlen(substr($normalizedString, 0, $match[1]), 'UTF-8');
5957
$length = mb_strlen($match[0], 'UTF-8');
6058
$matchedText = $match[0];
6159

@@ -85,12 +83,7 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
8583

8684
$continue = true;
8785

88-
// Apply mask
89-
$replacement = $mask->mask($matchedText, $length);
90-
91-
$workingCleanString = mb_substr($workingCleanString, 0, $start) . $replacement .
92-
mb_substr($workingCleanString, $start + $length);
93-
86+
// Mask in normalizedString only (needed for loop termination)
9487
$normalizedString = mb_substr($normalizedString, 0, $start) . str_repeat('*', mb_strlen($match[0], 'UTF-8')) .
9588
mb_substr($normalizedString, $start + mb_strlen($match[0], 'UTF-8'));
9689

@@ -123,6 +116,17 @@ public function detect(string $text, Dictionary $dictionary, MaskStrategyInterfa
123116
));
124117
}
125118

119+
// Rebuild cleanText from surviving matches (right-to-left)
120+
$workingCleanString = $text;
121+
$sorted = $matchedWords;
122+
usort($sorted, fn($a, $b) => $b->position - $a->position);
123+
foreach ($sorted as $word) {
124+
$replacement = $mask->mask($word->text, $word->length);
125+
$workingCleanString = mb_substr($workingCleanString, 0, $word->position)
126+
. $replacement
127+
. mb_substr($workingCleanString, $word->position + $word->length);
128+
}
129+
126130
$totalWords = max(1, str_word_count($text));
127131
$scoreValue = Score::calculate($matchedWords, $totalWords);
128132

src/Facades/Blasp.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,18 @@ public static function withoutFiltering(Closure $callback): mixed
6464
public static function assertChecked(): void
6565
{
6666
$instance = static::getFacadeRoot();
67-
if ($instance instanceof BlaspFake) {
68-
$instance->assertChecked();
67+
if (!$instance instanceof BlaspFake) {
68+
throw new \RuntimeException('Blasp::assertChecked() requires Blasp::fake() to be called first.');
6969
}
70+
$instance->assertChecked();
7071
}
7172

7273
public static function assertCheckedTimes(int $times): void
7374
{
7475
$instance = static::getFacadeRoot();
75-
if ($instance instanceof BlaspFake) {
76-
$instance->assertCheckedTimes($times);
76+
if (!$instance instanceof BlaspFake) {
77+
throw new \RuntimeException('Blasp::assertCheckedTimes() requires Blasp::fake() to be called first.');
7778
}
79+
$instance->assertCheckedTimes($times);
7880
}
7981
}

src/Rules/Profanity.php

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,25 +12,32 @@ class Profanity implements ValidationRule
1212
protected ?int $maxScore = null;
1313
protected ?Severity $minimumSeverity = null;
1414

15-
public static function in(string $language): self
15+
public static function make(): self
1616
{
17-
$rule = new self();
18-
$rule->language = $language;
19-
return $rule;
17+
return new self();
2018
}
2119

22-
public static function maxScore(int $score): self
20+
public function in(string $language): self
2321
{
24-
$rule = new self();
25-
$rule->maxScore = $score;
26-
return $rule;
22+
$this->language = $language;
23+
return $this;
2724
}
2825

29-
public static function severity(Severity $severity): self
26+
public function maxScore(int $score): self
3027
{
31-
$rule = new self();
32-
$rule->minimumSeverity = $severity;
33-
return $rule;
28+
$this->maxScore = $score;
29+
return $this;
30+
}
31+
32+
public function severity(Severity $severity): self
33+
{
34+
$this->minimumSeverity = $severity;
35+
return $this;
36+
}
37+
38+
public static function __callStatic(string $name, array $arguments): self
39+
{
40+
return (new self())->$name(...$arguments);
3441
}
3542

3643
public function validate(string $attribute, mixed $value, Closure $fail): void

0 commit comments

Comments
 (0)