mirror of
https://github.com/j93es/oauth-backend.git
synced 2026-06-04 03:21:51 +09:00
Compare commits
82 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3018b4fd23 |
||
|
|
27e7a290ba | ||
|
|
a4b14ab20f | ||
|
|
8e0523e734 | ||
|
|
7d378fa91f | ||
|
|
905db47d8a | ||
|
|
182ea21178 | ||
|
61e4ed6119 |
|||
|
|
8ef13de441 | ||
|
|
9898f215f3 | ||
|
|
a3b54028b7 |
||
|
|
e2ee91034d | ||
|
|
1a97b9d403 |
||
|
|
cf5746685a | ||
|
c8815f3f28 |
|||
|
|
a1758a60d4 | ||
|
|
4758d7a689 |
||
| 87d5b0209c | |||
| 5edab9244c | |||
|
|
949b156f19 |
||
|
|
c20bcdebf3 | ||
|
|
6e5c37423c |
||
|
|
0d81fdd49f | ||
|
|
00c81f365a |
||
|
|
58d5deb435 | ||
|
|
05a095df7d | ||
|
|
4deb032708 | ||
|
|
3c5db3c1fd | ||
|
|
53db0fb14e | ||
|
|
3a1422a2f2 | ||
|
|
062552d3d8 | ||
|
|
afcfd7de87 |
||
|
|
1c6fc53a81 | ||
|
|
6dceba0c24 | ||
|
|
0bee707406 |
||
| 69622e4648 | |||
|
e063dadb72 |
|||
| 897173ba46 | |||
| c511b3bfd7 | |||
|
9071ed11b7 |
|||
|
5d1624a96a |
|||
|
ba277ccec1 |
|||
|
|
3af5787064 | ||
|
|
0c7994a52f | ||
|
|
9a14872964 | ||
|
|
b221c4a9e6 | ||
|
|
990eb1b643 | ||
|
|
c593a92b11 | ||
|
|
cf3bfee039 | ||
|
|
32efcbe1a0 | ||
|
|
3850b0de2f | ||
|
|
00e3958300 | ||
|
|
40867acb26 | ||
|
|
c311aaad71 | ||
|
|
05bbdc65c1 | ||
|
1b3f58b432 |
|||
|
99fc280517 |
|||
|
12d0ed73ff |
|||
|
db514172dc |
|||
|
|
ba6064c378 | ||
|
|
57625307a7 | ||
|
|
ef61667cfe | ||
|
30e2730cb1 |
|||
|
5b2dec4db8 |
|||
|
|
7ac749fa36 | ||
|
|
0be13ec5f2 | ||
|
|
367a7156bf | ||
|
|
aa8bf95a5c | ||
|
59b3d7d9d2 |
|||
|
|
31ca96f037 | ||
|
|
eda0c5a679 |
||
|
|
34c547c1b1 | ||
|
|
69268f0a9a |
||
|
|
4f6f2519b3 | ||
|
39d2e7906c |
|||
|
|
798d437a80 | ||
|
|
5fe33564d6 | ||
|
|
e91b2738e1 |
||
|
|
723e14c314 | ||
|
909f5006bd |
|||
|
|
0d09f191c5 | ||
|
|
fcc0b6d2f3 |
26 changed files with 3332 additions and 273 deletions
2
.env
Normal file
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Google OAuth 설정
|
||||
GOOGLE_ID=whs.imnya.ng@gmail.com
|
||||
52
.github/workflows/ci.yml
vendored
Normal file
52
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.13]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "uv.lock"
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Set up environment variables
|
||||
run: |
|
||||
echo "GOOGLE_ID=bot.imnya.ng@gmail.com" > .env
|
||||
|
||||
- name: Start application and run proxy test
|
||||
run: |
|
||||
# Start the application in background
|
||||
uv run main.py &
|
||||
APP_PID=$!
|
||||
|
||||
# Wait for application to start
|
||||
sleep 5
|
||||
|
||||
# Test proxy functionality
|
||||
sudo cp ~/.mitmproxy/mitmproxy-ca-cert.pem /usr/local/share/ca-certificates/mitmproxy-ca-cert.crt
|
||||
sudo update-ca-certificates
|
||||
|
||||
mkdir data
|
||||
echo https://github.com > ./data/target.dump
|
||||
curl -k -x https://localhost:11080 "https://github.com/login/oauth/authorize?client_id=Ov23lixietSCQOHxPvcr&redirect_uri=http://localhost:8787&scope=read:user+user:email&response_type=code&code_challenge=abc123&code_challenge_method=S256"
|
||||
|
||||
# Clean up
|
||||
kill $APP_PID
|
||||
69
.gitignore
vendored
69
.gitignore
vendored
|
|
@ -9,4 +9,71 @@ wheels/
|
|||
# Virtual environments
|
||||
.venv
|
||||
|
||||
data/
|
||||
.env
|
||||
|
||||
data/
|
||||
|
||||
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/macos,windows
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/macos,windows
|
||||
80
README.md
80
README.md
|
|
@ -1,5 +1,7 @@
|
|||
# 환경 설정
|
||||
|
||||
## Python Virtual Environment
|
||||
|
||||
이 프로젝트는 [uv](https://docs.astral.sh/uv/getting-started/installation/)라는 Python 패키지 관리자를 사용하여 설정해야합니다.
|
||||
|
||||
uv 설치 후 다음과 같은 명령어를 입력합니다.
|
||||
|
|
@ -8,8 +10,33 @@ uv 설치 후 다음과 같은 명령어를 입력합니다.
|
|||
uv sync
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
venv와 패키지가 설치가 됩니다.
|
||||
|
||||
.env.example을 복사하여 .env를 붙여넣습니다.
|
||||
|
||||
`GOOGLE_ID=`에 봇에서 쓸 구글 계정의 전체 GMail을 기입합니다.
|
||||
|
||||
입력하지 않는다면 Google OAuth시 자동적으로 넘어가지 않을 수도 있습니다.
|
||||
|
||||
---
|
||||
|
||||
> [oauth-backend](https://github.com/j93es/oauth-backend) 프록시를 사용한다면 이 가이드에 따라 인증서 또한 설정되어야만 합니다.
|
||||
>
|
||||
> 그렇지 않으면 실행되지 않습니다.
|
||||
>
|
||||
> 윈도우 환경에서는 `sudo certutil -addstore root mitmproxy-ca-cert.cer`로 인증합니다.
|
||||
>
|
||||
> Sudo가 활성화되어있지 않은 환경에서는 관리자로 상향된 쉘에서 실행합니다.
|
||||
>
|
||||
> MacOS 환경에서는 `sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem`으로 인증합니다.
|
||||
>
|
||||
> 다른 플렛폼은 수동으로 설정되어야만 합니다.
|
||||
> https://docs.mitmproxy.org/stable/concepts/certificates/
|
||||
|
||||
---
|
||||
|
||||
# 실행 방법
|
||||
|
||||
```
|
||||
|
|
@ -17,44 +44,51 @@ uv run main.py
|
|||
```
|
||||
|
||||
이러면 http(s)://localhost:11080로 서버가 열리게 됩니다.
|
||||
http://localhost:11081로 백엔드 서버가 열리게 됩니다.
|
||||
|
||||
# 기여 방법
|
||||
|
||||
`./addon/init.py`
|
||||
|
||||
```py
|
||||
from example_check import Example
|
||||
|
||||
class LoggerAddon:
|
||||
def __init__(self):
|
||||
self.checker = Example()
|
||||
|
||||
def request(self, flow: http.HTTPFlow): # 비동기가 필요할 경우 async def로 할 것
|
||||
self.checker.test(flow)
|
||||
def response(self, flow: http.HTTPFlow): # 비동기가 필요할 경우 async def로 할 것
|
||||
self.checker.test(flow)
|
||||
...
|
||||
async def request(self, flow: http.HTTPFlow):
|
||||
if false_true_varifing_task.is_verifing_false_true():
|
||||
return
|
||||
|
||||
tasks = [
|
||||
try_catch(self.google_login_hint.request(flow)) if self.google_login_hint else None,
|
||||
try_catch(PKCEDowngradeChecker().test(flow)),
|
||||
try_catch(Example().test(flow))
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
...
|
||||
```
|
||||
|
||||
`./addon/example.py`
|
||||
|
||||
```py
|
||||
import lib.target as target
|
||||
from lib.report import save_report
|
||||
from lib.report_vuln import report_vuln
|
||||
|
||||
class Example:
|
||||
async def test(self, flow):
|
||||
req = flow.request
|
||||
method = req.method
|
||||
url = req.pretty_url
|
||||
|
||||
# data/report.csv에 저장
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': "CRITICAL",
|
||||
'title': "PKCE Downgrade Vulnerability",
|
||||
'description': "PKCE downgrade vulnerability detected! Both URLs returned authorization code.",
|
||||
'uri': f"Original: {url}\nDowngraded: {downgraded_url}"
|
||||
}]
|
||||
save_report(report_data)
|
||||
report_vuln(
|
||||
title="PKCE Plain Method",
|
||||
desc="PKCE method is set to 'plain'. Possible downgrade.",
|
||||
status="CRITICAL",
|
||||
uri=url,
|
||||
)
|
||||
```
|
||||
|
||||
이러한 예제를 참고하여 작성하여주세요.
|
||||
이러한 예제를 참고하여 작성하여주세요.
|
||||
|
||||
# 백엔드 API DOCS
|
||||
|
||||
`uv run main.py`으로 백엔드를 실행한 후에, 다음의 url에 접속합니다.
|
||||
|
||||
```
|
||||
http://localhost:11081/redoc
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import lib.target as target
|
||||
from lib.report import save_report
|
||||
|
||||
class ScopeDetection:
|
||||
def get_scope_from_query(self, query: str) -> str | None:
|
||||
if not query:
|
||||
return None
|
||||
import urllib.parse
|
||||
parsed = urllib.parse.parse_qs(query)
|
||||
scope_values = parsed.get("scope", [])
|
||||
if scope_values:
|
||||
return scope_values[0]
|
||||
return None
|
||||
|
||||
async def check_scope(self, flow):
|
||||
req = flow.request
|
||||
res = flow.response
|
||||
|
||||
# req.query가 MultiDictView일 수 있으므로 문자열로 변환
|
||||
if hasattr(req.query, "urlencode"):
|
||||
query = req.query.urlencode()
|
||||
else:
|
||||
query = str(req.query) if req.query else ""
|
||||
|
||||
location = res.headers.get("location", "")
|
||||
|
||||
query_scope = self.get_scope_from_query(query)
|
||||
location_scope = self.get_scope_from_query(location)
|
||||
|
||||
result = []
|
||||
if query_scope in ["all", "*"]:
|
||||
result.append(f"Scope value issue detected in request: {query_scope}")
|
||||
if location_scope in ["all", "*"]:
|
||||
result.append(f"Scope value issue detected in response location: {location_scope}")
|
||||
|
||||
return result if result else 0
|
||||
|
||||
async def test(self, flow):
|
||||
req = flow.request
|
||||
method = req.method
|
||||
url = req.pretty_url
|
||||
|
||||
result = await self.check_scope(flow)
|
||||
|
||||
if result != 0:
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': "WARNING",
|
||||
'title': "OAuth scope value issue",
|
||||
'description': f"{method} {url}: {', '.join(result)}",
|
||||
'uri': url
|
||||
}]
|
||||
save_report(report_data)
|
||||
151
addon/access_token.py
Normal file
151
addon/access_token.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import re
|
||||
import asyncio
|
||||
|
||||
from typing import Optional, Any
|
||||
from mitmproxy.http import HTTPFlow
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from lib.report_vuln import report_vuln
|
||||
|
||||
|
||||
# 요청/응답에서 액세스 토큰 노출 여부를 검사하는 스캐너
|
||||
class AccessTokenScanner:
|
||||
|
||||
async def scan(self, flow: HTTPFlow) -> None:
|
||||
"""단일 HTTPFlow(request + response)에 대해 요청과 응답을 모두 검사."""
|
||||
print(f"[TOKENDEBUG] Request URL: {flow.request.url}")
|
||||
|
||||
async_gather = []
|
||||
async_gather.append(self._scan_request(flow.request))
|
||||
async_gather.append(self._scan_response(flow.response, flow.request.url))
|
||||
await asyncio.gather(*async_gather)
|
||||
|
||||
# 내부 구현
|
||||
async def _scan_request(self, request: Any):
|
||||
|
||||
print("[TOKENDEBUG] ==scan request==")
|
||||
# URL 검사
|
||||
if self._is_implicit_flow(request.url):
|
||||
print("[TOKENDEBUG] OAuth Implicit Flow detected.")
|
||||
report_vuln(
|
||||
title="Token Leak in Request URL",
|
||||
desc="취약한 Grant Type입니다 (Implicit Grant Type)",
|
||||
status="MEDIUM",
|
||||
uri=request.url
|
||||
)
|
||||
|
||||
# Body 검사 (텍스트 컨텐츠인 경우)
|
||||
if request.content:
|
||||
body_text = request.get_text(strict=False)
|
||||
token_result = self._extract_token(body_text)
|
||||
if token_result:
|
||||
report_vuln(
|
||||
title="Token Leak in Request Body",
|
||||
desc=f"요청 본문에 토큰이 포함되어 있습니다 (앞 20자): {token_result[:20]}…",
|
||||
status="LOW",
|
||||
uri=request.url
|
||||
)
|
||||
|
||||
async def _scan_response(self, response: Optional[Any], request_url: str):
|
||||
if response is None:
|
||||
return
|
||||
|
||||
print("[TOKENDEBUG] ==scan response==")
|
||||
# Location 헤더 검사 (리다이렉트)
|
||||
if location_header := response.headers.get("Location"):
|
||||
token_result = self._extract_token(location_header)
|
||||
if token_result:
|
||||
report_vuln(
|
||||
title="Token Leak in Redirect URL (Location header)",
|
||||
desc=f"Location 헤더에 토큰이 노출되었습니다 (앞 20자): {token_result[:20]}…",
|
||||
status="MEDIUM",
|
||||
uri=location_header,
|
||||
)
|
||||
|
||||
# Body 검사 (텍스트 컨텐츠인 경우)
|
||||
if response.content:
|
||||
body_text = response.get_text(strict=False)
|
||||
token_result = self._extract_token(body_text)
|
||||
if token_result:
|
||||
report_vuln(
|
||||
title="Token Leak in Response Body",
|
||||
desc=f"응답 본문에 토큰이 노출되었습니다 (앞 20자): {token_result[:20]}…",
|
||||
status="LOW",
|
||||
uri=request_url,
|
||||
)
|
||||
|
||||
# 토큰 탐지 키워드드
|
||||
_TOKEN_KEYS = [
|
||||
"access_token",
|
||||
"accesstoken",
|
||||
"refresh_token",
|
||||
"refreshtoken",
|
||||
"auth_token",
|
||||
"session_token",
|
||||
]
|
||||
|
||||
# "bearer" 표시가 동시에 존재할 때만 토큰으로 판단하여 false positive를 줄임
|
||||
_TOKEN_TYPE_REGEXES = [
|
||||
re.compile(r"token[_-]?type[=:]?\s*bearer", re.IGNORECASE),
|
||||
re.compile(r"authorization\s*[:=]\s*bearer", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# 동적 컴파일: key=value / "key": "value" / Bearer <token>
|
||||
_TOKEN_PATTERNS = (
|
||||
[
|
||||
re.compile(fr"{key}[=:]\s*([A-Za-z0-9\-._~+/]{{10,}})", re.IGNORECASE)
|
||||
for key in _TOKEN_KEYS
|
||||
]
|
||||
+ [
|
||||
re.compile(fr"\"{key}\"\s*:\s*\"([^\"]{{10,}})\"", re.IGNORECASE)
|
||||
for key in _TOKEN_KEYS
|
||||
]
|
||||
+ [re.compile(r"bearer\s+([A-Za-z0-9\-._~+/]{10,})", re.IGNORECASE)]
|
||||
)
|
||||
|
||||
def _extract_token(self, text: str) -> Optional[str]:
|
||||
"""텍스트 블록에서 토큰 후보를 추출. Bearer 유형이 동반된 경우에 한정."""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
# Bearer 타입이 같이 존재하는지 미리 확인 (속도 & 정확도↑)
|
||||
has_bearer = any(rx.search(text) for rx in self._TOKEN_TYPE_REGEXES)
|
||||
|
||||
for pattern in self._TOKEN_PATTERNS:
|
||||
if (m := pattern.search(text)) and m.group(1):
|
||||
print(f"[TOKENDEBUG] token: {m.group(1)}")
|
||||
print(f"[TOKENDEBUG] has_bearer: {has_bearer}")
|
||||
if has_bearer:
|
||||
return m.group(1)
|
||||
print("[TOKENDEBUG] No matched.")
|
||||
return None
|
||||
|
||||
def _is_implicit_flow(self, request_url: str) -> bool:
|
||||
"""
|
||||
URL의 파라미터에서 OAuth Implicit Flow 패턴을 체크합니다.
|
||||
|
||||
Args:
|
||||
request_url: 체크할 요청 URL
|
||||
|
||||
Returns:
|
||||
bool: client_id, redirect_uri, response_type이 모두 존재하고
|
||||
response_type 값이 'token'인 경우 True, 그렇지 않으면 False
|
||||
"""
|
||||
try:
|
||||
parsed_url = urlparse(request_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
# 필요한 파라미터들이 모두 존재하는지 확인
|
||||
required_params = ['redirect_uri', 'response_type']
|
||||
|
||||
for param in required_params:
|
||||
if param not in query_params:
|
||||
return False
|
||||
|
||||
# response_type 값이 'token'인지 확인
|
||||
response_type_values = query_params.get('response_type', [])
|
||||
|
||||
# response_type 파라미터가 존재하고 값 중에 'token'이 있는지 확인
|
||||
return 'token' in response_type_values or 'id_token' in response_type_values
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
29
addon/client_secret.py
Normal file
29
addon/client_secret.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from lib.report_vuln import report_vuln
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
class ClientSecret:
|
||||
def get_target_from_query(self, query: str, target: str) -> str | None:
|
||||
if not query:
|
||||
return None
|
||||
parsed = parse_qs(query)
|
||||
scope_values = parsed.get(target, [])
|
||||
if scope_values:
|
||||
return scope_values[0]
|
||||
return None
|
||||
|
||||
async def test(self, flow):
|
||||
req = flow.request
|
||||
|
||||
parsed = urlparse(req.pretty_url)
|
||||
query = parsed.query
|
||||
|
||||
query_client_id = self.get_target_from_query(query, "client_id")
|
||||
query_client_secret = self.get_target_from_query(query, "client_secret")
|
||||
|
||||
if query_client_id and query_client_secret:
|
||||
report_vuln(
|
||||
title="OAuth Client Secret Exposure",
|
||||
desc=f"Client ID and Secret found in request: {query_client_id}, {query_client_secret}",
|
||||
status="CRITICAL",
|
||||
uri=req.pretty_url
|
||||
)
|
||||
169
addon/csrf_check.py
Normal file
169
addon/csrf_check.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# csrf_check.py
|
||||
from mitmproxy import http
|
||||
from urllib.parse import urlparse, parse_qs, unquote
|
||||
import httpx
|
||||
from typing import Optional, Union, List
|
||||
|
||||
from lib.report_vuln import report_vuln
|
||||
from lib.utils.is_oauth_uri import is_oauth_uri
|
||||
|
||||
class CsrfChecker:
|
||||
nonce_params = {
|
||||
"state", "nonce", "csrf_token", "csrf"
|
||||
}
|
||||
|
||||
def get_header(self, headers: http.Headers, name: str) -> Optional[str]:
|
||||
# mitmproxy Headers는 case-insensitive
|
||||
raw = headers.get(name)
|
||||
if raw is None:
|
||||
return None
|
||||
# percent-encoding 디코딩 (예: '%20' → ' ')
|
||||
return unquote(raw)
|
||||
|
||||
def get_query_param(self, uri: str, param: str) -> Optional[str]:
|
||||
return parse_qs(urlparse(uri).query).get(param, [None])[0]
|
||||
|
||||
def set_query_param(self, qs: dict, param: str, value: str) -> dict:
|
||||
new_qs = dict(qs)
|
||||
new_qs[param] = [value]
|
||||
return new_qs
|
||||
|
||||
def is_oauth_redirect(self, flow: http.HTTPFlow) -> bool:
|
||||
code = flow.response.status_code
|
||||
loc = self.get_header(flow.response.headers, "location") or ""
|
||||
return 300 <= code < 400 and is_oauth_uri(loc)
|
||||
|
||||
def check_nonce_in_request(self, flow: http.HTTPFlow) -> bool:
|
||||
qs = parse_qs(urlparse(flow.request.url).query)
|
||||
|
||||
for p in self.nonce_params:
|
||||
val = qs.get(p) # 값이 없으면 None, 있으면 리스트 혹은 단일값
|
||||
if val:
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_nonce_param(self, uri: str) -> Optional[str]:
|
||||
qs_keys = parse_qs(urlparse(uri).query).keys()
|
||||
for p in self.nonce_params:
|
||||
if p in qs_keys:
|
||||
return p
|
||||
return None
|
||||
|
||||
async def fetch_no_cookie(self, flow: http.HTTPFlow) -> httpx.Response:
|
||||
# HTTPX로 비동기 재요청: 쿠키 제외
|
||||
headers = {
|
||||
k: v for k, v in flow.request.headers.items()
|
||||
if k.lower() != "cookie"
|
||||
}
|
||||
async with httpx.AsyncClient(follow_redirects=False) as cli:
|
||||
return await cli.request(
|
||||
method=flow.request.method,
|
||||
url=flow.request.url,
|
||||
headers=headers,
|
||||
content=flow.request.get_content(),
|
||||
)
|
||||
|
||||
def check_redirect_nonce(self, flow: http.HTTPFlow) -> Union[List[str], int]:
|
||||
# ① OAuth URI, ② 요청에 nonce, ③ 리다이렉트 응답
|
||||
if not (is_oauth_uri(flow.request.url)
|
||||
and self.check_nonce_in_request(flow)
|
||||
and self.is_oauth_redirect(flow)):
|
||||
return 0
|
||||
|
||||
param = self.find_nonce_param(flow.request.url)
|
||||
orig_nonce = self.get_query_param(flow.request.url, param) if param else None
|
||||
loc = self.get_header(flow.response.headers, "location") or ""
|
||||
resp_nonce = self.get_query_param(loc, param) if param else None
|
||||
|
||||
if resp_nonce is None:
|
||||
report_vuln(
|
||||
title="CSRF Risk",
|
||||
desc="Missing nonce in redirect response",
|
||||
status="CRITICAL",
|
||||
uri=flow.request.url
|
||||
)
|
||||
return 1
|
||||
if orig_nonce != resp_nonce:
|
||||
report_vuln(
|
||||
title="CSRF Risk",
|
||||
desc="Nonce mismatch request↔response",
|
||||
status="HIGH",
|
||||
uri=flow.request.url
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
async def check_nonce_reuse(self, flow: http.HTTPFlow) -> Union[int, List[str]]:
|
||||
# OAuth Request가 아니면서, OAuth 리다이렉트 응답인 경우만 검사
|
||||
if is_oauth_uri(flow.request.url) or not self.is_oauth_redirect(flow):
|
||||
return 0
|
||||
|
||||
loc0 = self.get_header(flow.response.headers, "location") or ""
|
||||
param = self.find_nonce_param(loc0) or "state"
|
||||
qs0 = parse_qs(urlparse(loc0).query)
|
||||
orig_nonce = qs0.get(param, [None])[0]
|
||||
|
||||
# (1) 쿠키 없는 재요청 → 새 nonce
|
||||
resp_no_cookie = await self.fetch_no_cookie(flow)
|
||||
if resp_no_cookie.status_code >= 400:
|
||||
return 0
|
||||
loc1 = resp_no_cookie.headers.get("location", "")
|
||||
new_nonce = parse_qs(urlparse(loc1).query).get(param, [None])[0]
|
||||
if new_nonce is None:
|
||||
return 0
|
||||
if new_nonce == orig_nonce:
|
||||
report_vuln(
|
||||
title="CSRF Risk",
|
||||
desc="Nonce reused without cookies",
|
||||
status="CRITICAL",
|
||||
uri=flow.request.url
|
||||
)
|
||||
return 1
|
||||
|
||||
# (2) 두 번의 리다이렉트 비교
|
||||
async with httpx.AsyncClient(follow_redirects=True) as cli:
|
||||
# 원본 쿼리
|
||||
req1 = await cli.get(loc0, params=qs0, headers=flow.request.headers)
|
||||
# nonce 교체 쿼리
|
||||
qs0[param] = [new_nonce]
|
||||
req2 = await cli.get(loc0, params=qs0, headers=flow.request.headers)
|
||||
|
||||
if (
|
||||
req1.status_code == req2.status_code
|
||||
and 200 <= req1.status_code < 400
|
||||
and urlparse(req1.headers.get("location", "")).path
|
||||
== urlparse(req2.headers.get("location", "")).path
|
||||
):
|
||||
report_vuln(
|
||||
title="CSRF Risk",
|
||||
desc="Identical redirects on nonce swap → potential CSRF",
|
||||
status="NOT-VERIFIED-HIGH",
|
||||
uri=flow.request.url
|
||||
)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
async def response(self, flow: http.HTTPFlow) -> None:
|
||||
try:
|
||||
|
||||
# 1) 요청에 nonce 없으면
|
||||
if is_oauth_uri(flow.request.url) and not self.check_nonce_in_request(flow):
|
||||
report_vuln(
|
||||
title="CSRF Risk",
|
||||
desc="Missing nonce in OAuth request",
|
||||
status="CRITICAL",
|
||||
uri=flow.request.url
|
||||
)
|
||||
return
|
||||
|
||||
# 2) 리다이렉트에서 nonce 검사
|
||||
r1 = self.check_redirect_nonce(flow)
|
||||
|
||||
# 3) nonce 재사용 검사
|
||||
r2 = await self.check_nonce_reuse(flow)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] CSRF Check failed: {e}")
|
||||
return
|
||||
67
addon/google_login_hint.py
Normal file
67
addon/google_login_hint.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import os
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env 파일 로드
|
||||
load_dotenv(override=True)
|
||||
|
||||
class GoogleLoginHint:
|
||||
def __init__(self):
|
||||
self.google_id = os.getenv('GOOGLE_ID', '')
|
||||
if not self.google_id:
|
||||
print("⚠️ Warning: GOOGLE_ID not found in .env file")
|
||||
|
||||
async def request(self, flow):
|
||||
"""Google OAuth 요청을 가로채서 login_hint를 추가하거나 수정"""
|
||||
req = flow.request
|
||||
url = req.pretty_url
|
||||
|
||||
# Google OAuth 인증 URL인지 확인
|
||||
if self._is_google_oauth_url(url):
|
||||
print(f"🔍 Google OAuth URL detected: {url}")
|
||||
|
||||
# URL 파싱
|
||||
parsed_url = urlparse(url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
# login_hint 추가 또는 수정
|
||||
if self.google_id:
|
||||
query_params['login_hint'] = [self.google_id]
|
||||
print(f"✅ Added/Updated login_hint: {self.google_id}")
|
||||
|
||||
# 새로운 쿼리 스트링 생성
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
|
||||
# 새로운 URL 생성
|
||||
new_url = urlunparse((
|
||||
parsed_url.scheme,
|
||||
parsed_url.netloc,
|
||||
parsed_url.path,
|
||||
parsed_url.params,
|
||||
new_query,
|
||||
parsed_url.fragment
|
||||
))
|
||||
|
||||
# 요청 URL 수정 - URL과 호스트 모두 업데이트
|
||||
flow.request.url = new_url
|
||||
print(f"🔄 Modified URL: {new_url}")
|
||||
|
||||
def _is_google_oauth_url(self, url):
|
||||
"""Google OAuth URL인지 확인"""
|
||||
google_oauth_domains = [
|
||||
'accounts.google.com',
|
||||
'oauth2.googleapis.com'
|
||||
]
|
||||
|
||||
parsed_url = urlparse(url)
|
||||
domain = parsed_url.netloc.lower()
|
||||
|
||||
# Google OAuth 도메인 확인
|
||||
for google_domain in google_oauth_domains:
|
||||
if google_domain in domain:
|
||||
# OAuth 관련 경로 확인
|
||||
path = parsed_url.path.lower()
|
||||
if any(oauth_path in path for oauth_path in ['/oauth2', '/auth', '/login']):
|
||||
return True
|
||||
|
||||
return False
|
||||
52
addon/google_response_type_token.py
Normal file
52
addon/google_response_type_token.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from lib.report_vuln import report_vuln
|
||||
import httpx
|
||||
from lib.utils.is_oauth_uri import is_oauth_uri
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
class GoogleResponseTypeToken:
|
||||
def get_taregt_from_query(self, query: str, target: str) -> str | None:
|
||||
if not query:
|
||||
return None
|
||||
parsed = parse_qs(query)
|
||||
scope_values = parsed.get(target, [])
|
||||
if scope_values:
|
||||
return scope_values[0]
|
||||
return None
|
||||
|
||||
async def test(self, flow):
|
||||
req = flow.request
|
||||
|
||||
if not is_oauth_uri(req.pretty_url):
|
||||
return
|
||||
|
||||
if req.pretty_host != "accounts.google.com":
|
||||
return
|
||||
|
||||
if "response_type=token" in req.pretty_url:
|
||||
return
|
||||
|
||||
url = f"{req.pretty_url}".replace("response_type=code", "response_type=token")
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True) as cli:
|
||||
response = await cli.request(
|
||||
method=req.method,
|
||||
url=url,
|
||||
headers=req.headers,
|
||||
content=req.get_content(),
|
||||
)
|
||||
|
||||
|
||||
if response.status_code >= 400:
|
||||
return
|
||||
|
||||
if "<b>400.</b>" in response.text:
|
||||
return
|
||||
|
||||
if "response_type=token" in str(response.url):
|
||||
report_vuln(
|
||||
"Google Response Type Token",
|
||||
f"Response type token allowed in {req.pretty_url}",
|
||||
"HIGH",
|
||||
str(response.url)
|
||||
)
|
||||
|
||||
113
addon/init.py
113
addon/init.py
|
|
@ -1,38 +1,93 @@
|
|||
from mitmproxy import http
|
||||
import asyncio
|
||||
from pkce_check import PKCEDowngradeChecker
|
||||
from ScopeDetection import ScopeDetection
|
||||
from addon.scope_detection import ScopeDetection
|
||||
from csrf_check import CsrfChecker
|
||||
from client_secret import ClientSecret
|
||||
from addon.open_redirect_check import OpenRedirectChecker
|
||||
from access_token import AccessTokenScanner
|
||||
from addon.google_login_hint import GoogleLoginHint
|
||||
from addon.google_response_type_token import GoogleResponseTypeToken
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from lib.utils.try_catch import try_catch
|
||||
from lib.false_true_varifing_task import FalseTrueVarifingTask
|
||||
|
||||
class PKCEAddon:
|
||||
def __init__(self):
|
||||
self.checker = PKCEDowngradeChecker()
|
||||
# Initialize the singleton task manager
|
||||
false_true_varifing_task = FalseTrueVarifingTask()
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
_open_redirect_checker = OpenRedirectChecker()
|
||||
|
||||
class AddonBase:
|
||||
"""
|
||||
Base class for addons.
|
||||
Each addon should implement its own request or response method.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
if os.getenv('GOOGLE_ID'):
|
||||
self.google_login_hint = GoogleLoginHint()
|
||||
else:
|
||||
self.google_login_hint = None
|
||||
|
||||
def should_ignore(self, flow: http.HTTPFlow) -> bool:
|
||||
"""Check if the request should be ignored."""
|
||||
ignore_domains = [
|
||||
".googleapis.com",
|
||||
"android.clients.google.com", # Added missing comma here
|
||||
".adtrafficquality.google",
|
||||
".googlesyndication.com",
|
||||
"cdn.jsdelivr.net",
|
||||
"update.googleapis.com",
|
||||
".google-analytics.com",
|
||||
".gstatic.com"
|
||||
]
|
||||
# Ignore .googleapis.com domains
|
||||
for domain in ignore_domains:
|
||||
if domain in flow.request.pretty_host:
|
||||
return True
|
||||
|
||||
# Ignore static files (JS, CSS, fonts, images, etc.)
|
||||
# Split on '?' to remove query parameters before checking extension
|
||||
path = flow.request.path.split('?')[0].lower()
|
||||
static_extensions = [
|
||||
'.js', '.css', '.woff2', '.woff', '.ttf', '.otf', '.svg',
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp',
|
||||
'.tiff', '.tif', '.webm', '.mp4', '.avi', '.mov', '.pdf', '.md',
|
||||
'.txt', '.csv'
|
||||
]
|
||||
|
||||
if any(path.endswith(ext) for ext in static_extensions):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def request(self, flow: http.HTTPFlow):
|
||||
print(f"[DEBUG] Processing request: {flow.request.method} {flow.request.pretty_url}")
|
||||
try:
|
||||
await self.checker.test(flow)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Addon failed: {e}")
|
||||
|
||||
class ScopeAddon:
|
||||
def __init__(self):
|
||||
self.checker = ScopeDetection()
|
||||
self._flow_map = {} # 요청 정보를 저장
|
||||
|
||||
async def request(self, flow: http.HTTPFlow):
|
||||
self._flow_map[flow.id] = {
|
||||
"method": flow.request.method,
|
||||
"url": flow.request.pretty_url,
|
||||
"query": flow.request.query,
|
||||
}
|
||||
if self.google_login_hint:
|
||||
await try_catch(self.google_login_hint.request(flow))
|
||||
|
||||
if false_true_varifing_task.is_verifing_false_true():
|
||||
return
|
||||
|
||||
tasks = [
|
||||
try_catch(PKCEDowngradeChecker().test(flow)),
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def response(self, flow: http.HTTPFlow):
|
||||
try:
|
||||
await self.checker.test(flow)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] ScopeDetection failed: {e}")
|
||||
if false_true_varifing_task.is_verifing_false_true() or self.should_ignore(flow):
|
||||
return
|
||||
|
||||
tasks = [
|
||||
try_catch(CsrfChecker().response(flow)),
|
||||
try_catch(ScopeDetection().test(flow)),
|
||||
try_catch(ClientSecret().test(flow)),
|
||||
try_catch(AccessTokenScanner().scan(flow)),
|
||||
try_catch(GoogleResponseTypeToken().test(flow)),
|
||||
try_catch(_open_redirect_checker.test(flow)),
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
addons = [PKCEAddon(),
|
||||
ScopeAddon()
|
||||
]
|
||||
addons = [AddonBase()]
|
||||
|
|
|
|||
82
addon/nonce_check.py
Normal file
82
addon/nonce_check.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import jwt
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from typing import Union
|
||||
|
||||
from lib.report_vuln import report_vuln
|
||||
|
||||
class NonceChecker:
|
||||
def is_oidc_flow(self, flow) -> bool:
|
||||
req = flow.request
|
||||
res = flow.response
|
||||
url = req.pretty_url
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query)
|
||||
location = res.headers.get("location", "")
|
||||
content_type = res.headers.get("content-type", "")
|
||||
|
||||
if "/authorize" in url and "response_type" in query and "openid" in query.get("scope", [""])[0]:
|
||||
return True
|
||||
|
||||
if "application/json" in content_type:
|
||||
if "id_token" in res.text:
|
||||
return True
|
||||
|
||||
if res.status_code in [302, 303]:
|
||||
if isinstance(location, list):
|
||||
location = location[0]
|
||||
if "id_token=" in location:
|
||||
return True
|
||||
|
||||
if "/authorize" in url and "nonce" in query:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def extract_id_token(self, response) -> Union[str, None]:
|
||||
"""
|
||||
응답에서 id_token을 추출하는 함수.
|
||||
"""
|
||||
# 1. JSON 응답에 id_token 있음
|
||||
try:
|
||||
if "application/json" in response.headers.get("content-type", ""):
|
||||
data = response.json()
|
||||
return data.get("id_token")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Location 헤더에서 id_token 파싱 (예: #id_token=...&access_token=...)
|
||||
location = response.headers.get("location", "")
|
||||
if location:
|
||||
if "#" in location:
|
||||
fragment = location.split("#")[1]
|
||||
params = parse_qs(fragment)
|
||||
return params.get("id_token", [None])[0]
|
||||
elif "?" in location:
|
||||
query = location.split("?")[1]
|
||||
params = parse_qs(query)
|
||||
return params.get("id_token", [None])[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def decode_id_token(self, id_token: str) -> dict:
|
||||
try:
|
||||
return jwt.decode(id_token, options={"verify_signature": False})
|
||||
except Exception as e:
|
||||
return {}
|
||||
|
||||
# TODO id_token을 파싱하는 부분이 누락되어있습니다.
|
||||
def check_nonce_in_id_token(self, flow, id_token: str) -> bool:
|
||||
decoded = self.decode_id_token(id_token)
|
||||
nonce = decoded.get("nonce")
|
||||
if not nonce:
|
||||
report_vuln(
|
||||
title="Nonce Check Failed",
|
||||
desc="id_token에 nonce가 없습니다.",
|
||||
status="HIGH",
|
||||
uri=flow.request.url
|
||||
)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
1722
addon/open_redirect_check.py
Normal file
1722
addon/open_redirect_check.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,153 +1,153 @@
|
|||
# pkce_check.py
|
||||
|
||||
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
|
||||
import asyncio
|
||||
import httpx
|
||||
import csv
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
from typing import Dict, List
|
||||
from lib.report_vuln import report_vuln
|
||||
|
||||
import lib.target as target
|
||||
from lib.report import save_report
|
||||
|
||||
class PKCEDowngradeChecker:
|
||||
required_keys = ["client_id", "response_type", "code_challenge", "code_challenge_method"]
|
||||
required_keys = [
|
||||
"client_id",
|
||||
"response_type",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
]
|
||||
|
||||
async def test(self, flow):
|
||||
req = flow.request
|
||||
method = req.method
|
||||
url = req.pretty_url
|
||||
|
||||
print(f"[DEBUG] PKCE check - Method: {method}, URL: {url}")
|
||||
|
||||
if method.upper() != "GET":
|
||||
print(f"[DEBUG] Skipping non-GET request")
|
||||
if req.method.upper() != "GET":
|
||||
print("[DEBUG] Skipping non-GET request")
|
||||
return
|
||||
|
||||
url = req.pretty_url
|
||||
parsed = urlparse(url)
|
||||
query = parse_qs(parsed.query)
|
||||
|
||||
print(f"[DEBUG] Checking URL: {url}")
|
||||
print(f"[DEBUG] Query parameters: {list(query.keys())}")
|
||||
|
||||
if not all(k in query for k in self.required_keys):
|
||||
missing_keys = [k for k in self.required_keys if k not in query]
|
||||
print(f"[DEBUG] Missing required keys: {missing_keys}")
|
||||
if not self.has_required_pkce_keys(query):
|
||||
print(f"[DEBUG] Missing required keys: {self.missing_keys(query)}")
|
||||
return
|
||||
|
||||
print(f"[DEBUG] Found OAuth request with PKCE parameters")
|
||||
|
||||
is_openid = "openid" in query.get("scope", [""])[0] or "id_token" in url
|
||||
method_val = query.get("code_challenge_method", [None])[0]
|
||||
challenge_val = query.get("code_challenge", [None])[0]
|
||||
is_openid = self.is_openid_flow(query, url)
|
||||
|
||||
if not method_val or not challenge_val:
|
||||
status = "MEDIUM" if is_openid else "LOW"
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': status,
|
||||
'title': "PKCE Parameters Missing",
|
||||
'description': "PKCE parameters are missing or incomplete.",
|
||||
'uri': url
|
||||
}]
|
||||
save_report(report_data)
|
||||
await self.report_missing_parameters(url, is_openid)
|
||||
return
|
||||
|
||||
if method_val.lower() == "plain":
|
||||
status = "CRITICAL"
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': status,
|
||||
'title': "PKCE Plain Method",
|
||||
'description': "PKCE method is set to 'plain'. Possible downgrade.",
|
||||
'uri': url
|
||||
}]
|
||||
save_report(report_data)
|
||||
await self.report_plain_method(url)
|
||||
return
|
||||
|
||||
# Create downgraded request
|
||||
downgraded_url = self.create_downgraded_url(parsed, query)
|
||||
await self.compare_responses(url, downgraded_url, req.headers, is_openid)
|
||||
|
||||
def has_required_pkce_keys(self, query: Dict[str, List[str]]) -> bool:
|
||||
return all(k in query for k in self.required_keys)
|
||||
|
||||
def missing_keys(self, query: Dict[str, List[str]]) -> List[str]:
|
||||
return [k for k in self.required_keys if k not in query]
|
||||
|
||||
def is_openid_flow(self, query: Dict[str, List[str]], url: str) -> bool:
|
||||
return "openid" in query.get("scope", [""])[0] or "id_token" in url
|
||||
|
||||
async def report_missing_parameters(self, url: str, is_openid: bool):
|
||||
status = "MEDIUM" if is_openid else "LOW"
|
||||
report_vuln(
|
||||
title="PKCE Parameters Missing",
|
||||
desc="PKCE parameters are missing or incomplete.",
|
||||
status=status,
|
||||
uri=url,
|
||||
)
|
||||
|
||||
async def report_plain_method(self, url: str):
|
||||
report_vuln(
|
||||
title="PKCE Plain Method",
|
||||
desc="PKCE method is set to 'plain'. Possible downgrade.",
|
||||
status="CRITICAL",
|
||||
uri=url,
|
||||
)
|
||||
|
||||
def create_downgraded_url(self, parsed, query):
|
||||
downgraded_query = query.copy()
|
||||
downgraded_query.pop("code_challenge", None)
|
||||
downgraded_query.pop("code_challenge_method", None)
|
||||
|
||||
new_query = urlencode(downgraded_query, doseq=True)
|
||||
downgraded_url = urlunparse(parsed._replace(query=new_query))
|
||||
|
||||
# Ensure downgraded_url is a string, not bytes
|
||||
if isinstance(downgraded_url, bytes):
|
||||
downgraded_url = downgraded_url.decode('utf-8')
|
||||
|
||||
# Ensure it's definitely a string
|
||||
downgraded_url = str(downgraded_url)
|
||||
return str(urlunparse(parsed._replace(query=new_query)))
|
||||
|
||||
async def compare_responses(self, original_url, downgraded_url, headers, is_openid):
|
||||
print(f"[DEBUG] Original: {original_url}")
|
||||
print(f"[DEBUG] Downgraded: {downgraded_url}")
|
||||
|
||||
print(f"[DEBUG] Testing downgraded URL: {downgraded_url}")
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=False, verify=False) as client:
|
||||
try:
|
||||
print(f"[DEBUG] Sending original request...")
|
||||
orig_resp = await client.get(url, headers=dict(req.headers))
|
||||
print(f"[DEBUG] Original response: {orig_resp.status_code}")
|
||||
|
||||
print(f"[DEBUG] Sending downgraded request...")
|
||||
down_resp = await client.get(downgraded_url, headers=dict(req.headers))
|
||||
print(f"[DEBUG] Downgraded response: {down_resp.status_code}")
|
||||
orig_resp = await client.get(original_url, headers=dict(headers))
|
||||
down_resp = await client.get(downgraded_url, headers=dict(headers))
|
||||
|
||||
orig_status = orig_resp.status_code
|
||||
down_status = down_resp.status_code
|
||||
orig_loc = orig_resp.headers.get("location", "")
|
||||
down_loc = down_resp.headers.get("location", "")
|
||||
|
||||
print(f"[DEBUG] Original location: {orig_loc[:100]}..." if len(orig_loc) > 100 else f"[DEBUG] Original location: {orig_loc}")
|
||||
print(f"[DEBUG] Downgraded location: {down_loc[:100]}..." if len(down_loc) > 100 else f"[DEBUG] Downgraded location: {down_loc}")
|
||||
|
||||
both_redirect = orig_resp.status_code in [301, 302] and down_resp.status_code in [301, 302]
|
||||
both_have_code = "code=" in orig_loc and "code=" in down_loc
|
||||
|
||||
print(f"[DEBUG] Both redirect: {both_redirect}")
|
||||
print(f"[DEBUG] Both have code: {both_have_code}")
|
||||
|
||||
# Check if both requests succeeded (either with redirect or direct response)
|
||||
orig_success = orig_resp.status_code in [200, 301, 302]
|
||||
down_success = down_resp.status_code in [200, 301, 302]
|
||||
|
||||
if orig_success and down_success:
|
||||
# If both redirect and both have code, it's a clear vulnerability
|
||||
if both_redirect and both_have_code:
|
||||
status = "CRITICAL"
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': status,
|
||||
'title': "PKCE Downgrade Vulnerability",
|
||||
'description': "PKCE downgrade vulnerability detected! Both URLs returned authorization code.",
|
||||
'uri': f"Original: {url}\nDowngraded: {downgraded_url}"
|
||||
}]
|
||||
save_report(report_data)
|
||||
# If responses are similar (both redirect to similar pages), it might be vulnerable
|
||||
elif both_redirect:
|
||||
# Check if the redirect locations are similar (excluding PKCE parameters)
|
||||
orig_parsed = urlparse(orig_loc)
|
||||
down_parsed = urlparse(down_loc)
|
||||
|
||||
if orig_parsed.path == down_parsed.path and orig_parsed.netloc == down_parsed.netloc:
|
||||
status = "MEDIUM" if is_openid else "LOW"
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': status,
|
||||
'title': "Potential PKCE Downgrade",
|
||||
'description': "Potential PKCE downgrade vulnerability! Server accepts requests without PKCE.",
|
||||
'uri': f"Original: {url}\nDowngraded: {downgraded_url}"
|
||||
}]
|
||||
save_report(report_data)
|
||||
elif down_resp.status_code != 400: # 400 would be expected for missing required parameters
|
||||
status = "MEDIUM" if is_openid else "LOW"
|
||||
report_data = [{
|
||||
'target': target.load(),
|
||||
'status': status,
|
||||
'title': "PKCE Not Enforced",
|
||||
'description': "Server accepts OAuth request without PKCE parameters.",
|
||||
'uri': f"Original: {url}\nDowngraded: {downgraded_url}"
|
||||
}]
|
||||
save_report(report_data)
|
||||
print(f"[DEBUG] Original status: {orig_status}, location: {orig_loc}")
|
||||
print(f"[DEBUG] Downgraded status: {down_status}, location: {down_loc}")
|
||||
|
||||
if self.both_success(orig_status, down_status):
|
||||
await self.analyze_results(
|
||||
original_url, downgraded_url, orig_loc, down_loc, is_openid
|
||||
)
|
||||
else:
|
||||
print(f"[DEBUG] Requests had different success status - likely PKCE is enforced")
|
||||
|
||||
print(
|
||||
"[DEBUG] Requests had different success status - likely PKCE is enforced"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Request failed: {e}")
|
||||
|
||||
def both_success(self, orig_status, down_status):
|
||||
return orig_status in [200, 301, 302] and down_status in [200, 301, 302]
|
||||
|
||||
async def analyze_results(
|
||||
self, original_url, downgraded_url, orig_loc, down_loc, is_openid
|
||||
):
|
||||
both_redirect = all(
|
||||
code in [301, 302] for code in [orig_loc and 302, down_loc and 302]
|
||||
)
|
||||
both_have_code = "code=" in orig_loc and "code=" in down_loc
|
||||
|
||||
if both_redirect and both_have_code:
|
||||
status = "CRITICAL"
|
||||
title = "PKCE Downgrade Vulnerability"
|
||||
description = (
|
||||
"Both URLs returned an authorization code without enforcing PKCE."
|
||||
)
|
||||
elif both_redirect and self.same_redirect_destination(orig_loc, down_loc):
|
||||
status = "MEDIUM" if is_openid else "LOW"
|
||||
title = "Potential PKCE Downgrade"
|
||||
description = (
|
||||
"Redirects are similar even without PKCE. Possible vulnerability."
|
||||
)
|
||||
elif (
|
||||
"code=" in down_loc
|
||||
or "id_token=" in down_loc
|
||||
or "access_token=" in down_loc
|
||||
):
|
||||
status = "MEDIUM" if is_openid else "LOW"
|
||||
title = "PKCE Not Enforced"
|
||||
description = "OAuth flow succeeded without PKCE parameters."
|
||||
else:
|
||||
return # Likely safe
|
||||
|
||||
report_vuln(
|
||||
title=title,
|
||||
desc=description,
|
||||
status=status,
|
||||
uri=f"Original: {original_url}\nDowngraded: {downgraded_url}",
|
||||
)
|
||||
|
||||
def same_redirect_destination(self, orig_loc, down_loc):
|
||||
orig = urlparse(orig_loc)
|
||||
down = urlparse(down_loc)
|
||||
return orig.netloc == down.netloc and orig.path == down.path
|
||||
|
||||
|
|
|
|||
32
addon/scope_detection.py
Normal file
32
addon/scope_detection.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from lib.report_vuln import report_vuln
|
||||
from lib.utils.is_oauth_uri import is_oauth_uri
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
class ScopeDetection:
|
||||
def get_scope_from_query(self, query: str) -> str | None:
|
||||
if not query:
|
||||
return None
|
||||
parsed = parse_qs(query)
|
||||
scope_values = parsed.get("scope", [])
|
||||
if scope_values:
|
||||
return scope_values[0]
|
||||
return None
|
||||
|
||||
async def test(self, flow):
|
||||
if not is_oauth_uri(flow.request.pretty_url):
|
||||
return
|
||||
|
||||
req = flow.request
|
||||
|
||||
parsed = urlparse(req.pretty_url)
|
||||
query = parsed.query
|
||||
|
||||
query_scope = self.get_scope_from_query(query)
|
||||
|
||||
if query_scope in ["all", "*"]:
|
||||
report_vuln(
|
||||
title="OAuth Scope Value Issue",
|
||||
desc=f"Scope value issue detected in request: {query_scope}",
|
||||
status="WARNING",
|
||||
uri=req.pretty_url
|
||||
)
|
||||
65
lib/false_true_varifing_task.py
Normal file
65
lib/false_true_varifing_task.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from typing import Any
|
||||
from copy import deepcopy
|
||||
|
||||
class FalseTrueVarifingTask:
|
||||
"""
|
||||
A singleton class representing a task that can be either false or true.
|
||||
This class is used to handle tasks that require verification of their truth value.
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super(FalseTrueVarifingTask, cls).__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
self._is_verifing = False
|
||||
self.task_queue = []
|
||||
self._initialized = True
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the task queue and verification status.
|
||||
"""
|
||||
self._is_verifing = False
|
||||
self.task_queue.clear()
|
||||
|
||||
# 각 addon의 검증 로직에서 해당 함수를 호출하여, 추후 오탐 검증을 위한 작업을 추가할 수 있습니다.
|
||||
# TODO: 모델 지정해두기
|
||||
def add_task(self, task_name: str, initial_uri: str, data: Any):
|
||||
"""
|
||||
Add a task to the task queue.
|
||||
:param task: The task to be added.
|
||||
"""
|
||||
self.task_queue.append(
|
||||
{
|
||||
"task_name": task_name,
|
||||
"initial_uri": initial_uri,
|
||||
"data": data
|
||||
}
|
||||
)
|
||||
|
||||
def start_verification(self):
|
||||
"""
|
||||
Start the verification process for the tasks in the queue.
|
||||
"""
|
||||
self._is_verifing = True
|
||||
|
||||
def get_task_queue(self):
|
||||
"""
|
||||
Get a copy of the current task queue.
|
||||
:return: A copy of the task queue.
|
||||
"""
|
||||
return deepcopy(self.task_queue)
|
||||
|
||||
def is_verifing_false_true(self):
|
||||
"""
|
||||
Get the current verification status.
|
||||
:return: True if verification is in progress, False otherwise.
|
||||
"""
|
||||
return self._is_verifing
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# save as data/report.csv
|
||||
import os
|
||||
import csv
|
||||
from typing import List, Dict, Any
|
||||
|
||||
# target, status, title, description, uri
|
||||
|
||||
# file path는 'data/report.csv'로 고정
|
||||
def save_report(report_data: List[Dict[str, Any]], file_path: str = 'data/report.csv') -> None:
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
|
||||
"""
|
||||
Save the report data to a CSV file.
|
||||
|
||||
:param report_data: List of dictionaries containing report data.
|
||||
:param file_path: Path to the CSV file where the report will be saved.
|
||||
"""
|
||||
fieldnames = ['target', 'status', 'title', 'description', 'uri']
|
||||
|
||||
with open(file_path, mode='w', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
for row in report_data:
|
||||
# Replace actual newlines with literal \n strings
|
||||
escaped_row = {k: str(v).replace('\n', '\\n') if v is not None else v for k, v in row.items()}
|
||||
writer.writerow(escaped_row)
|
||||
34
lib/report_vuln.py
Normal file
34
lib/report_vuln.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# save as data/report.csv
|
||||
import os
|
||||
import csv
|
||||
from mitmproxy import http
|
||||
import lib.cur_target_url as cur_target_url
|
||||
|
||||
# target, status, title, description, uri
|
||||
|
||||
# file path는 'data/report.csv'로 고정
|
||||
def report_vuln(title: str, desc: str, status: str, uri: str) -> None:
|
||||
file_path: str = 'data/report.csv'
|
||||
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
"""
|
||||
report_data 안의 각 레포트를 한 줄씩 CSV에 추가로 저장합니다.
|
||||
파일이 없으면 헤더를 먼저 쓰고, 있으면 레코드만 이어서 씁니다.
|
||||
"""
|
||||
fieldnames = ['target', 'status', 'title', 'description', 'uri']
|
||||
file_exists = os.path.exists(file_path)
|
||||
|
||||
with open(file_path, mode='a', newline='', encoding='utf-8') as csvfile:
|
||||
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
||||
# 파일이 없던 새로 만들 때만 헤더 작성
|
||||
if not file_exists:
|
||||
writer.writeheader()
|
||||
|
||||
writer.writerow({
|
||||
'target': cur_target_url.load(),
|
||||
'status': status,
|
||||
'title': title,
|
||||
'description': desc,
|
||||
'uri': uri,
|
||||
})
|
||||
10
lib/utils/is_oauth_uri.py
Normal file
10
lib/utils/is_oauth_uri.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
def is_oauth_uri(uri: str) -> bool:
|
||||
qs = parse_qs(urlparse(uri).query)
|
||||
qs_keys = [*qs]
|
||||
|
||||
if "client_id" in qs_keys and any(p in qs_keys for p in (
|
||||
"redirect_uri", "response_type", "grant_type", "scope", "state", "nonce")):
|
||||
return True
|
||||
return False
|
||||
5
lib/utils/try_catch.py
Normal file
5
lib/utils/try_catch.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
async def try_catch(coro):
|
||||
try:
|
||||
return await coro
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {coro} failed: {e}")
|
||||
46
main.py
46
main.py
|
|
@ -1,31 +1,23 @@
|
|||
import sys
|
||||
from mitmproxy.tools.main import mitmdump
|
||||
from flask import Flask, request
|
||||
from runner.proxy import run_proxy
|
||||
import threading
|
||||
import lib.target as target
|
||||
import uvicorn
|
||||
from runner.backend import app
|
||||
|
||||
def main():
|
||||
sys.argv = ["mitmdump", "-s", "./addon/init.py", "--listen-port", "11080"]
|
||||
mitmdump()
|
||||
|
||||
# get target from browser use web api
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route('/start', methods=['GET', 'POST'])
|
||||
def start():
|
||||
target_url = request.args.get('url')
|
||||
if target_url:
|
||||
target.save(target_url)
|
||||
print(f"Target URL set to: {target_url}")
|
||||
return f"Target URL set to: {target_url}"
|
||||
return "No URL provided"
|
||||
|
||||
def run_web_server():
|
||||
app.run(host='localhost', port=11081, debug=False)
|
||||
|
||||
# Start web server in a separate thread
|
||||
web_thread = threading.Thread(target=run_web_server, daemon=True)
|
||||
web_thread.start()
|
||||
def run_fastapi_server():
|
||||
"""FastAPI 서버를 실행하는 함수"""
|
||||
uvicorn.run(app, host="localhost", port=11081, log_level="info")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
try:
|
||||
# FastAPI 서버를 백그라운드 스레드에서 실행
|
||||
fastapi_thread = threading.Thread(target=run_fastapi_server, daemon=True)
|
||||
fastapi_thread.start()
|
||||
print("🚀 FastAPI server started on http://localhost:11081")
|
||||
|
||||
# Run mitmdump proxy (메인 스레드에서 실행)
|
||||
print("🛡️ Starting mitmdump proxy on port 11080...")
|
||||
run_proxy()
|
||||
except KeyboardInterrupt:
|
||||
print("🛑 Shutting down...")
|
||||
finally:
|
||||
print("✅ Mitmdump proxy has been stopped.")
|
||||
|
|
@ -8,4 +8,7 @@ dependencies = [
|
|||
"mitmproxy>=12.1.1",
|
||||
"aiohttp>=3.8.0",
|
||||
"httpx>=0.28.1",
|
||||
"fastapi[standard]>=0.115.12",
|
||||
"granian>=2.3.2",
|
||||
"PyJWT>=2.10.1",
|
||||
]
|
||||
|
|
|
|||
113
runner/backend/__init__.py
Normal file
113
runner/backend/__init__.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from fastapi import FastAPI, Query, HTTPException
|
||||
from fastapi.responses import Response
|
||||
import lib.cur_target_url as cur_target_url
|
||||
from lib.false_true_varifing_task import FalseTrueVarifingTask
|
||||
from pydantic import BaseModel, Field
|
||||
from lib.report_vuln import report_vuln as save_vuln
|
||||
|
||||
# Initialize the singleton task manager
|
||||
false_true_varifing_task = FalseTrueVarifingTask()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
|
||||
@app.post(
|
||||
"/start",
|
||||
summary="취약점 검증을 위한 대상 URL 설정",
|
||||
description="""
|
||||
이 엔드포인트는 시스템이 취약점 검증 작업에 사용할 대상 URL을 설정합니다.
|
||||
|
||||
유효한 URL이 제공되면:
|
||||
- 해당 URL이 저장됩니다.
|
||||
- 검증 작업 큐가 초기화됩니다.
|
||||
- 새로운 검증 작업을 시작할 준비가 완료됩니다.
|
||||
|
||||
URL이 제공되지 않으면, 오류가 반환됩니다.
|
||||
"""
|
||||
,
|
||||
tags=["1st STEP"]
|
||||
)
|
||||
async def start(url: str = Query(..., description="The URL to target for vulnerability verification")):
|
||||
cur_target_url.save(url)
|
||||
false_true_varifing_task.reset()
|
||||
return {"message": f"Target URL set to: {url}"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post(
|
||||
"/start-false-true-verifing",
|
||||
summary="시스템에 오탐 검증 작업 시작을 알림",
|
||||
description="""
|
||||
이 엔드포인트는 시스템에 오탐 검증 작업이 시작되었음을 알립니다.
|
||||
또한 시스템은 미리 준비된 오탐 검증 작업 목록을 반환합니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"payload": [
|
||||
{
|
||||
"task_name": "pkce_task", # 검증 작업의 이름
|
||||
"initial_uri": "http://auth.example.com", # browser가 처음 접속할 URI
|
||||
"data": any # 추가 데이터
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
""",
|
||||
tags=["2nd STEP"]
|
||||
)
|
||||
async def start_false_true_verifing():
|
||||
false_true_varifing_task.start_verification()
|
||||
task_queue = false_true_varifing_task.get_task_queue()
|
||||
return {"payload": task_queue}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class VulnerabilityReport(BaseModel):
|
||||
title: str = Field(..., description="Short title for the vulnerability")
|
||||
url: str = Field(..., description="URL where the vulnerability was discovered")
|
||||
status: str = Field(..., description="Status of the vulnerability (e.g., VERIFIED-CRITICAL)")
|
||||
desc: str = Field(..., description="Detailed description of the issue")
|
||||
|
||||
@app.post(
|
||||
"/report-vuln",
|
||||
summary="취약점 보고",
|
||||
description="""
|
||||
정탐인 취약점을 시스템에 보고합니다.
|
||||
보고 시 다음 정보를 포함해야 합니다:
|
||||
|
||||
- **title**: 취약점의 간단한 이름
|
||||
- **url**: 취약점이 발견된 위치 (URL)
|
||||
- **status**: 심각도
|
||||
- **desc**: 취약점에 대한 상세 설명
|
||||
""",
|
||||
tags=["3rd STEP"]
|
||||
)
|
||||
async def report_vuln(vuln: VulnerabilityReport):
|
||||
save_vuln(
|
||||
title=vuln.title,
|
||||
desc=vuln.desc,
|
||||
status=vuln.status,
|
||||
uri=vuln.url
|
||||
)
|
||||
|
||||
return {"message": "Vulnerability reported successfully"}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request, exc):
|
||||
return Response(status_code=404)
|
||||
|
||||
@app.exception_handler(405)
|
||||
async def method_not_allowed_handler(request, exc):
|
||||
return Response(status_code=405)
|
||||
6
runner/proxy.py
Normal file
6
runner/proxy.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import sys
|
||||
from mitmproxy.tools.main import mitmdump
|
||||
|
||||
def run_proxy():
|
||||
sys.argv = ["mitmdump", "-s", "./addon/init.py", "--listen-port", "11080"]
|
||||
mitmdump()
|
||||
397
uv.lock
generated
397
uv.lock
generated
|
|
@ -79,6 +79,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.9.0"
|
||||
|
|
@ -259,6 +268,71 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.115.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi-cli", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi-cli"
|
||||
version = "0.0.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "rich-toolkit" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/73/82a5831fbbf8ed75905bacf5b2d9d3dfd6f04d6968b29fe6f72a5ae9ceb1/fastapi_cli-0.0.7.tar.gz", hash = "sha256:02b3b65956f526412515907a0793c9094abd4bfb5457b389f645b0ea6ba3605e", size = 16753, upload-time = "2024-12-15T14:28:10.028Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/e6/5daefc851b514ce2287d8f5d358ae4341089185f78f3217a69d0ce3a390c/fastapi_cli-0.0.7-py3-none-any.whl", hash = "sha256:d549368ff584b2804336c61f192d86ddea080c11255f375959627911944804f4", size = 10705, upload-time = "2024-12-15T14:28:06.18Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.0"
|
||||
|
|
@ -318,6 +392,35 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/13/be/0ebbb283f2d91b72beaee2d07760b2c47dab875c49c286f5591d3d157198/frozenlist-1.6.2-py3-none-any.whl", hash = "sha256:947abfcc8c42a329bbda6df97a4b9c9cdb4e12c85153b3b57b9d2f02aa5877dc", size = 12582, upload-time = "2025-06-03T21:48:03.201Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granian"
|
||||
version = "2.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/80/31faf7a08ddfc3b70af68202de66c6c3acf26cb8eeb0d821a04d21a80f16/granian-2.3.2.tar.gz", hash = "sha256:434bea33a3a4f63db1e65d63a64b80ab44dd09c85421c5555d4188c05c37794d", size = 100765, upload-time = "2025-06-02T20:18:07.096Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/9a/1e34ef9416446eeb9506649770e72dd471f82137ca6271d1fdaa7084b093/granian-2.3.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:73b945fadf520e6f8b65cc839fe57af094ef0a44ce99c26bf3aaecf100fa64e3", size = 3059224, upload-time = "2025-06-02T20:16:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/62/a319e7368285903804a88ec8d15482bfd8d1fead9e2169e23d660819d20b/granian-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e734027d5b3be16c3d2d060f006cc49592962c6ebae965d9841db22ac1a7c348", size = 2734549, upload-time = "2025-06-02T20:16:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/1f/90532d63714ddc59566b0f285b18861541591a1a4a648b5f7df1a039b10a/granian-2.3.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a212a17fe8d2a750d0e5f04e379eb7a6eec8ff80b67baee7f9f7232867f10ad", size = 3317557, upload-time = "2025-06-02T20:16:41.895Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d2/df2433d186ebdba2330f43610e16d33aa7495fa742be3816de5eae0392d6/granian-2.3.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:85f6ad09a414ffc1a8009bba98b3198db4b73baef37b7f6417c597aa38d7c5a9", size = 3007269, upload-time = "2025-06-02T20:16:43.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8e/cd942a31fcdedb213f634ce7cec92183bfd789d628149e6980c0dab2dc4c/granian-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25d731916e1d1539a9dd2e4d26128e7527e0b5e06bb44d78100b3799dfdb572", size = 3222557, upload-time = "2025-06-02T20:16:45.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/c2/3bf9c4916e420e4024d120524b6fb9bba38fd78ae5ddafa93744cd2eb6eb/granian-2.3.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61fd3094b286cd5cb5cfbc22d86a3d8f28f829017029a26717c7cfbe7211b55a", size = 3139593, upload-time = "2025-06-02T20:16:46.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/b5/f55c1e04a6252377d3717897adada46566b122af730a05aef4570e670922/granian-2.3.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2ec0a1724978bec104e46d798371892a8131d879a292e4d104a7764d145cb188", size = 3120473, upload-time = "2025-06-02T20:16:48.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6f/bd89f074af692b80c85f593117eae6d35705b2195bfe60be1e937237c447/granian-2.3.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:eac2b2771d0ee56e842cdc4ef861beb69c4a9a73d96cacf169328793b1be1869", size = 3470288, upload-time = "2025-06-02T20:16:49.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f2/7af1e44ba8a92f86c31928c315ec9823c9fb0b53de495ec27c27c31aadfe/granian-2.3.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:096f6c77683ba476e383360ea57b9239c95235e1d55ffe996ea482917b2e00da", size = 3284715, upload-time = "2025-06-02T20:16:51.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/97/30bcd14de3e6419138731c9da821d2e50d65d4c5398381bfb29d4ee9331c/granian-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:de90bef798241a2ae5cc70a6ae8403aeeaf538bc37037b5779bd5e655e3718a8", size = 2800198, upload-time = "2025-06-02T20:16:53.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/1c/1e67cb95c45893725a377bc5bdf50add3c0a30ba63c6775a99f6cfb3e628/granian-2.3.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:0b3325f4406790e4a2e0ddb1541a8192c869930edbd63577245c7f97f9e3f547", size = 2998378, upload-time = "2025-06-02T20:16:55.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/572532da161d3819e9b6c0cf5ee4062974d48357855eff2ad61fe0195848/granian-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01bf1fc15ce2ec0835da1f3f1b946f6399a3222d5af45d735447ebbaed8cddd3", size = 2663803, upload-time = "2025-06-02T20:16:57.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/24/04bfb65649cff9688f5024d892de351dadb91bce5ef12a3a49aad5629497/granian-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9793a2d92db22638672929df753ed5aff517000dbffe391d4b1d698771f1462c", size = 3096781, upload-time = "2025-06-02T20:16:58.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/0b/62f56c53c9e128f1b14ed8a4adb6dab95989a5797a539425817b31364420/granian-2.3.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:a626fc723d2192fc108422d3393d5f231e01d05c90fba952a8093744d4e25c46", size = 2994630, upload-time = "2025-06-02T20:17:00.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/b5/74ecb1627e63ec95ef10375e4ad2111c1c11dcb9f064a7592ff7cd074647/granian-2.3.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:e46ef42fbb54995cddcbcfe281e31ee3f99cd092a260c7edd0d3859c42464c6a", size = 3110450, upload-time = "2025-06-02T20:17:02.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/6d/1203d665bc543ddaeb336d8ba3f5c01b6263c6c1a7a9ca9ee0b318e92ddb/granian-2.3.2-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:b9204b11aba5ee1e99f9eb45a2dbeaa6fea1bc4695264efe03abef06f0e43e80", size = 3461156, upload-time = "2025-06-02T20:17:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/e5/e4bb2d5e274dd45a3c278674fde9bb6db630f85bd1c1f56c96353b2a0cbf/granian-2.3.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:7f9117a4576b89ce8360e8ed76fe4a57f60793fcffcaff10156821fb7734783b", size = 3279049, upload-time = "2025-06-02T20:17:06.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/02/2c0f248515fd3339f40b5c372bb29b1eff01be113b1898790c327f3ad2ce/granian-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:2b2a7788b6627a218ed978e01ee955c27e9b73f0ef849dcb28e26769bdb4f85d", size = 2801466, upload-time = "2025-06-02T20:17:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
|
|
@ -362,6 +465,21 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.6.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
|
|
@ -437,6 +555,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/4e/f6/71d6ec9f18da0b2201287ce9db6afb1a1f637dedb3f0703409558981c723/ldap3-2.9.1-py2.py3-none-any.whl", hash = "sha256:5869596fc4948797020d3f03b7939da938778a0f9e2009f7a072ccf92b8e8d70", size = 432192, upload-time = "2021-07-18T06:34:12.905Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mdurl" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.2"
|
||||
|
|
@ -465,6 +595,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mitmproxy"
|
||||
version = "12.1.1"
|
||||
|
|
@ -612,15 +751,21 @@ version = "0.1.0"
|
|||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "granian" },
|
||||
{ name = "httpx" },
|
||||
{ name = "mitmproxy" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.8.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.12" },
|
||||
{ name = "granian", specifier = ">=2.3.2" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "mitmproxy", specifier = ">=12.1.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -712,6 +857,49 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.11.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.33.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydivert"
|
||||
version = "2.1.0"
|
||||
|
|
@ -721,6 +909,24 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/ca/8f/86d7931c62013a5a7ebf4e1642a87d4a6050c0f570e714f61b0df1984c62/pydivert-2.1.0-py2.py3-none-any.whl", hash = "sha256:382db488e3c37c03ec9ec94e061a0b24334d78dbaeebb7d4e4d32ce4355d9da1", size = 104718, upload-time = "2017-10-20T21:36:56.726Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pylsqpack"
|
||||
version = "0.3.22"
|
||||
|
|
@ -767,6 +973,68 @@ version = "1.9.0"
|
|||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961, upload-time = "2024-06-18T20:38:48.401Z" }
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078, upload-time = "2025-03-30T14:15:14.23Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229, upload-time = "2025-03-30T14:15:12.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-toolkit"
|
||||
version = "0.14.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/7a/cb48b7024b247631ce39b1f14a0f1abedf311fb27b892b0e0387d809d4b5/rich_toolkit-0.14.7.tar.gz", hash = "sha256:6cca5a68850cc5778915f528eb785662c27ba3b4b2624612cce8340fa9701c5e", size = 104977, upload-time = "2025-05-27T15:48:09.377Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/2e/95fde5b818dac9a37683ea064096323f593442d0f6358923c5f635974393/rich_toolkit-0.14.7-py3-none-any.whl", hash = "sha256:def05cc6e0f1176d6263b6a26648f16a62c4563b277ca2f8538683acdba1e0da", size = 24870, upload-time = "2025-05-27T15:48:07.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruamel-yaml"
|
||||
version = "0.18.10"
|
||||
|
|
@ -791,6 +1059,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/08/2c/ca6dd598b384bc1ce581e24aaae0f2bed4ccac57749d5c3befbb5e742081/service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85", size = 11364, upload-time = "2024-10-26T07:21:56.302Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
|
|
@ -809,6 +1086,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.46.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5"
|
||||
|
|
@ -828,6 +1117,21 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/9a/3cc3969c733ddd4f5992b3d4ec15c9a2564192c7b1a239ba21c8f73f8af4/tornado-6.5-cp39-abi3-win_arm64.whl", hash = "sha256:542e380658dcec911215c4820654662810c06ad872eefe10def6a5e9b20e9633", size = 442874, upload-time = "2025-05-15T20:37:41.267Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c5/8c/7d682431efca5fd290017663ea4588bf6f2c6aad085c7f108c5dbc316e70/typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b", size = 102625, upload-time = "2025-05-26T14:30:31.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.0"
|
||||
|
|
@ -837,6 +1141,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urwid"
|
||||
version = "2.6.16"
|
||||
|
|
@ -850,6 +1166,67 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/cb/271a4f5a1bf4208dbdc96d85b9eae744cf4e5e11ac73eda76dc98c8fd2d7/urwid-2.6.16-py3-none-any.whl", hash = "sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797", size = 297196, upload-time = "2024-10-15T16:07:22.521Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload-time = "2025-04-08T10:36:26.722Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/62/435766874b704f39b2fecd8395a29042db2b5ec4005bd34523415e9bd2e0/watchfiles-1.0.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0b289572c33a0deae62daa57e44a25b99b783e5f7aed81b314232b3d3c81a11d", size = 401531, upload-time = "2025-04-08T10:35:35.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/a6/e52a02c05411b9cb02823e6797ef9bbba0bfaf1bb627da1634d44d8af833/watchfiles-1.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a056c2f692d65bf1e99c41045e3bdcaea3cb9e6b5a53dcaf60a5f3bd95fc9763", size = 392417, upload-time = "2025-04-08T10:35:37.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/53/c4af6819770455932144e0109d4854437769672d7ad897e76e8e1673435d/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9dca99744991fc9850d18015c4f0438865414e50069670f5f7eee08340d8b40", size = 453423, upload-time = "2025-04-08T10:35:38.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/d1/8e88df58bbbf819b8bc5cfbacd3c79e01b40261cad0fc84d1e1ebd778a07/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:894342d61d355446d02cd3988a7326af344143eb33a2fd5d38482a92072d9563", size = 458185, upload-time = "2025-04-08T10:35:39.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/70/fffaa11962dd5429e47e478a18736d4e42bec42404f5ee3b92ef1b87ad60/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab44e1580924d1ffd7b3938e02716d5ad190441965138b4aa1d1f31ea0877f04", size = 486696, upload-time = "2025-04-08T10:35:41.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/db/723c0328e8b3692d53eb273797d9a08be6ffb1d16f1c0ba2bdbdc2a3852c/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d6f9367b132078b2ceb8d066ff6c93a970a18c3029cea37bfd7b2d3dd2e5db8f", size = 522327, upload-time = "2025-04-08T10:35:43.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/05/9fccc43c50c39a76b68343484b9da7b12d42d0859c37c61aec018c967a32/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2e55a9b162e06e3f862fb61e399fe9f05d908d019d87bf5b496a04ef18a970a", size = 499741, upload-time = "2025-04-08T10:35:44.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/14/499e90c37fa518976782b10a18b18db9f55ea73ca14641615056f8194bb3/watchfiles-1.0.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0125f91f70e0732a9f8ee01e49515c35d38ba48db507a50c5bdcad9503af5827", size = 453995, upload-time = "2025-04-08T10:35:46.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d9/f75d6840059320df5adecd2c687fbc18960a7f97b55c300d20f207d48aef/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:13bb21f8ba3248386337c9fa51c528868e6c34a707f729ab041c846d52a0c69a", size = 629693, upload-time = "2025-04-08T10:35:48.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/17/180ca383f5061b61406477218c55d66ec118e6c0c51f02d8142895fcf0a9/watchfiles-1.0.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:839ebd0df4a18c5b3c1b890145b5a3f5f64063c2a0d02b13c76d78fe5de34936", size = 624677, upload-time = "2025-04-08T10:35:49.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/15/714d6ef307f803f236d69ee9d421763707899d6298d9f3183e55e366d9af/watchfiles-1.0.5-cp313-cp313-win32.whl", hash = "sha256:4a8ec1e4e16e2d5bafc9ba82f7aaecfeec990ca7cd27e84fb6f191804ed2fcfc", size = 277804, upload-time = "2025-04-08T10:35:51.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b4/c57b99518fadf431f3ef47a610839e46e5f8abf9814f969859d1c65c02c7/watchfiles-1.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:f436601594f15bf406518af922a89dcaab416568edb6f65c4e5bbbad1ea45c11", size = 291087, upload-time = "2025-04-08T10:35:52.458Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
|
|
@ -859,6 +1236,26 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.3"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue