homographic_spoofing 0.1.1 โ 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/lib/homographic_spoofing/detector/detection.rb +9 -1
- data/lib/homographic_spoofing/detector/email_address.rb +382 -5
- data/lib/homographic_spoofing/detector/idn.rb +54 -5
- data/lib/homographic_spoofing/detector/rule/base.rb +1 -1
- data/lib/homographic_spoofing/detector/rule/context.rb +6 -2
- data/lib/homographic_spoofing/detector/rule/data/allowed_idn_characters.txt +1 -1
- data/lib/homographic_spoofing/detector/rule/data/digits.csv +80 -0
- data/lib/homographic_spoofing/detector/rule/disallowed_characters.rb +11 -4
- data/lib/homographic_spoofing/detector/rule/idn/base.rb +1 -1
- data/lib/homographic_spoofing/detector/rule/idn/context.rb +9 -3
- data/lib/homographic_spoofing/detector/rule/idn/dangerous_pattern.rb +15 -0
- data/lib/homographic_spoofing/detector/rule/idn/invisible_characters.rb +1 -1
- data/lib/homographic_spoofing/detector/rule/idn/script_confusable.rb +15 -3
- data/lib/homographic_spoofing/detector/rule/mixed_digits.rb +1 -1
- data/lib/homographic_spoofing/sanitizer/base.rb +142 -8
- data/lib/homographic_spoofing/sanitizer/idn.rb +30 -0
- data/lib/homographic_spoofing/version.rb +1 -1
- metadata +21 -7
|
@@ -388,6 +388,16 @@
|
|
|
388
388
|
๐ดท,hanifi_rohingya_digit
|
|
389
389
|
๐ดธ,hanifi_rohingya_digit
|
|
390
390
|
๐ดน,hanifi_rohingya_digit
|
|
391
|
+
๐ต,garay_digit
|
|
392
|
+
๐ต,garay_digit
|
|
393
|
+
๐ต,garay_digit
|
|
394
|
+
๐ต,garay_digit
|
|
395
|
+
๐ต,garay_digit
|
|
396
|
+
๐ต
,garay_digit
|
|
397
|
+
๐ต,garay_digit
|
|
398
|
+
๐ต,garay_digit
|
|
399
|
+
๐ต,garay_digit
|
|
400
|
+
๐ต,garay_digit
|
|
391
401
|
๐ฆ,brahmi_digit
|
|
392
402
|
๐ง,brahmi_digit
|
|
393
403
|
๐จ,brahmi_digit
|
|
@@ -478,6 +488,26 @@
|
|
|
478
488
|
๐,takri_digit
|
|
479
489
|
๐,takri_digit
|
|
480
490
|
๐,takri_digit
|
|
491
|
+
๐,myanmar_pao_digit
|
|
492
|
+
๐,myanmar_pao_digit
|
|
493
|
+
๐,myanmar_pao_digit
|
|
494
|
+
๐,myanmar_pao_digit
|
|
495
|
+
๐,myanmar_pao_digit
|
|
496
|
+
๐,myanmar_pao_digit
|
|
497
|
+
๐,myanmar_pao_digit
|
|
498
|
+
๐,myanmar_pao_digit
|
|
499
|
+
๐,myanmar_pao_digit
|
|
500
|
+
๐,myanmar_pao_digit
|
|
501
|
+
๐,myanmar_eastern_pwo_karen_digit
|
|
502
|
+
๐,myanmar_eastern_pwo_karen_digit
|
|
503
|
+
๐,myanmar_eastern_pwo_karen_digit
|
|
504
|
+
๐,myanmar_eastern_pwo_karen_digit
|
|
505
|
+
๐,myanmar_eastern_pwo_karen_digit
|
|
506
|
+
๐,myanmar_eastern_pwo_karen_digit
|
|
507
|
+
๐ ,myanmar_eastern_pwo_karen_digit
|
|
508
|
+
๐ก,myanmar_eastern_pwo_karen_digit
|
|
509
|
+
๐ข,myanmar_eastern_pwo_karen_digit
|
|
510
|
+
๐ฃ,myanmar_eastern_pwo_karen_digit
|
|
481
511
|
๐ฐ,ahom_digit
|
|
482
512
|
๐ฑ,ahom_digit
|
|
483
513
|
๐ฒ,ahom_digit
|
|
@@ -508,6 +538,16 @@
|
|
|
508
538
|
๐ฅ,dives_akuru_digit
|
|
509
539
|
๐ฅ,dives_akuru_digit
|
|
510
540
|
๐ฅ,dives_akuru_digit
|
|
541
|
+
๐ฏฐ,sunuwar_digit
|
|
542
|
+
๐ฏฑ,sunuwar_digit
|
|
543
|
+
๐ฏฒ,sunuwar_digit
|
|
544
|
+
๐ฏณ,sunuwar_digit
|
|
545
|
+
๐ฏด,sunuwar_digit
|
|
546
|
+
๐ฏต,sunuwar_digit
|
|
547
|
+
๐ฏถ,sunuwar_digit
|
|
548
|
+
๐ฏท,sunuwar_digit
|
|
549
|
+
๐ฏธ,sunuwar_digit
|
|
550
|
+
๐ฏน,sunuwar_digit
|
|
511
551
|
๐ฑ,bhaiksuki_digit
|
|
512
552
|
๐ฑ,bhaiksuki_digit
|
|
513
553
|
๐ฑ,bhaiksuki_digit
|
|
@@ -548,6 +588,16 @@
|
|
|
548
588
|
๐ฝ,kawi_digit
|
|
549
589
|
๐ฝ,kawi_digit
|
|
550
590
|
๐ฝ,kawi_digit
|
|
591
|
+
๐ฐ,gurung_khema_digit
|
|
592
|
+
๐ฑ,gurung_khema_digit
|
|
593
|
+
๐ฒ,gurung_khema_digit
|
|
594
|
+
๐ณ,gurung_khema_digit
|
|
595
|
+
๐ด,gurung_khema_digit
|
|
596
|
+
๐ต,gurung_khema_digit
|
|
597
|
+
๐ถ,gurung_khema_digit
|
|
598
|
+
๐ท,gurung_khema_digit
|
|
599
|
+
๐ธ,gurung_khema_digit
|
|
600
|
+
๐น,gurung_khema_digit
|
|
551
601
|
๐ฉ ,mro_digit
|
|
552
602
|
๐ฉก,mro_digit
|
|
553
603
|
๐ฉข,mro_digit
|
|
@@ -578,6 +628,26 @@
|
|
|
578
628
|
๐ญ,pahawh_hmong_digit
|
|
579
629
|
๐ญ,pahawh_hmong_digit
|
|
580
630
|
๐ญ,pahawh_hmong_digit
|
|
631
|
+
๐ตฐ,kirat_rai_digit
|
|
632
|
+
๐ตฑ,kirat_rai_digit
|
|
633
|
+
๐ตฒ,kirat_rai_digit
|
|
634
|
+
๐ตณ,kirat_rai_digit
|
|
635
|
+
๐ตด,kirat_rai_digit
|
|
636
|
+
๐ตต,kirat_rai_digit
|
|
637
|
+
๐ตถ,kirat_rai_digit
|
|
638
|
+
๐ตท,kirat_rai_digit
|
|
639
|
+
๐ตธ,kirat_rai_digit
|
|
640
|
+
๐ตน,kirat_rai_digit
|
|
641
|
+
๐ณฐ,outlined_digit
|
|
642
|
+
๐ณฑ,outlined_digit
|
|
643
|
+
๐ณฒ,outlined_digit
|
|
644
|
+
๐ณณ,outlined_digit
|
|
645
|
+
๐ณด,outlined_digit
|
|
646
|
+
๐ณต,outlined_digit
|
|
647
|
+
๐ณถ,outlined_digit
|
|
648
|
+
๐ณท,outlined_digit
|
|
649
|
+
๐ณธ,outlined_digit
|
|
650
|
+
๐ณน,outlined_digit
|
|
581
651
|
๐,mathematical_bold_digit
|
|
582
652
|
๐,mathematical_bold_digit
|
|
583
653
|
๐,mathematical_bold_digit
|
|
@@ -658,6 +728,16 @@
|
|
|
658
728
|
๐ท,nag_mundari_digit
|
|
659
729
|
๐ธ,nag_mundari_digit
|
|
660
730
|
๐น,nag_mundari_digit
|
|
731
|
+
๐ฑ,ol_onal_digit
|
|
732
|
+
๐ฒ,ol_onal_digit
|
|
733
|
+
๐ณ,ol_onal_digit
|
|
734
|
+
๐ด,ol_onal_digit
|
|
735
|
+
๐ต,ol_onal_digit
|
|
736
|
+
๐ถ,ol_onal_digit
|
|
737
|
+
๐ท,ol_onal_digit
|
|
738
|
+
๐ธ,ol_onal_digit
|
|
739
|
+
๐น,ol_onal_digit
|
|
740
|
+
๐บ,ol_onal_digit
|
|
661
741
|
๐ฅ,adlam_digit
|
|
662
742
|
๐ฅ,adlam_digit
|
|
663
743
|
๐ฅ,adlam_digit
|
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
# 3. and 4. of Google Chrome IDN policy See https://util.unicode.org/UnicodeJsps/list-unicodeset.jsp?a=%5B%3AIdentifierStatus%3DAllowed%3A&abb=on&g=&i=
|
|
2
2
|
class HomographicSpoofing::Detector::Rule::DisallowedCharacters < HomographicSpoofing::Detector::Rule::Base
|
|
3
3
|
class << self
|
|
4
|
-
#
|
|
5
|
-
|
|
4
|
+
# Characters removed from the allowed set. Combines Mozilla's blocklist
|
|
5
|
+
# (http://kb.mozillazine.org/Network.IDN.blacklist_chars) with the
|
|
6
|
+
# removals in Chromium's IDNSpoofChecker::SetAllowedUnicodeSet
|
|
7
|
+
# (components/url_formatter/spoof_checks/idn_spoof_checker.cc).
|
|
8
|
+
DISALLOWED_CHARACTERS = Set[
|
|
6
9
|
"\u0020", # Space
|
|
7
10
|
"\u00a0", # No-break space
|
|
8
11
|
"\u00bc", # Vulgar fraction one quarter
|
|
9
12
|
"\u00bd", # Vulgar fraction one half
|
|
10
13
|
"\u00be", # Vulgar fraction three quarters
|
|
14
|
+
"\u0138", # Latin small letter kra (Chromium)
|
|
11
15
|
"\u01c3", # Latin letter retroflex click
|
|
16
|
+
"\u02bb", # Modifier letter turned comma (Chromium)
|
|
17
|
+
"\u02bc", # Modifier letter apostrophe (Chromium)
|
|
12
18
|
"\u02d0", # Modifier letter triangular colon
|
|
19
|
+
"\u02ec", # Modifier letter voicing (Chromium)
|
|
13
20
|
"\u0337", # Combining short solidus overlay
|
|
14
21
|
"\u0338", # Combining long solidus overlay
|
|
15
22
|
"\u0589", # Armenian full stop
|
|
@@ -124,11 +131,11 @@ class HomographicSpoofing::Detector::Rule::DisallowedCharacters < HomographicSpo
|
|
|
124
131
|
]
|
|
125
132
|
|
|
126
133
|
def allowed_chars_set
|
|
127
|
-
@@allowed_chars_set ||= (read_allowed_idn_chars.chars.to_set -
|
|
134
|
+
@@allowed_chars_set ||= (read_allowed_idn_chars.chars.to_set - DISALLOWED_CHARACTERS)
|
|
128
135
|
end
|
|
129
136
|
|
|
130
137
|
private
|
|
131
|
-
# Built with
|
|
138
|
+
# Built with bin/generate_allowed_idn_characters
|
|
132
139
|
def read_allowed_idn_chars
|
|
133
140
|
File.read("#{__dir__}/data/allowed_idn_characters.txt")
|
|
134
141
|
end
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
class HomographicSpoofing::Detector::Rule::Idn::Context < HomographicSpoofing::Detector::Rule::Context
|
|
2
|
-
attr_reader :tld
|
|
2
|
+
attr_reader :tld, :tld_allowances
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
# `tld_allowances` is shared by every label of one domain, so a decision that
|
|
5
|
+
# depends only on the TLD is made once per domain rather than once per label.
|
|
6
|
+
# Under a wildcard suffix such as "*.mm" the TLD carries a label the sender
|
|
7
|
+
# chose, and scanning it again for every label would cost the label count
|
|
8
|
+
# times its length.
|
|
9
|
+
def initialize(label:, tld:, occurrence: 0, tld_allowances: {})
|
|
5
10
|
@tld = tld
|
|
6
|
-
|
|
11
|
+
@tld_allowances = tld_allowances
|
|
12
|
+
super(label:, occurrence:)
|
|
7
13
|
end
|
|
8
14
|
end
|
|
@@ -16,6 +16,13 @@ class HomographicSpoofing::Detector::Rule::Idn::DangerousPattern < HomographicSp
|
|
|
16
16
|
[\u30ce\u30f3\u30bd\u30be\u4e36\u4e40\u4e41\u4e3f]
|
|
17
17
|
[^\p{kana}\p{hira}\p{hani}]/x,
|
|
18
18
|
|
|
19
|
+
/# Rules see one label at a time, so a label edge counts as a non-Japanese
|
|
20
|
+
# side too: the dot the renderer draws there is not Japanese either. Beside
|
|
21
|
+
# an edge, "ใผ" (U+30FC, Common to Unicode's Script property but Japanese in
|
|
22
|
+
# use) counts as Japanese, so words like "ใใผใ" and "ใณใผใณ" stay clear.
|
|
23
|
+
\A[\u30ce\u30f3\u30bd\u30be\u4e36\u4e40\u4e41\u4e3f](?:[^\p{kana}\p{hira}\p{hani}\u30fc]|\z)|
|
|
24
|
+
[^\p{kana}\p{hira}\p{hani}\u30fc][\u30ce\u30f3\u30bd\u30be\u4e36\u4e40\u4e41\u4e3f]\z/x,
|
|
25
|
+
|
|
19
26
|
/# Disallow three Hiragana letters (U+307[8-A]) or Katakana letters
|
|
20
27
|
# (U+30D[8-A]) that look exactly like each other when they're used
|
|
21
28
|
# in a label otherwise entirely in Katakana or Hiragana.
|
|
@@ -49,6 +56,14 @@ class HomographicSpoofing::Detector::Rule::Idn::DangerousPattern < HomographicSp
|
|
|
49
56
|
[\u4e00\u3127\u4e28\u4e5b\u4e03\u4e05\u5341\u3007\u3112\u311a\u311f\u3128\u3129\u3108\u31ba\u31b3\u5de5\u31b2\u8ba0\u4e01]
|
|
50
57
|
[^\p{kana}\p{hira}\p{hani}\p{bopo}]/x,
|
|
51
58
|
|
|
59
|
+
/# Rules see one label at a time, so a label that is only one of these
|
|
60
|
+
# ideographs has the dots the renderer draws on both sides, and neither
|
|
61
|
+
# is CJK: "a.ไธ.example.com" reads as "a.-.example.com". A longer label
|
|
62
|
+
# with one at its edge is already caught by the two patterns above when
|
|
63
|
+
# its other neighbour is non-CJK, and stays safe when it is CJK, as in
|
|
64
|
+
# "ๅทฅ่ก" or "ไธๅทๅบ".
|
|
65
|
+
\A[ไธใงไธจไนไธไธ
ๅใใใใใจใฉใใบใณๅทฅใฒ่ฎ ไธ]\z/x,
|
|
66
|
+
|
|
52
67
|
/# Disallow combining diacritical mark (U+0300-U+0339) after a
|
|
53
68
|
# non-LGC character. Other combining diacritical marks are not in
|
|
54
69
|
# the allowed character set.
|
|
@@ -9,6 +9,6 @@ class HomographicSpoofing::Detector::Rule::Idn::InvisibleCharacters < Homographi
|
|
|
9
9
|
/\u0e48{2,}/, # Thai tone repeated
|
|
10
10
|
/\u0301{2,}/, # accute accent repeated
|
|
11
11
|
/\u00e1\u0301/, # 'a' with acuted accent + another acute accent
|
|
12
|
-
|
|
12
|
+
/\A\p{M}/, # Combining mark at the beginning of a label
|
|
13
13
|
)
|
|
14
14
|
end
|
|
@@ -46,11 +46,23 @@ class HomographicSpoofing::Detector::Rule::Idn::ScriptConfusable < HomographicSp
|
|
|
46
46
|
[ /\p{Mymr}/, /[แแแแแแแแแแ\u1090\u1091\u1095\u1096\u1097]/, /[a-z]+\.mm/ ],
|
|
47
47
|
# # Thai
|
|
48
48
|
[ /\p{Thai}/, /[เธเธเธเธเธฃเธซเนเนเนเธเธฅเธเธเธก]/, /th/ ]
|
|
49
|
-
]
|
|
49
|
+
]
|
|
50
|
+
# Anchor each allowed-TLD pattern so it only matches whole trailing labels
|
|
51
|
+
# of the TLD, never a substring of one. Unanchored, `/ru/` matched `.guru`,
|
|
52
|
+
# `/su/` matched `.surf`, `/by/` matched `.baby`/`.rugby`, etc., silently
|
|
53
|
+
# disabling single-script confusable detection on those TLDs. The
|
|
54
|
+
# `(?:\A|\.)โฆ\z` boundary keeps multi-label public suffixes working โ
|
|
55
|
+
# `co.th` still matches `/th/`, `com.mm` still matches `/[a-z]+\.mm/`.
|
|
56
|
+
.map do |script, latin_lookalike, allowed_tlds|
|
|
57
|
+
anchored_tlds = allowed_tlds && /(?:\A|\.)(?:#{allowed_tlds.source})\z/
|
|
58
|
+
Confusable.new script, latin_lookalike, anchored_tlds
|
|
59
|
+
end
|
|
50
60
|
|
|
51
61
|
def is_script_confusable_allowed_for_tld?(confusable)
|
|
52
|
-
|
|
53
|
-
confusable
|
|
62
|
+
tld_allowances.fetch(confusable) do
|
|
63
|
+
tld_allowances[confusable] = tld_contains_any_letter_from_script?(confusable.script) ||
|
|
64
|
+
confusable.allowed_tlds&.match?(tld) || false
|
|
65
|
+
end
|
|
54
66
|
end
|
|
55
67
|
|
|
56
68
|
def tld_contains_any_letter_from_script?(script)
|
|
@@ -23,7 +23,7 @@ class HomographicSpoofing::Detector::Rule::MixedDigits < HomographicSpoofing::De
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# Built with
|
|
26
|
+
# Built with bin/generate_digits_characters
|
|
27
27
|
def read_digits
|
|
28
28
|
File.read("#{__dir__}/data/digits.csv")
|
|
29
29
|
end
|
|
@@ -10,19 +10,153 @@ class HomographicSpoofing::Sanitizer::Base
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def sanitize
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
result = punycode(result, detection.label)
|
|
17
|
-
end
|
|
18
|
-
result
|
|
13
|
+
detections = detector_class.new(field).detections
|
|
14
|
+
detections.each { |detection| log(detection.reason, detection.label) }
|
|
15
|
+
apply(field, detections)
|
|
19
16
|
end
|
|
20
17
|
|
|
21
18
|
private
|
|
22
19
|
attr_reader :field
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
# Punycode each offending label only within the component it came from. A
|
|
22
|
+
# spanned detection carries the [offset, length] of its component, so a
|
|
23
|
+
# domain-label spoof is confined to the domain and never rewrites a benign
|
|
24
|
+
# local part that merely equals the same string โ this is what keeps
|
|
25
|
+
# "ะท@ะผะธั.ะท.example.com" punycoding the domain label "ะท" while leaving the "ะท"
|
|
26
|
+
# mailbox intact. Spans are disjoint or nested (a display name read from a
|
|
27
|
+
# comment the host's token carries), so they form a tree: each span's text is
|
|
28
|
+
# rebuilt with its inner spans already rewritten, then rewritten itself, in
|
|
29
|
+
# one pass over the field. A detection with no span (a bare IDN or quoted
|
|
30
|
+
# string) spans the whole field and is applied last, over what remains.
|
|
31
|
+
def apply(field, detections)
|
|
32
|
+
return field.dup if detections.empty?
|
|
33
|
+
|
|
34
|
+
whole, spanned = detections.partition { |detection| detection.span.nil? }
|
|
35
|
+
edits = spanned.group_by(&:span).map do |(offset, length), group|
|
|
36
|
+
Edit.new(offset, offset + length, group.map(&:label).uniq, [])
|
|
37
|
+
end
|
|
38
|
+
chars = field.chars
|
|
39
|
+
replace_labels(splice(chars, 0, chars.length, nest(edits)), whole.map(&:label).uniq)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Edit = Struct.new(:start, :finish, :labels, :inner)
|
|
43
|
+
|
|
44
|
+
# Arrange spans into a tree, outer before inner, in one pass over them sorted
|
|
45
|
+
# by start. A span that overlaps an earlier sibling without nesting in it is
|
|
46
|
+
# trimmed to start where that sibling ends.
|
|
47
|
+
def nest(edits)
|
|
48
|
+
outermost, open = [], []
|
|
49
|
+
edits.sort_by { |edit| [ edit.start, -edit.finish ] }.each do |edit|
|
|
50
|
+
open.pop while open.any? && open.last.finish < edit.finish
|
|
51
|
+
siblings = open.empty? ? outermost : open.last.inner
|
|
52
|
+
edit.start = [ edit.start, siblings.last.finish ].max if siblings.any?
|
|
53
|
+
next if edit.start > edit.finish
|
|
54
|
+
|
|
55
|
+
siblings << edit
|
|
56
|
+
open << edit
|
|
57
|
+
end
|
|
58
|
+
outermost
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def splice(chars, start, finish, edits)
|
|
62
|
+
text, at = +"", start
|
|
63
|
+
edits.each do |edit|
|
|
64
|
+
text << chars[at...edit.start].join << replace_labels(splice(chars, edit.start, edit.finish, edit.inner), edit.labels)
|
|
65
|
+
at = edit.finish
|
|
66
|
+
end
|
|
67
|
+
text << chars[at...finish].join
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Replace each label where it is a whole "."-delimited component of the
|
|
71
|
+
# region, so an offending label is never rewritten as a substring of a benign
|
|
72
|
+
# sibling (the Cyrillic digit-look-alike "ะท" that also sits inside
|
|
73
|
+
# "ะผะฐะณะฐะทะธะฝ"), and every matching component is punycoded from its own
|
|
74
|
+
# spelling โ case-exact, so a spoof repeated in different casing is handled
|
|
75
|
+
# per occurrence. A component is compared with its CFWS (comments and
|
|
76
|
+
# surrounding whitespace, which the parser drops from the label it reports)
|
|
77
|
+
# set aside, and only the label itself is rewritten, so "ะท(comment)" is
|
|
78
|
+
# punycoded at the label and a comment beside it never sends the replacement
|
|
79
|
+
# into a benign sibling. A label that is only a substring of a component (a
|
|
80
|
+
# display name carrying spaces) has no whole component to match and falls
|
|
81
|
+
# back to exact substring replacement.
|
|
82
|
+
#
|
|
83
|
+
# A label that keeps whitespace inside it, as PublicSuffix reports "ะฐ " in
|
|
84
|
+
# "ะ .com", matches its component as written.
|
|
85
|
+
#
|
|
86
|
+
# The region is scanned once for all labels, so sanitizing stays linear in
|
|
87
|
+
# its length however many labels were detected.
|
|
88
|
+
def replace_labels(region, labels)
|
|
89
|
+
return region if labels.empty?
|
|
90
|
+
|
|
91
|
+
components = label_components(region)
|
|
92
|
+
present = components.to_set(&:bare) | components.map(&:text)
|
|
93
|
+
whole, partial = labels.partition { |label| present.include?(label) }
|
|
94
|
+
whole = whole.to_set
|
|
95
|
+
|
|
96
|
+
result = components.map do |component|
|
|
97
|
+
if whole.include?(component.text) && component.bare
|
|
98
|
+
Dnsruby::Name.punycode(component.text)
|
|
99
|
+
elsif whole.include?(component.bare)
|
|
100
|
+
component.punycoded
|
|
101
|
+
else
|
|
102
|
+
component.text
|
|
103
|
+
end
|
|
104
|
+
end.join
|
|
105
|
+
partial.inject(result) { |text, label| text.gsub(label) { Dnsruby::Name.punycode(label) } }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One "."-delimited component of a region: its text, the label it carries
|
|
109
|
+
# with CFWS set aside (`bare`), and where that label starts in the text, past
|
|
110
|
+
# any leading CFWS that may repeat it. Separators are components too, with no
|
|
111
|
+
# label.
|
|
112
|
+
Component = Struct.new(:text, :bare, :start) do
|
|
113
|
+
def punycoded
|
|
114
|
+
if text[start, bare.length] == bare
|
|
115
|
+
text[0, start] + Dnsruby::Name.punycode(bare) + text[(start + bare.length)..]
|
|
116
|
+
else
|
|
117
|
+
text
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Split a region into label components on each "." outside comments and
|
|
123
|
+
# quoted strings, in a single pass. Comments nest and honour backslash
|
|
124
|
+
# escapes, as RFC 5322 has them, so a comment's own dots or parentheses
|
|
125
|
+
# never split or end a label early.
|
|
126
|
+
def label_components(region)
|
|
127
|
+
components = []
|
|
128
|
+
text, bare, start = +"", +"", nil
|
|
129
|
+
depth, quoted, escaped = 0, false, false
|
|
130
|
+
|
|
131
|
+
region.each_char do |char|
|
|
132
|
+
commented = depth > 0
|
|
133
|
+
if escaped
|
|
134
|
+
escaped = false
|
|
135
|
+
elsif char == "\\" && (commented || quoted)
|
|
136
|
+
escaped = true
|
|
137
|
+
elsif commented
|
|
138
|
+
depth += 1 if char == "("
|
|
139
|
+
depth -= 1 if char == ")"
|
|
140
|
+
elsif quoted
|
|
141
|
+
quoted = false if char == '"'
|
|
142
|
+
elsif char == "("
|
|
143
|
+
depth, commented = 1, true
|
|
144
|
+
elsif char == '"'
|
|
145
|
+
quoted = true
|
|
146
|
+
elsif char == "."
|
|
147
|
+
components << Component.new(text, bare.strip, start || 0) << Component.new(char, nil, 0)
|
|
148
|
+
text, bare, start = +"", +"", nil
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
unless commented
|
|
153
|
+
start ||= text.length unless char.match?(/\s/)
|
|
154
|
+
bare << char
|
|
155
|
+
end
|
|
156
|
+
text << char
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
components << Component.new(text, bare.strip, start || 0)
|
|
26
160
|
end
|
|
27
161
|
|
|
28
162
|
def detector_class
|
|
@@ -7,4 +7,34 @@ class HomographicSpoofing::Sanitizer::Idn < HomographicSpoofing::Sanitizer::Base
|
|
|
7
7
|
def spoofing_type
|
|
8
8
|
"EmailIDN"
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
# A bare domain has no comments or quoted strings: the detector splits it on
|
|
12
|
+
# every ".", so split it the same way. Honouring "(" or '"' here would let an
|
|
13
|
+
# unmatched one fold the rest of the domain into one component, and every
|
|
14
|
+
# detected label would then fall back to a substitution over the whole field.
|
|
15
|
+
#
|
|
16
|
+
# The detector reports labels as PublicSuffix does: with the domain's own
|
|
17
|
+
# leading and trailing padding set aside, but any whitespace inside the
|
|
18
|
+
# domain kept on its label ("ะ " in " ะ .ะ .com"). That padding is set apart
|
|
19
|
+
# as components with no label, so each label component reads as its label
|
|
20
|
+
# was reported.
|
|
21
|
+
def label_components(region)
|
|
22
|
+
core = region.strip
|
|
23
|
+
leading = core.empty? ? region : region[0, region.index(core)]
|
|
24
|
+
trailing = region[(leading.length + core.length)..]
|
|
25
|
+
|
|
26
|
+
[ padding(leading), *core.split(/(\.)/, -1).map { |text| component(text) }, padding(trailing) ].compact
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def component(text)
|
|
30
|
+
if text == "."
|
|
31
|
+
Component.new(text, nil, 0)
|
|
32
|
+
else
|
|
33
|
+
Component.new(text, text.strip, text.index(/\S/) || 0)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def padding(text)
|
|
38
|
+
Component.new(text, nil, 0) unless text.empty?
|
|
39
|
+
end
|
|
10
40
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: homographic_spoofing
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jacopo Beschi
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: zeitwerk
|
|
@@ -94,13 +93,27 @@ dependencies:
|
|
|
94
93
|
- - ">="
|
|
95
94
|
- !ruby/object:Gem::Version
|
|
96
95
|
version: '0'
|
|
97
|
-
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: csv
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '3.3'
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.3'
|
|
98
110
|
email:
|
|
99
111
|
- jacopo@37signals.com
|
|
100
112
|
executables: []
|
|
101
113
|
extensions: []
|
|
102
114
|
extra_rdoc_files: []
|
|
103
115
|
files:
|
|
116
|
+
- CHANGELOG.md
|
|
104
117
|
- MIT-LICENSE
|
|
105
118
|
- README.md
|
|
106
119
|
- lib/homographic_spoofing.rb
|
|
@@ -145,7 +158,9 @@ metadata:
|
|
|
145
158
|
allowed_push_host: https://rubygems.org
|
|
146
159
|
homepage_uri: https://github.com/basecamp/homographic_spoofing
|
|
147
160
|
source_code_uri: https://github.com/basecamp/homographic_spoofing
|
|
148
|
-
|
|
161
|
+
changelog_uri: https://github.com/basecamp/homographic_spoofing/blob/main/CHANGELOG.md
|
|
162
|
+
bug_tracker_uri: https://github.com/basecamp/homographic_spoofing/issues
|
|
163
|
+
rubygems_mfa_required: 'true'
|
|
149
164
|
rdoc_options: []
|
|
150
165
|
require_paths:
|
|
151
166
|
- lib
|
|
@@ -160,8 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
160
175
|
- !ruby/object:Gem::Version
|
|
161
176
|
version: '0'
|
|
162
177
|
requirements: []
|
|
163
|
-
rubygems_version: 3.
|
|
164
|
-
signing_key:
|
|
178
|
+
rubygems_version: 3.6.9
|
|
165
179
|
specification_version: 4
|
|
166
180
|
summary: A toolkit to both detect and sanitize homographic spoofing attacks in URLs
|
|
167
181
|
and Email addresses
|