From dc72dd4542426beaf05db64878c226dc17ae1e0e Mon Sep 17 00:00:00 2001 From: Peter Nardi Date: Sat, 21 Dec 2024 08:37:19 +0100 Subject: [PATCH] Compactor (#25) * Fixed input validation. * Code linting. * Code linting. * Built-out compact function. * Documentation and tuning 1. Documented compact function 2. Set default for compaction to 0 (no compact) * Compact function tuning. * Code tuning. * Refactoring. * Code linting. * Dependency updates. * Code linting. * Refactor and linting. * Linting. * Linting. * Documentation linting. * Code linting. * Documentation linting. --- README.md | 6 +- pyproject.toml | 2 +- samples/targets.txt | 8 +- src/banip/build.py | 64 ++++++++-------- src/banip/check.py | 9 ++- src/banip/utilities.py | 155 +++++++++++++++++++++++++++----------- src/parsers/build_args.py | 106 +++++++++++++++++++++----- uv.lock | 38 +++++----- 8 files changed, 263 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index aaf1101..a767b94 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,11 @@ cp /* ./data/geolite/ cp /ipsum.txt ./data/ipsum.txt ``` -#### Target Countries +#### Targets + +The global list of blacklisted IPs is massive. When you build a custom +blacklist with *banip*, it's carefully tailored to just the countries +you specify using a list of targets. ```shell cp ./samples/targets.txt ./data/targets.txt diff --git a/pyproject.toml b/pyproject.toml index 90ff0a6..fcafff2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "banip" version = "1.1.2" -description = "Create a custom list of band ip for specific countries" +description = "Create a list of banned IPs for specific countries" license = {file = "LICENSE"} readme = {file = "README.md", content-type = "text/markdown"} requires-python = ">=3.12,<3.13" diff --git a/samples/targets.txt b/samples/targets.txt index d8f684d..63b68f4 100644 --- a/samples/targets.txt +++ b/samples/targets.txt @@ -1,10 +1,10 @@ # Use two-letter country codes, one per line. Either uppercase or # lowercase is fine. Blank lines, spaces, and lines starting with '#' # are ignored. NOTE: These are the ISO-3166 ALPHA2 codes, not the -# two-letter Top Level Domain names. For example, the two-letter TLD for -# United Kingdom is "uk", but the ISO-3166 code for United Kingdom is -# "gb". You can find a list of all the codes here: -# https://www.geonames.org/countries/ +# two-letter Top Level Domain names (which may be different). For +# example, the two-letter TLD for the United Kingdom is "uk", but the +# ISO-3166 code for United Kingdom is "gb". You can find a list of all +# the codes here: https://www.geonames.org/countries/ # Norway no diff --git a/src/banip/build.py b/src/banip/build.py index de78196..31a61a9 100755 --- a/src/banip/build.py +++ b/src/banip/build.py @@ -2,12 +2,12 @@ """Build a custom list of banned IPs.""" -import ipaddress as ipa import shutil import sys from argparse import Namespace from datetime import datetime as dt from pathlib import Path +from typing import cast from rich import box from rich.console import Console @@ -25,10 +25,11 @@ from banip.constants import RENDERED_BLACKLIST from banip.constants import TARGETS from banip.constants import AddressType -from banip.constants import NetworkType +from banip.utilities import compact from banip.utilities import extract_ip from banip.utilities import ip_in_network from banip.utilities import load_ipsum +from banip.utilities import split_hybrid from banip.utilities import tag_networks @@ -84,23 +85,14 @@ def task_runner(args: Namespace) -> None: # ------------------------------------------------------------------ # Load the custom blacklist and split it into separate lists of - # networks and addresses. Remove any duplicates using sets. + # addresses and networks. Remove any duplicates using sets. console = Console() msg = "Pruning custom blacklist" with console.status(msg): with open(CUSTOM_BLACKLIST, "r") as f: - custom: list[AddressType | NetworkType] = [ - ip for line in f if (ip := extract_ip(line.strip())) - ] - custom_nets = sorted( - list({token for token in custom if isinstance(token, NetworkType)}), - key=lambda x: int(x.network_address), - ) + custom = {item for line in f if (item := extract_ip(line.strip()))} + custom_ips, custom_nets = split_hybrid(list(custom)) custom_nets_size = len(custom_nets) - custom_ips = sorted( - list({token for token in custom if isinstance(token, AddressType)}), - key=lambda x: int(x), - ) # Remove any custom IPs that are covered by existing custom # subnets custom_ips = [ @@ -124,9 +116,8 @@ def task_runner(args: Namespace) -> None: for line in f if (token := line.strip()) and token[0] != "#" ] - target_geolite = sorted( - [net for net in geolite_D if geolite_D[net] in countries], - key=lambda x: int(x.network_address), + _, target_geolite = split_hybrid( + [net for net in geolite_D if geolite_D[net] in countries] ) target_geolite_size = len(target_geolite) print(f"{msg:.<{PAD}}done") @@ -147,17 +138,12 @@ def task_runner(args: Namespace) -> None: # are not in the custom whitelist. msg = "Pruning ipsum.txt" with console.status(msg): - whitelist: list[AddressType] = [] with open(CUSTOM_WHITELIST, "r") as f: - for line in f: - try: - ip = ipa.ip_address(line.strip()) - whitelist.append(ip) - except ValueError: - continue - + whitelist = [ + cast(AddressType, ip) for line in f if (ip := extract_ip(line.strip())) + ] ipsum_D = load_ipsum() - ipsum_L: list[AddressType] = [ + ipsum_L = [ ip for ip in ipsum_D if ( @@ -174,8 +160,19 @@ def task_runner(args: Namespace) -> None: and ipsum_D[ip] >= args.threshold ) ] - ipsum_L = sorted(ipsum_L, key=lambda x: int(x)) - ipsum_size = len(ipsum_L) + print(f"{msg:.<{PAD}}done") + + # ------------------------------------------------------------------ + + # Compact ipsum. A compact factor of 0 indicates no compaction. + msg = f"Compacting ipsum ({args.compact})" + with console.status(msg): + ipsum_ips, ipsum_nets = compact( + ip_list=ipsum_L, whitelist=whitelist, min_num=args.compact + ) + ipsum_ips_size = len(ipsum_ips) + ipsum_nets_size = len(ipsum_nets) + ipsum_size = ipsum_ips_size + ipsum_nets_size print(f"{msg:.<{PAD}}done") # ------------------------------------------------------------------ @@ -189,7 +186,10 @@ def task_runner(args: Namespace) -> None: custom_ips = [ ip for ip in custom_ips - if ip not in ipsum_L + if ip not in ipsum_ips + and not ip_in_network( + ip=ip, networks=ipsum_nets, first=0, last=ipsum_nets_size - 1 + ) and ip_in_network( ip=ip, networks=target_geolite, first=0, last=target_geolite_size - 1 ) @@ -215,8 +215,10 @@ def task_runner(args: Namespace) -> None: msg = "Rendering blacklist" with console.status(msg): with open(RENDERED_BLACKLIST, "w") as f: - for ip in ipsum_L: + for ip in ipsum_ips: f.write(f"{ip}\n") + for net in ipsum_nets: + f.write(f"{net}\n") now = dt.now().strftime("%Y-%m-%d %H:%M:%S") f.write("\n# ------------custom entries -------------\n") f.write(f"# Added on: {now}\n") @@ -239,7 +241,7 @@ def task_runner(args: Namespace) -> None: table.add_column(header="Value", justify="right") table.add_row("Target Countries", f"{",".join(countries)}") - table.add_row("Blacklist IPs from ipsum.txt", f"{(ipsum_size):,d}") + table.add_row("Blacklist entries from ipsum.txt", f"{(ipsum_size):,d}") table.add_row("Custom blacklist IPs", f"{(custom_ips_size):,d}") table.add_row("Custom blacklist subnets", f"{(custom_nets_size):,d}") table.add_row("Total entries saved", f"{(total_size):,d}") diff --git a/src/banip/check.py b/src/banip/check.py index 62a55a3..8f0ae66 100644 --- a/src/banip/check.py +++ b/src/banip/check.py @@ -14,6 +14,7 @@ from banip.utilities import ip_in_network from banip.utilities import load_ipsum from banip.utilities import load_rendered_blacklist +from banip.utilities import split_hybrid def task_runner(args: argparse.Namespace) -> None: @@ -34,7 +35,7 @@ def task_runner(args: argparse.Namespace) -> None: if not COUNTRY_NETS_DICT.exists(): msg = """ Some required files are missing. Make sure to build the - databases before checking for a particular ip address. Run + databases before checking for a particular IP address. Run \'banip build -h\' for more information. """ print(textwrap.fill(text=" ".join(msg.split()))) @@ -53,7 +54,7 @@ def task_runner(args: argparse.Namespace) -> None: # Load rendered blacklist msg = "Loading rendered blacklist" with console.status(msg): - rendered_nets, rendered_ips = load_rendered_blacklist() + rendered_ips, rendered_nets = load_rendered_blacklist() print(f"{msg:.<{PAD}}done") # Start building the table @@ -62,13 +63,13 @@ def task_runner(args: argparse.Namespace) -> None: table.add_column(header="Result", justify="right") # Load the HAProxy countries dictionary, arrange sorted keys, and - # locate the two-letter country code for target ip. + # locate the two-letter country code for target IP. msg = "Finding country of origin" attribute = "Country Code" with console.status(msg): with open(COUNTRY_NETS_DICT, "rb") as f: nets_D = pickle.load(f) - nets_L = sorted(nets_D.keys(), key=lambda x: int(x.network_address)) + _, nets_L = split_hybrid(nets_D.keys()) if located_net := ip_in_network( ip=target, networks=nets_L, first=0, last=len(nets_L) - 1 ): diff --git a/src/banip/utilities.py b/src/banip/utilities.py index b490490..d842688 100644 --- a/src/banip/utilities.py +++ b/src/banip/utilities.py @@ -20,6 +20,90 @@ # ====================================================================== +def split_hybrid( + hybrid_list: list[AddressType | NetworkType], +) -> tuple[list[AddressType], list[NetworkType]]: + """Split a heterogeneous list of IPs and Networks. + + Parameters + ---------- + hybrid_list : list[AddressType | NetworkType] + A list containing a mix of both IPs and/or Networks + + Returns + ------- + tuple[list[AddressType], list[NetworkType]] + Two separate, sorted lists in a tuple. The first containing only + IPs, and the second containing only networks. + """ + ips = sorted( + [ip for ip in hybrid_list if isinstance(ip, AddressType)], + key=lambda x: int(x), + ) + nets = sorted( + [net for net in hybrid_list if isinstance(net, NetworkType)], + key=lambda x: int(x.network_address), + ) + return ips, nets + + +# ====================================================================== + + +def compact( + ip_list: list[AddressType], whitelist: list[AddressType], min_num: int +) -> tuple[list[AddressType], list[NetworkType]]: + """Compact IP addresses into representative Class-C subnets. + + Parameters + ---------- + ip_list : list[AddressType] + A list of IP addresses to compact - usually the filtered ipsum + data. + whitelist : list[AddressType] + A list of whitelisted IPs. Need to ensure that a collapsed + subnet does not include a whitelisted IP. + min_num : int + The minimum number of IPs required before the group is collapsed + into a /24 subnet. + + Returns + ------- + tuple[list[AddressType], list[NetworkType]] + Separate lists of IP addresses and /24 subnets. + """ + compacted: list[AddressType | NetworkType] = [] + D: dict[NetworkType, set[AddressType]] = {} + + # 0 indicates no compaction desired. Return the original list, + # sorted. + if min_num == 0: + return sorted(ip_list, key=lambda x: int(x)), [] + + # Build a dictionary of subnets for every group of IPs in the list. + for ip in ip_list: + network = ipa.ip_network(f"{ip}/24", strict=False) + if network in D: + D[network].add(ip) + else: + D[network] = {ip} + + # Create a new hybrid list representing IP addresses for groups + # containing less than the min_num of members, and /24 subnets for + # groups sized >= min_num. + for net, ips in D.items(): + if len(ips) >= min_num and not any([ip in net for ip in whitelist]): + compacted.append(net) + else: + compacted += list(ips) + + # Return separate, sorted lists of IP addresses and subnets. + return split_hybrid(compacted) + + +# ====================================================================== + + def extract_ip(from_str: str) -> AddressType | NetworkType | None: """Convert a string to either an IP address or IP subnet. @@ -35,6 +119,7 @@ def extract_ip(from_str: str) -> AddressType | NetworkType | None: The formated ipaddress object. """ to_ip: AddressType | NetworkType | None = None + try: if "/" in from_str: to_ip = ipa.ip_network(from_str) @@ -42,6 +127,7 @@ def extract_ip(from_str: str) -> AddressType | NetworkType | None: to_ip = ipa.ip_address(from_str) except ValueError: return None + return to_ip @@ -85,45 +171,29 @@ def tag_networks() -> dict[NetworkType, str]: # Lines in the IPv4 country blocks file look like this: # 1.47.160.0/19,1605651,1605651,,0,0, - # The variable "net" will hold each line of the file, and the code - # we're looking for is normally in index 1 (starting from 0). If - # that entry is blank, use the code in index 2. Index 0 contains the - # IP address. - msg = "Geotagging IPv4 Networks" - with console.status(msg): - with open(GEOLITE_4, "r") as f: - reader = csv.reader(f) - next(reader) - for net in reader: - try: - country_id = countries[int(net[1])] - except ValueError: - country_id = countries[int(net[2])] - networks[ipa.IPv4Network(net[0])] = country_id - print(f"{msg:.<{PAD}}done") - # Lines in the IPv6 country blocks file look like this: # 2001:67c:299c::/48,2921044,2921044,,0,0, # The variable "net" will hold each line of the file, and the code # we're looking for is normally in index 1 (starting from 0). If # that entry is blank, use the code in index 2. Index 0 contains the # IP address. - msg = "Geotagging IPv6 Networks" + msg = "Geotagging Networks" with console.status(msg): - with open(GEOLITE_6, "r") as f: - reader = csv.reader(f) - next(reader) - for net in reader: - try: - country_id = countries[int(net[1])] - except ValueError: - country_id = countries[int(net[2])] - networks[ipa.IPv6Network(net[0])] = country_id + for geolite_file in [GEOLITE_4, GEOLITE_6]: + with open(geolite_file, "r") as f: + reader = csv.reader(f) + next(reader) + for net in reader: + try: + country_id = countries[int(net[1])] + except ValueError: + country_id = countries[int(net[2])] + networks[ipa.ip_network(net[0])] = country_id print(f"{msg:.<{PAD}}done") msg = "Generating build products" with console.status(msg): - keys = sorted(list(networks.keys()), key=lambda x: int(x.network_address)) + _, keys = split_hybrid(list(networks.keys())) with open(COUNTRY_NETS_TXT, "w") as f: for key in keys: f.write(f"{format(key)} {networks[key]}\n") @@ -160,18 +230,20 @@ def ip_in_network( Returns ------- NetworkType | None - If ip is in one of the networks in the list, then return the + If IP is in one of the networks in the list, then return the network containing it; if not, return None. """ if first > last: return None + mid = (first + last) // 2 ip_int = int(ip) - network_address = int(networks[mid].network_address) - broadcast_address = int(networks[mid].broadcast_address) - if ip_int >= network_address and ip_int <= broadcast_address: + inner = int(networks[mid].network_address) + outer = int(networks[mid].broadcast_address) + + if ip_int >= inner and ip_int <= outer: return networks[mid] - if ip_int < network_address: + if ip_int < inner: return ip_in_network(ip, networks, first, mid - 1) return ip_in_network(ip, networks, mid + 1, last) @@ -197,10 +269,11 @@ def load_ipsum() -> dict[AddressType, int]: except (ValueError, NameError): continue ipsum[ip] = hits + return ipsum -def load_rendered_blacklist() -> tuple[list[NetworkType], list[AddressType]]: +def load_rendered_blacklist() -> tuple[list[AddressType], list[NetworkType]]: """Load the contents of the rendered blacklist Separate it into separate, sorted lists of Networks and IPs @@ -211,15 +284,5 @@ def load_rendered_blacklist() -> tuple[list[NetworkType], list[AddressType]]: The rendered blacklist split into Networks and IPs """ with open(RENDERED_BLACKLIST, "r") as f: - rendered: list[AddressType | NetworkType] = [ - token for line in f if (token := extract_ip(line.strip())) - ] - rendered_nets = sorted( - [token for token in rendered if isinstance(token, NetworkType)], - key=lambda x: int(x.network_address), - ) - rendered_ips = sorted( - [token for token in rendered if isinstance(token, AddressType)], - key=lambda x: int(x), - ) - return (rendered_nets, rendered_ips) + rendered = [token for line in f if (token := extract_ip(line.strip()))] + return split_hybrid(rendered) diff --git a/src/parsers/build_args.py b/src/parsers/build_args.py index de553a2..7869c5c 100644 --- a/src/parsers/build_args.py +++ b/src/parsers/build_args.py @@ -1,11 +1,80 @@ """Argument parser for build command.""" -import argparse +from argparse import ArgumentTypeError +from argparse import FileType from argparse import _SubParsersAction COMMAND_NAME = "build" +def threshold_type(x: str) -> int: + """Validate the threshold input. + + Parameters + ---------- + x : str + User input for the threshold option. + + Returns + ------- + int + The validated user input. + + Raises + ------ + argparse.ArgumentTypeError + If the user input is not an integer type + argparse.ArgumentTypeError + If the user input is not within the acceptable range [1,10] + """ + try: + x_int = int(x) + except ValueError: + raise ArgumentTypeError("Threshold must be an integer") + + if x_int not in range(1, 11): + raise ArgumentTypeError("Threshold must be between 1 and 10") + + return x_int + + +# ====================================================================== + + +def compact_type(x: str) -> int: + """Validate the compact input. + + Parameters + ---------- + x : str + User input for the compact option. + + Returns + ------- + int + The validated user input. + + Raises + ------ + argparse.ArgumentTypeError + If the user input is not an integer type + argparse.ArgumentTypeError + If the user input is not within the acceptable range [1,255] + """ + try: + x_int = int(x) + except ValueError: + raise ArgumentTypeError("Compact must be an integer") + + if x_int not in range(1, 256): + raise ArgumentTypeError("Compact must be between 1 and 255") + + return x_int + + +# ====================================================================== + + def load_command_args(sp: _SubParsersAction) -> None: """Assemble the argument parser.""" msg = """ @@ -13,23 +82,14 @@ def load_command_args(sp: _SubParsersAction) -> None: with a proxy server (like HAProxy) to block network access from those clients. """ - parser = sp.add_parser( - name=COMMAND_NAME, - help=msg, - description=msg, - ) + parser = sp.add_parser(name=COMMAND_NAME, help=msg, description=msg) msg = """ Output file that will contain the generated list of blacklisted IP addresses. If not provided, results will be saved to ./data/ip_blacklist.txt """ - parser.add_argument( - "-o", - "--outfile", - type=argparse.FileType("w"), - help=msg, - ) + parser.add_argument("-o", "--outfile", type=FileType("w"), help=msg) msg = """ Each banned IP address in the source database has a factor (from 1 @@ -39,13 +99,21 @@ def load_command_args(sp: _SubParsersAction) -> None: but you may choose any threshold from 1 to 10. If you find you're getting false positives, just re-run banip with a higher threshold. """ - parser.add_argument( - "-t", - "--threshold", - type=int, - help=msg, - default=3, - ) + parser.add_argument("-t", "--threshold", type=threshold_type, help=msg, default=3) + + msg = """ + The ipsum.txt file contains many entries which all reside in the + same Class-C subnet (i.e. /24). Compacting those entries into a + single /24 subnet can significantly reduce the size of the + blacklist. COMPACT is an integer from 1 to 255 indicating how many + IP addresses from the single /24 subnet need to be present before + they're compacted. Smaller numbers create a smaller (more compact) + blacklist. NOTE: compacting the blacklist can result in + overblocking. For example, by compacting several IP addresses into + something like 45.78.4.0/24, you may block some benign IPs within + the same range that were not explicitly in your blacklist. + """ + parser.add_argument("-c", "--compact", type=compact_type, help=msg, default=0) return diff --git a/uv.lock b/uv.lock index da07c4e..48dddec 100644 --- a/uv.lock +++ b/uv.lock @@ -99,27 +99,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.8.3" +version = "0.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522 } +sdist = { url = "https://files.pythonhosted.org/packages/34/37/9c02181ef38d55b77d97c68b78e705fd14c0de0e5d085202bb2b52ce5be9/ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8", size = 3402103 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860 }, - { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327 }, - { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585 }, - { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597 }, - { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244 }, - { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439 }, - { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538 }, - { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172 }, - { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886 }, - { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599 }, - { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637 }, - { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591 }, - { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298 }, - { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965 }, - { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651 }, - { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289 }, - { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, + { url = "https://files.pythonhosted.org/packages/05/67/f480bf2f2723b2e49af38ed2be75ccdb2798fca7d56279b585c8f553aaab/ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60", size = 10546415 }, + { url = "https://files.pythonhosted.org/packages/eb/7a/5aba20312c73f1ce61814e520d1920edf68ca3b9c507bd84d8546a8ecaa8/ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac", size = 10346113 }, + { url = "https://files.pythonhosted.org/packages/76/f4/c41de22b3728486f0aa95383a44c42657b2db4062f3234ca36fc8cf52d8b/ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296", size = 9943564 }, + { url = "https://files.pythonhosted.org/packages/0e/f0/afa0d2191af495ac82d4cbbfd7a94e3df6f62a04ca412033e073b871fc6d/ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643", size = 10805522 }, + { url = "https://files.pythonhosted.org/packages/12/57/5d1e9a0fd0c228e663894e8e3a8e7063e5ee90f8e8e60cf2085f362bfa1a/ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e", size = 10306763 }, + { url = "https://files.pythonhosted.org/packages/04/df/f069fdb02e408be8aac6853583572a2873f87f866fe8515de65873caf6b8/ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3", size = 11359574 }, + { url = "https://files.pythonhosted.org/packages/d3/04/37c27494cd02e4a8315680debfc6dfabcb97e597c07cce0044db1f9dfbe2/ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f", size = 12094851 }, + { url = "https://files.pythonhosted.org/packages/81/b1/c5d7fb68506cab9832d208d03ea4668da9a9887a4a392f4f328b1bf734ad/ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604", size = 11655539 }, + { url = "https://files.pythonhosted.org/packages/ef/38/8f8f2c8898dc8a7a49bc340cf6f00226917f0f5cb489e37075bcb2ce3671/ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf", size = 12912805 }, + { url = "https://files.pythonhosted.org/packages/06/dd/fa6660c279f4eb320788876d0cff4ea18d9af7d9ed7216d7bd66877468d0/ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720", size = 11205976 }, + { url = "https://files.pythonhosted.org/packages/a8/d7/de94cc89833b5de455750686c17c9e10f4e1ab7ccdc5521b8fe911d1477e/ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae", size = 10792039 }, + { url = "https://files.pythonhosted.org/packages/6d/15/3e4906559248bdbb74854af684314608297a05b996062c9d72e0ef7c7097/ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7", size = 10400088 }, + { url = "https://files.pythonhosted.org/packages/a2/21/9ed4c0e8133cb4a87a18d470f534ad1a8a66d7bec493bcb8bda2d1a5d5be/ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111", size = 10900814 }, + { url = "https://files.pythonhosted.org/packages/0d/5d/122a65a18955bd9da2616b69bc839351f8baf23b2805b543aa2f0aed72b5/ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8", size = 11268828 }, + { url = "https://files.pythonhosted.org/packages/43/a9/1676ee9106995381e3d34bccac5bb28df70194167337ed4854c20f27c7ba/ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835", size = 8805621 }, + { url = "https://files.pythonhosted.org/packages/10/98/ed6b56a30ee76771c193ff7ceeaf1d2acc98d33a1a27b8479cbdb5c17a23/ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d", size = 9660086 }, + { url = "https://files.pythonhosted.org/packages/13/9f/026e18ca7d7766783d779dae5e9c656746c6ede36ef73c6d934aaf4a6dec/ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08", size = 9074500 }, ] [[package]]