Skip to content

Commit fb69125

Browse files
authored
fix: allow emoji ZWJ sequences in unicode-abuse security scanner (#18793)
1 parent a06cf97 commit fb69125

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

pkg/workflow/markdown_security_scanner.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,20 @@ var bidiOverrideRunes = map[rune]string{
212212
'\u2069': "pop directional isolate (U+2069)",
213213
}
214214

215+
// isEmojiLike reports whether r is in a Unicode range commonly used for emoji.
216+
// This is used to distinguish legitimate emoji ZWJ sequences (e.g., 🧑‍🤝‍🧑)
217+
// from abusive uses of U+200D to hide or obfuscate ASCII text.
218+
//
219+
// Ranges covered:
220+
// - U+2194–U+27BF: arrows, misc symbols, and dingbats (includes ❤ U+2764, ♀ U+2640)
221+
// - U+FE00–U+FE0F: variation selectors (e.g., U+FE0F forces emoji presentation)
222+
// - U+1F000+: supplementary multilingual plane where most modern emoji live
223+
func isEmojiLike(r rune) bool {
224+
return (r >= 0x2194 && r <= 0x27BF) || // arrows, misc symbols, dingbats
225+
(r >= 0xFE00 && r <= 0xFE0F) || // variation selectors
226+
r >= 0x1F000 // supplementary multilingual plane (most modern emoji)
227+
}
228+
215229
func scanUnicodeAbuse(content string) []SecurityFinding {
216230
var findings []SecurityFinding
217231
lines := strings.Split(content, "\n")
@@ -222,6 +236,7 @@ func scanUnicodeAbuse(content string) []SecurityFinding {
222236
lineNo := lineNum + 1
223237

224238
// Check for zero-width and invisible characters
239+
var prevRune rune
225240
for i := 0; i < len(line); {
226241
r, size := utf8.DecodeRuneInString(line[i:])
227242
if r == utf8.RuneError && size <= 1 {
@@ -230,6 +245,22 @@ func scanUnicodeAbuse(content string) []SecurityFinding {
230245
}
231246

232247
if name, ok := dangerousUnicodeRunes[r]; ok {
248+
// U+200D (ZWJ) is a standard component of emoji sequences such as
249+
// 🧑‍🤝‍🧑 (people holding hands) or 👨‍👩‍👧 (family). Only flag it when
250+
// it is NOT flanked by emoji-range codepoints on both sides.
251+
// prevRune is 0 (null) at the start of each line, so a ZWJ at
252+
// the beginning of a line is always flagged (isEmojiLike(0)==false).
253+
if r == '\u200D' {
254+
var nextRune rune
255+
if i+size < len(line) {
256+
nextRune, _ = utf8.DecodeRuneInString(line[i+size:])
257+
}
258+
if isEmojiLike(prevRune) && isEmojiLike(nextRune) {
259+
prevRune = r
260+
i += size
261+
continue
262+
}
263+
}
233264
findings = append(findings, SecurityFinding{
234265
Category: CategoryUnicodeAbuse,
235266
Description: "contains invisible character: " + name,
@@ -260,6 +291,7 @@ func scanUnicodeAbuse(content string) []SecurityFinding {
260291
}
261292
}
262293

294+
prevRune = r
263295
i += size
264296
}
265297
}

pkg/workflow/markdown_security_scanner_test.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,70 @@ func TestScanMarkdownSecurity_UnicodeAbuse_AllowsNormalWhitespace(t *testing.T)
115115
assert.Empty(t, findings, "should not flag normal whitespace characters")
116116
}
117117

118-
// --- Hidden Content Tests ---
118+
func TestScanMarkdownSecurity_UnicodeAbuse_AllowsEmojiZWJ(t *testing.T) {
119+
// ZWJ (U+200D) is legitimate between emoji codepoints and must not be flagged.
120+
tests := []struct {
121+
name string
122+
content string
123+
}{
124+
{
125+
name: "people holding hands (issue report example)",
126+
content: "Status: \U0001F9D1\u200D\U0001F91D\u200D\U0001F9D1 Team Triage",
127+
},
128+
{
129+
name: "family emoji",
130+
content: "Group: \U0001F468\u200D\U0001F469\u200D\U0001F467",
131+
},
132+
{
133+
name: "woman technologist",
134+
content: "Role: \U0001F469\u200D\U0001F4BB",
135+
},
136+
{
137+
name: "rainbow flag (variation selector before ZWJ)",
138+
content: "Flag: \U0001F3F3\uFE0F\u200D\U0001F308",
139+
},
140+
{
141+
name: "couple with heart (symbol-range emoji before ZWJ)",
142+
content: "Love: \U0001F468\u200D\u2764\uFE0F\u200D\U0001F469",
143+
},
144+
}
145+
146+
for _, tt := range tests {
147+
t.Run(tt.name, func(t *testing.T) {
148+
findings := ScanMarkdownSecurity(tt.content)
149+
assert.Empty(t, findings, "should not flag emoji ZWJ sequence in %s", tt.name)
150+
})
151+
}
152+
}
153+
154+
func TestScanMarkdownSecurity_UnicodeAbuse_FlagsNonEmojiZWJ(t *testing.T) {
155+
// ZWJ between non-emoji (ASCII) characters is still suspicious and must be flagged.
156+
tests := []struct {
157+
name string
158+
content string
159+
}{
160+
{
161+
name: "ZWJ between ASCII letters",
162+
content: "Hello\u200Dworld",
163+
},
164+
{
165+
name: "ZWJ at start of text",
166+
content: "\u200DHello",
167+
},
168+
{
169+
name: "ZWJ at end of line",
170+
content: "Hello\u200D",
171+
},
172+
}
173+
174+
for _, tt := range tests {
175+
t.Run(tt.name, func(t *testing.T) {
176+
findings := ScanMarkdownSecurity(tt.content)
177+
require.NotEmpty(t, findings, "should flag ZWJ outside emoji sequence in %s", tt.name)
178+
assert.Equal(t, CategoryUnicodeAbuse, findings[0].Category, "category should be unicode-abuse")
179+
})
180+
}
181+
}
119182

120183
func TestScanMarkdownSecurity_HiddenContent_SuspiciousHTMLComments(t *testing.T) {
121184
tests := []struct {

0 commit comments

Comments
 (0)