Skip to content

Commit ce9157c

Browse files
authored
Merge branch 'master' into fix-email-tagged-ipv6-literals
2 parents ea748ac + 22786cc commit ce9157c

31 files changed

Lines changed: 527 additions & 58 deletions

‎.github/workflows/sast.yaml‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,20 @@ on:
1111
jobs:
1212
sast:
1313
permissions:
14-
security-events: write
14+
contents: read
1515
runs-on: ubuntu-latest
1616
steps:
17-
- name: Bandit
18-
uses: PyCQA/bandit-action@v1
17+
# checkout repository
18+
- name: Checkout repository
19+
uses: actions/checkout@v7
20+
# set up uv and a specific python version
21+
- name: Set up uv with Python v3.14
22+
uses: astral-sh/setup-uv@v10.3.0
1923
with:
20-
targets: src/validators
21-
exclude: .github,.pytest_cache,.venv,.vscode,site,tests
24+
python-version: "3.14"
25+
enable-cache: true
26+
# bandit, pinned by uv.lock and configured in pyproject.toml
27+
- name: Install 'sast' dependencies
28+
run: uv sync --locked --group sast
29+
- name: Bandit
30+
run: uv run --no-sync bandit -c pyproject.toml -r src/validators

‎src/validators/_tld.txt‎

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Version 2024052400, Last Updated Fri May 24 07:07:01 2024 UTC
1+
# Version 2026102100, Last Updated Wed Oct 21 07:07:01 2026 UTC
22
AAA
33
AARP
44
ABB
@@ -118,7 +118,6 @@ BE
118118
BEATS
119119
BEAUTY
120120
BEER
121-
BENTLEY
122121
BERLIN
123122
BEST
124123
BESTBUY
@@ -297,7 +296,6 @@ CY
297296
CYMRU
298297
CYOU
299298
CZ
300-
DABUR
301299
DAD
302300
DANCE
303301
DATA
@@ -346,7 +344,6 @@ DOWNLOAD
346344
DRIVE
347345
DTV
348346
DUBAI
349-
DUNLOP
350347
DUPONT
351348
DURBAN
352349
DVAG
@@ -489,7 +486,6 @@ GODADDY
489486
GOLD
490487
GOLDPOINT
491488
GOLF
492-
GOO
493489
GOODYEAR
494490
GOOG
495491
GOOGLE
@@ -623,12 +619,10 @@ JP
623619
JPMORGAN
624620
JPRS
625621
JUEGOS
626-
JUNIPER
627622
KAUFEN
628623
KDDI
629624
KE
630625
KERRYHOTELS
631-
KERRYLOGISTICS
632626
KERRYPROPERTIES
633627
KFH
634628
KG
@@ -660,7 +654,6 @@ LA
660654
LACAIXA
661655
LAMBORGHINI
662656
LAMER
663-
LANCASTER
664657
LAND
665658
LANDROVER
666659
LANXESS
@@ -692,7 +685,6 @@ LIMITED
692685
LIMO
693686
LINCOLN
694687
LINK
695-
LIPSY
696688
LIVE
697689
LIVING
698690
LK
@@ -748,6 +740,7 @@ MEME
748740
MEMORIAL
749741
MEN
750742
MENU
743+
MERCK
751744
MERCKMSD
752745
MG
753746
MH
@@ -802,7 +795,6 @@ NA
802795
NAB
803796
NAGOYA
804797
NAME
805-
NATURA
806798
NAVY
807799
NBA
808800
NC
@@ -918,7 +910,6 @@ POLITIE
918910
PORN
919911
POST
920912
PR
921-
PRAMERICA
922913
PRAXI
923914
PRESS
924915
PRIME
@@ -952,7 +943,6 @@ REALTOR
952943
REALTY
953944
RECIPES
954945
RED
955-
REDSTONE
956946
REDUMBRELLA
957947
REHAB
958948
REISE
@@ -1039,7 +1029,6 @@ SG
10391029
SH
10401030
SHANGRILA
10411031
SHARP
1042-
SHAW
10431032
SHELL
10441033
SHIA
10451034
SHIKSHA
@@ -1243,6 +1232,7 @@ WATCH
12431232
WATCHES
12441233
WEATHER
12451234
WEATHERCHANNEL
1235+
WEB
12461236
WEBCAM
12471237
WEBER
12481238
WEBSITE
@@ -1260,7 +1250,6 @@ WINDOWS
12601250
WINE
12611251
WINNERS
12621252
WME
1263-
WOLTERSKLUWER
12641253
WOODSIDE
12651254
WORK
12661255
WORKS

‎src/validators/crypto_addresses/btc_address.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def btc_address(value: str, /):
5050

5151
return (
5252
# segwit pattern
53-
re.compile(r"^(bc|tb)[0-3][02-9ac-hj-np-z]{14,74}$").match(value)
53+
re.compile(r"^(bc|tb)[0-3][02-9ac-hj-np-z]{14,74}$").fullmatch(value)
5454
if value[:2] in ("bc", "tb")
5555
else _validate_old_btc_address(value)
5656
)

‎src/validators/crypto_addresses/eth_address.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,6 @@ def eth_address(value: str, /):
5959
if not value:
6060
return False
6161

62-
return re.compile(r"^0x[0-9a-f]{40}$|^0x[0-9A-F]{40}$").match(
62+
return re.compile(r"^0x[0-9a-f]{40}$|^0x[0-9A-F]{40}$").fullmatch(
6363
value
6464
) or _validate_eth_checksum_address(value)

‎src/validators/domain.py‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from os import environ
55
from pathlib import Path
66
import re
7+
from stringprep import in_table_b1
78
from typing import Optional, Set
9+
from unicodedata import normalize
810

911
# local
1012
from .utils import validator
@@ -37,6 +39,22 @@ def check(cls, tld: str):
3739
return tld in cls._full_cache
3840

3941

42+
def _relies_on_idna_mapping(value: str):
43+
"""Return whether `value` only becomes a valid name through IDNA 2003 mapping.
44+
45+
Python's `idna` codec implements IDNA 2003 (RFC 3490), whose nameprep step
46+
(RFC 3491) drops invisible characters (RFC 3454, table B.1), folds
47+
compatibility characters such as fullwidth letters to ASCII, and treats
48+
U+3002 and similar dots as label separators. A value that needs any of
49+
these only looks like the name it encodes to, e.g. "example.com" written
50+
with fullwidth letters.
51+
"""
52+
return not value.isascii() and (
53+
normalize("NFKC", value) != value
54+
or any(in_table_b1(char) or char in "\u3002\uff0e\uff61" for char in value)
55+
)
56+
57+
4058
@validator
4159
def domain(
4260
value: str, /, *, consider_tld: bool = False, rfc_1034: bool = False, rfc_2782: bool = False
@@ -76,10 +94,15 @@ def domain(
7694
if not value:
7795
return False
7896

79-
if consider_tld and not _IanaTLD.check(value.rstrip(".").rsplit(".", 1)[-1].upper()):
80-
return False
81-
8297
try:
98+
if _relies_on_idna_mapping(value):
99+
return False
100+
101+
if value.rstrip(".").rsplit(".", 1)[-1].isdecimal():
102+
# ref: RFC 1123 (2.1) and RFC 3696 (2), a TLD is not all-numeric. An
103+
# all-digit IDN label like "\u0661" is, but encodes to a letter-final A-label.
104+
return False
105+
83106
service_record = r"_" if rfc_2782 else ""
84107
trailing_dot = r"\.?$" if rfc_1034 else r"$"
85108

@@ -88,6 +111,11 @@ def domain(
88111
# ref: RFC 1035, presentation form is limited to 253 characters
89112
return False
90113

114+
# IANA lists IDN TLDs in their ASCII (punycode) form,
115+
# so look up the encoded TLD, not the raw one.
116+
if consider_tld and not _IanaTLD.check(encoded.rstrip(".").rsplit(".", 1)[-1].upper()):
117+
return False
118+
91119
return not re.search(r"\s|__+", value) and re.match(
92120
# First character of the domain
93121
rf"^(?:[a-z0-9{service_record}]"

‎src/validators/encoding.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
def base16(value: str, /):
1212
"""Return whether or not given value is a valid base16 encoding.
1313
14+
Each encoded byte requires two hexadecimal characters.
15+
1416
Examples:
1517
>>> base16('a3f4b2')
1618
True
@@ -25,7 +27,7 @@ def base16(value: str, /):
2527
(Literal[True]): If `value` is a valid base16 encoding.
2628
(ValidationError): If `value` is an invalid base16 encoding.
2729
"""
28-
return re.fullmatch(r"^[0-9A-Fa-f]+$", value) if value else False
30+
return re.fullmatch(r"(?:[0-9A-Fa-f]{2})+", value) if value else False
2931

3032

3133
@validator
@@ -46,7 +48,15 @@ def base32(value: str, /):
4648
(Literal[True]): If `value` is a valid base32 encoding.
4749
(ValidationError): If `value` is an invalid base32 encoding.
4850
"""
49-
return re.fullmatch(r"^[A-Z2-7]+=*$", value) if value else False
51+
return (
52+
re.fullmatch(
53+
r"(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}(?:={6})?|[A-Z2-7]{4}(?:={4})?|"
54+
r"[A-Z2-7]{5}(?:={3})?|[A-Z2-7]{7}=?)?",
55+
value,
56+
)
57+
if value
58+
else False
59+
)
5060

5161

5262
@validator

‎src/validators/hostname.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,23 @@ def _simple_hostname_regex():
2626
"""Simple hostname validation regex."""
2727
# {0,59} because two characters are already matched at
2828
# the beginning and at the end, making the range {1, 61}
29-
return re.compile(r"^(?!-)[a-z0-9](?:[a-z0-9-]{0,59}[a-z0-9])?(?<!-)$", re.IGNORECASE)
29+
return re.compile(
30+
r"^(?!-)[a-z0-9](?:[a-z0-9-]{0,59}[a-z0-9])?(?<!-)$", re.IGNORECASE | re.ASCII
31+
)
3032

3133

3234
def _port_validator(value: str):
3335
"""Returns host segment if port is valid."""
3436
if value.count("]:") == 1:
3537
# with ipv6
3638
host_seg, port_seg = value.rsplit(":", 1)
37-
if _port_regex().match(f":{port_seg}"):
39+
if _port_regex().fullmatch(f":{port_seg}"):
3840
return host_seg.lstrip("[").rstrip("]")
3941

4042
if value.count(":") == 1:
4143
# with ipv4 or simple hostname
4244
host_seg, port_seg = value.rsplit(":", 1)
43-
if _port_regex().match(f":{port_seg}"):
45+
if _port_regex().fullmatch(f":{port_seg}"):
4446
return host_seg
4547

4648
return None
@@ -120,7 +122,7 @@ def _matches_simple_hostname(host: str):
120122
# hostname, not only on the dotted names handled by `domain()`.
121123
if rfc_1034 and host.endswith("."):
122124
host = host[:-1]
123-
return _simple_hostname_regex().match(host)
125+
return _simple_hostname_regex().fullmatch(host)
124126

125127
if may_have_port and (host_seg := _port_validator(value)):
126128
return (

‎src/validators/i18n/es.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ def _nif_nie_validation(value: str, number_by_letter: Dict[str, str]):
1717
# letter to the corresponding digit
1818
numbers = number_by_letter.get(value[0], value[0]) + value[1:8]
1919
# doi[8] is control
20-
return numbers.isdigit() and value[8] == table[int(numbers) % 23]
20+
return numbers.isascii() and numbers.isdigit() and value[8] == table[int(numbers) % 23]
2121

2222

2323
@validator
@@ -58,7 +58,7 @@ def es_cif(value: str, /):
5858
first_chr = value[0]
5959
doi_body = value[1:8]
6060
control = value[8]
61-
if not doi_body.isdigit():
61+
if not doi_body.isascii() or not doi_body.isdigit():
6262
return False
6363
res = (
6464
10

‎src/validators/i18n/fi.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Finland."""
22

33
# standard
4+
from datetime import date
45
from functools import lru_cache
56
import re
67

@@ -22,10 +23,10 @@ def _ssn_pattern(ssn_check_marks: str):
2223
(?P<date>(0[1-9]|[1-2]\d|3[01])
2324
(0[1-9]|1[012])
2425
(\d{{2}}))
25-
[ABCDEFYXWVU+-]
26+
(?P<century>[ABCDEFYXWVU+-])
2627
(?P<serial>(\d{{3}}))
2728
(?P<checksum>[{check_marks}])$""".format(check_marks=ssn_check_marks),
28-
re.VERBOSE,
29+
re.VERBOSE | re.ASCII,
2930
)
3031

3132

@@ -95,6 +96,9 @@ def fi_ssn(value: str, /, *, allow_temporal_ssn: bool = True):
9596
if not (result := re.fullmatch(_ssn_pattern(ssn_check_marks), value)):
9697
return False
9798
gd = result.groupdict()
99+
century = 1800 if gd["century"] == "+" else 2000 if gd["century"] in "ABCDEF" else 1900
100+
birth_date = gd["date"]
101+
date(century + int(birth_date[4:]), int(birth_date[2:4]), int(birth_date[:2]))
98102
checksum = int(gd["date"] + gd["serial"])
99103
return (
100104
int(gd["serial"]) >= 2

‎src/validators/i18n/fr.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def _ssn_pattern():
2020
r"\s(\d{2,3})" # town of birth
2121
r"\s(\d{3})" # registration number
2222
r"(?:\s(\d{2}))?$", # control key (may or may not be provided)
23-
re.VERBOSE,
23+
re.VERBOSE | re.ASCII,
2424
)
2525

2626

0 commit comments

Comments
 (0)