diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4a4f60e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug report +about: Something in KShield isn't working as expected +title: "" +labels: bug +--- + + + +## Summary + + + +## Component + + +- [ ] CLI (`kshield` binary) +- [ ] Backend (FastAPI / detection engines) +- [ ] Dashboard (React frontend) +- [ ] VS Code extension +- [ ] Installers (curl / Homebrew / npm / pip) +- [ ] Docs + +## Steps to Reproduce + + +1. +2. +3. + +## Expected Behaviour + +## Actual Behaviour + +## Environment + +- OS + architecture: +- Python version (`python3 --version`): +- Rust version (`rustc --version`), if building from source: +- Node version (`node --version`), if using the frontend/extension: +- KShield version (`kshield --version`): + +## Relevant Logs + + + +``` +paste here +``` diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e474665 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability + url: mailto:accounts@ytt.global + about: Do not open a public issue for security vulnerabilities — email us directly instead. See SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..56fab0a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,33 @@ +--- +name: Feature request +about: Propose a new detection rule, CLI command, dashboard feature, or integration +title: "" +labels: enhancement +--- + +## Problem + + + +## Proposed Solution + + + +## Component + + +- [ ] New detection rule (secrets / access control / dependency hallucination / other) +- [ ] CLI command +- [ ] Dashboard +- [ ] VS Code extension +- [ ] New install path or platform (e.g. Windows support) +- [ ] CI/CD integration +- [ ] Other + +## Alternatives Considered + + + +--- + +Large features (new engine rules, new UI sections, new integrations, new install paths) should be discussed here before a PR is opened — see [CONTRIBUTING.md](../../CONTRIBUTING.md). diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..48a849d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## What Changed + + + +## Why + + + +## How to Test + + + +## Checklist + +- [ ] Relevant checks pass locally (see [CONTRIBUTING.md](../CONTRIBUTING.md#making-a-pull-request) for the exact commands per component) +- [ ] `kshield init` still works end-to-end after this change, if the CLI or backend was touched +- [ ] UI changes tested in both light and dark mode, and on a mobile viewport, if the dashboard or extension UI was touched +- [ ] Docs updated if this changes CLI commands, API endpoints, or setup steps +- [ ] PR is focused on one concern diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..caa0d2b --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,55 @@ +version: 2 +updates: + # Rust CLI + - package-ecosystem: "cargo" + directory: "/cli" + schedule: + interval: "weekly" + labels: ["dependencies", "cli"] + groups: + cargo-minor-patch: + update-types: ["minor", "patch"] + + # Python backend + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "weekly" + labels: ["dependencies", "backend"] + groups: + pip-minor-patch: + update-types: ["minor", "patch"] + + # React dashboard + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" + labels: ["dependencies", "frontend"] + groups: + npm-minor-patch: + update-types: ["minor", "patch"] + + # VS Code extension + - package-ecosystem: "npm" + directory: "/vscode-extension" + schedule: + interval: "weekly" + labels: ["dependencies", "vscode-extension"] + groups: + npm-minor-patch: + update-types: ["minor", "patch"] + + # npx wrapper package + - package-ecosystem: "npm" + directory: "/npm" + schedule: + interval: "weekly" + labels: ["dependencies", "npm-wrapper"] + + # GitHub Actions workflow versions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: ["dependencies", "ci"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 340da36..7bd0055 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,8 @@ jobs: from app.engine.entropy import analyze_entropy_and_secrets from app.engine.ast_rules import run_ast_structural_scan from app.engine.model import sequence_classifier_node - from app.engine.remediation import construct_remediation_patch + from app.engine.ksword import construct_remediation_patch + from app.engine.orchestrator import run_audit print('All backend imports OK') " working-directory: backend diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..31c7523 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: CodeQL + +on: + push: + branches: [dev, master] + pull_request: + branches: [dev, master] + schedule: + # Weekly, independent of any push — catches newly-disclosed query patterns + # against code that hasn't changed. + - cron: "17 3 * * 1" + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-22.04 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + # Rust isn't in CodeQL's supported-language set — the CLI is covered + # by `cargo build`/`cargo test` in ci.yml instead, not by this workflow. + language: ["python", "javascript-typescript"] + + steps: + - uses: actions/checkout@v4 + + - uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + + - uses: github/codeql-action/autobuild@v3 + + - uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62c7703..0d4612a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,8 @@ jobs: - target: aarch64-unknown-linux-gnu os: ubuntu-22.04 use_cross: true + - target: x86_64-pc-windows-msvc + os: windows-latest steps: - uses: actions/checkout@v4 @@ -52,20 +54,30 @@ jobs: - name: Package binary shell: bash run: | - BINARY="cli/target/${{ matrix.target }}/release/kshield" - TARBALL="kshield-${{ matrix.target }}.tar.gz" - cp "$BINARY" kshield - tar -czf "$TARBALL" kshield - rm kshield - sha256sum "$TARBALL" >> checksums-${{ matrix.target }}.txt 2>/dev/null \ - || shasum -a 256 "$TARBALL" >> checksums-${{ matrix.target }}.txt + if [[ "${{ matrix.target }}" == *windows* ]]; then + BINARY="cli/target/${{ matrix.target }}/release/kshield.exe" + ARCHIVE="kshield-${{ matrix.target }}.zip" + cp "$BINARY" kshield.exe + 7z a "$ARCHIVE" kshield.exe + rm kshield.exe + else + BINARY="cli/target/${{ matrix.target }}/release/kshield" + ARCHIVE="kshield-${{ matrix.target }}.tar.gz" + cp "$BINARY" kshield + tar -czf "$ARCHIVE" kshield + rm kshield + fi + sha256sum "$ARCHIVE" >> checksums-${{ matrix.target }}.txt 2>/dev/null \ + || shasum -a 256 "$ARCHIVE" >> checksums-${{ matrix.target }}.txt - uses: actions/upload-artifact@v4 with: name: binary-${{ matrix.target }} path: | kshield-${{ matrix.target }}.tar.gz + kshield-${{ matrix.target }}.zip checksums-${{ matrix.target }}.txt + if-no-files-found: ignore # ── Package the Python backend ─────────────────────────────────────────────── package-backend: @@ -141,11 +153,13 @@ jobs: brew install ytt-global/tap/kshield ``` - **npm:** + **npm (macOS / Linux / Windows):** ```bash - npx kshield@${{ github.ref_name }} init + npx @ytt-global/kshield@${{ github.ref_name }} init ``` + **Windows (manual):** download `kshield-x86_64-pc-windows-msvc.zip` below, extract, and add it to your `PATH`. Not yet covered by an install script — the npm path above handles this automatically. + **pip:** ```bash pip install kshield && kshield-backend & @@ -165,6 +179,7 @@ jobs: See [CHANGELOG](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for what's new. files: | dist/*.tar.gz + dist/*.zip checksums.txt homebrew/kshield.rb draft: false diff --git a/.gitignore b/.gitignore index e5ff831..4a4582d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ backend/**/__pycache__/ backend/**/*.pyc backend/.env backend/*.egg-info/ +backend/tests/ # ── Frontend ─────────────────────────────────────────────────────────────────── frontend/node_modules/ diff --git a/.kshield.yml b/.kshield.yml index 59306f8..b342451 100644 --- a/.kshield.yml +++ b/.kshield.yml @@ -8,3 +8,8 @@ suppress: - "**/pnpm-lock.yaml" - "Cargo.lock" - "**/Cargo.lock" + # Test fixtures intentionally contain realistic-looking fake secrets/PATs + # to verify the entropy/pattern detectors actually catch them — real + # findings here are expected noise, not leaked credentials. + - "backend/tests/**" + - "**/backend/tests/**" diff --git a/CHANGELOG.md b/CHANGELOG.md index 928a179..413da77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,44 @@ All notable changes to KShield are documented here. ## [Unreleased] ### Added +- **Repo-wide audit** (`kshield agent `, `POST /api/v1/audit`): scans every git-tracked file in one pass instead of just the staged diff. + - `graph_builder.py` builds a full repo graph (imports, symbols, every route with its guards, parse errors) before any check runs. + - `access_control.py` is a graph-aware Broken Access Control check — reuses guard names already proven elsewhere in the repo, exempts public paths, escalates severity on sensitive paths (`/admin`, `/payment`, etc.), and recognizes webhook signature-verification as a valid guard shape even when it isn't a `Depends()`. + - `orchestrator.py` sequences the audit: graph build → access control → a single batched, concurrency-bounded pass over every dependency registry lookup in the repo (was previously one-at-a-time per file, which timed out on large monorepos) → per-file entropy/hallucination/dependency scans reusing that cache. + - `dependency_audit.py` adds two new finding types: **Possible Typosquat** (Levenshtein distance against a curated popular-package list) and **Undeclared Dependency** (import not found in `requirements.txt`/`pyproject.toml`/`package.json`). + - `quiet_office.py` + `pattern_archive.py`: dismissing a finding (`POST /api/v1/audit/dismiss-finding`) is remembered by normalized signature and auto-suppresses matching findings on future runs; project-specific auth-keyword names learned this way persist across audits. + - `audit_runs` table + `GET /api/v1/audit/runs` — every audit run is persisted with file/finding/severity counts. + - `org-audit.sh` — chains `kshield agent` across every non-archived repo in a GitHub org, cloning in parallel and reporting a ranked summary from `audit_runs`. +- **Verified auto-remediation agent (`ksword.py`)**, replacing the old `remediation.py`. Every strategy proposes a fix, applies it in-memory, then re-runs the *same check* that raised the original finding against the patched result — a patch is only ever returned if that re-check confirms the finding actually cleared. No network calls, no model inference. + - Hardcoded Secret / High Entropy Credential → extracted to `os.getenv("VAR_NAME")`, adding `import os` if missing. + - Broken Access Control → reuses a `Depends()`/`Security()` guard already proven to work elsewhere in the same file; declines rather than inventing a guard name that might not exist. + - Possible Typosquat → corrects the import to the well-known package name identified by the typosquat check. + - Dependency Hallucination / Syntax Violation / AI Structural Hallucination → explanation-only by design; a rule engine can't safely fabricate business logic or guess a real package name. + - Wired into `/api/v1/scan`, `/api/v1/audit`, and `/api/v1/apply-patch`. The Rust CLI's `agent` output now renders remediation diffs the same way `scan`/`hook` already did. - `kshield-vscode` is now live on the VS Code Marketplace as [`YTTGlobal.kshield-vscode`](https://marketplace.visualstudio.com/items?itemName=YTTGlobal.kshield-vscode). Docs updated to lead with `code --install-extension YTTGlobal.kshield-vscode` ahead of the manual `.vsix` build steps. +- **Windows release target** (`x86_64-pc-windows-msvc`): `release.yml`'s build matrix now includes a native `windows-latest` runner alongside the existing macOS/Linux targets, producing a `.zip` (the other targets ship `.tar.gz`). The Rust CLI already had real Windows branches from day one (`USERPROFILE` fallback for the home directory, `venv/Scripts/python.exe` vs `venv/bin/python`) — they'd just never been built or shipped. Cross-compiled locally via `x86_64-pc-windows-gnu` + mingw-w64 as a smoke test before wiring up the CI target; **not yet verified on a real Windows machine**, so treat this release's Windows binary as best-effort until someone reports back. `npm/scripts/install.js` and `npm/bin/kshield.js` now detect `win32` and handle the `.exe`/`.zip` difference; `install.sh` (bash-only) now fails fast with a clear pointer to the npm path instead of silently mis-downloading a `.tar.gz` under Git Bash/MSYS. + +### Fixed +- **The npm package name `kshield` was squatted** by an unrelated third-party CLI (`kidshield`) — `npx kshield init`, as documented everywhere, silently ran someone else's tool instead of failing loudly. Renamed the package to the scoped `@ytt-global/kshield`; the installed command is still `kshield` (the `bin` field is unchanged), only the install/npx invocation changes. Every reference (`README.md`, `docs/setup.md`, `docs/features.md`, `install.sh`'s error text, the release-notes template, the npm package's own error/warning strings) updated to match. +- **`backend/tests/` is intentionally gitignored, not shipped in the public repo.** This audit-engine work is backed by a 186-test local suite (`test_access_control.py`, `test_ksword.py`, `test_orchestrator.py`, and six others) — all passing — but the test files themselves aren't committed. Practical effect: CI's `pytest tests/ || echo "No tests yet"` step reports "No tests yet" on every run, and a fresh clone has no test suite to run at all. `CONTRIBUTING.md`'s testing section reflects this. +- `remediation.py`'s Broken Access Control patch referenced symbols (`AuthenticationGuard`, `app`) that didn't exist in the target file and could corrupt it when applied via `git apply` — this was the entire reason for the ksword rewrite above. All finding types other than Broken Access Control previously returned no patch at all (an empty `patch_diff`), even where a safe automated fix was possible. +- `.github/workflows/ci.yml`'s backend import-sanity step still referenced the now-deleted `app.engine.remediation` module. +- README's CI badge pointed at a workflow file (`kshield-ci.yml`) that doesn't exist — the real workflow is `ci.yml`. +- `backend/requirements.txt` declared `tensorflow`, `sqlmodel`, and `cachetools` — none are imported anywhere in `app/`. `tensorflow` alone added several hundred MB to every local `kshield setup` and CI install for zero functional benefit; `model.py`'s classifier is regex-based, not ML. +- `backend/Dockerfile` built on `python:3.12-slim` while the rest of the project (pyproject.toml, CI, the local `.venv`) targets 3.11 — repointed to match. It also never installed `curl`, which `docker-compose.yml`'s backend healthcheck requires (`curl -f http://localhost:8000/health`) — on a Debian slim base that command doesn't exist, so the healthcheck would fail indefinitely and `frontend` (which waits on `backend: condition: service_healthy`) would never start. +- **`POST /api/v1/apply-patch` did not actually work end-to-end in any real workflow**, found during a full live-verification pass. Three independent bugs stacked: + 1. `cmd_hook`/`cmd_agent` sent `filename` as the raw string from `git diff --cached --name-only`/`git ls-files` — repo-relative, not absolute. `apply-patch`'s file lookup tries the string as-is, then `~/`, then the *backend daemon's own cwd* — none of which reliably matches a real repo, so the endpoint almost always returned `"File not found on disk"`. Fixed by resolving every path against `git rev-parse --show-toplevel` in `scanner.rs` before it ever leaves the CLI, and canonicalizing `cmd_scan`'s argument the same way. + 2. Once the file *was* found (an absolute path), `ksword.py`'s diff header used the full path (`a/{filename}`) — for an absolute path this produced `a//abs/path`, which `git apply` rejects outright (`error: invalid path`) since the default `-p1` strip still leaves an absolute remainder. Fixed by using the basename in the diff header, independent of whether the caller's `filename` is absolute or relative. + 3. Even with a correct header, the generated `patch_diff` had no trailing newline (`"\n".join(...)` never terminates the final line) — `git apply` treats that as `"corrupt patch at line N"` and refuses the whole file. The `test_ksword.py` helper had been silently adding the missing newline itself before writing the patch to disk, which is why all 12 ksword tests passed while the real endpoint was broken the whole time. Fixed in `ksword.py::_unified_diff` (single source of truth for every consumer), and the test helper now writes `patch_diff` byte-for-byte with an explicit assertion that it already ends in `\n`. + + Verified live end-to-end after the fix: `kshield hook` run from a repo subdirectory → correct absolute path in the finding → `apply-patch` → `status: "applied"` → re-scan of the patched file on disk → `safe: true`. +- `kshield agent --help` still described the command as "graph plumbing only — detection findings land in a later milestone", a holdover from before M2–M7 were built. It has produced full findings and remediation for some time; the help text now says so. +- **`ksword.py`'s Broken Access Control fix could reuse a non-auth dependency as the "guard"**, found by running `kshield agent` against kshield's own repo (11 of 46 findings hit this). `_existing_guard_in_file` accepted *any* `Depends()`/`Security()` call found elsewhere in the file — including `get_db_session` — with no check that the name was actually auth-related. The resulting patch applied cleanly and even passed verification, because verification used `ast_rules.py`'s checker, which has the identical blind spot (any `Depends()` counts as a guard — the specific gap `access_control.py`'s M2 fix exists to close, per its own test suite). Net effect: a "verified" patch that added zero real authentication. Fixed by reusing `access_control.py`'s own `_AUTH_KEYWORDS`/`_looks_like_auth` filter when selecting a guard to reuse, and by re-pointing verification at `access_control.py`'s graph-aware checker instead of `ast_rules.py`'s simpler one — strictly stricter than before, so this can only make ksword decline more often, never less safely. +- **`kshield agent`/`kshield hook` silently misbehaved when run from any subdirectory of a repo, not just the root.** `git ls-files` (used by `agent`) defaults to both a cwd-relative *scope* (only that subdirectory's files) and a cwd-relative *path format* — running `kshield agent` from `cli/` audited only `cli/`'s 9 files while reporting no error, not the repo's 145. `git diff --cached --name-only` (used by `hook`) is repo-root-relative by default, which happened to make an earlier fix look correct when tested — but the per-file `git show :{filename}` call that reads each staged blob interprets that same repo-relative path *relative to cwd*, so a staged file outside the invoking subdirectory could silently fail to read. Fixed by pinning every git subprocess in `scanner.rs` to the repo root via `.current_dir()`, rather than relying on each git subcommand's own (inconsistent) default. Verified live: `kshield agent` from `cli/` now reports all 145 files; `kshield hook` run from a subdirectory with the real staged change in a sibling directory now finds and flags it correctly. +- **`kshield agent` silently ignored `.kshield.yml` and the dashboard's global "Suppress Rule" action entirely.** Only `quiet_office.py`'s dismissed-finding memory applied to the repo-wide audit path — unlike `kshield scan`/`hook` (`scan.py`), which already merges both. A rule suppressed via the dashboard, or a severity/path ignored via `.kshield.yml`, would silently reappear the moment you ran a repo-wide audit instead of a single-file scan — same config, two different outcomes. Fixed by wiring the suppress config through the whole path: CLI (`cmd_agent` now calls `config::load()`, same as `cmd_hook`/`cmd_scan`), the wire format (`AuditPayload`/`AuditRequest` gain a `suppress` field), and the backend (`orchestrator.py::run_audit` now merges globally-suppressed rules and calls `suppress.py::apply()`, the same way `scan.py` does). Verified live: a `.kshield.yml` rule suppression that `kshield scan` already respected now also applies to `kshield agent` against the same repo. + - This surfaced a real ordering bug: `suppress.py` and `quiet_office.py` both unconditionally overwrote a finding's `suppressed` flag, so whichever ran second could silently un-suppress what the first had already suppressed. Both are now "sticky" — a finding already marked suppressed stays suppressed regardless of call order. Covered by six new tests in `test_orchestrator.py`. + - The CLI's "Quieted" label always said `(matches a previously dismissed finding)`, which stopped being accurate now that a quieted finding can come from three different mechanisms — reworded to name all three. +- **The dashboard's "Detection Rules" section in Settings was entirely client-side mock state** — an empty initial list, a "Save Configuration" button that just flipped a local flag with `setTimeout`, and an "Add New Rule" form implying a custom-rule capability the backend has never had. None of it called the API or persisted anything. Replaced with a real, API-backed list of the 8 actual rule types the engine supports; toggling one now calls the same `POST`/`DELETE /api/v1/suppress` endpoints the dashboard's per-finding "Suppress Rule" button already used, so state is consistent everywhere instead of three disconnected mechanisms (a fake toggle UI, a real global-suppress table, and `.kshield.yml` — see above) each showing something different. The separate "Suppressed Rules" panel added earlier this cycle is folded into this one section rather than kept as a redundant second view of the same state. ## [1.1.0] — 2026-07-17 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2d8f862 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,52 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior deemed inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces (issues, pull requests, discussions) and also applies when an individual is officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the maintainers at `accounts@ytt.global`. All complaints will be reviewed and investigated promptly and fairly. + +All maintainers are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Maintainers will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +1. **Correction** — A private, written warning, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. +2. **Warning** — A warning with consequences for continued behavior. No interaction with the people involved for a specified period of time. +3. **Temporary Ban** — A temporary ban from any sort of interaction or public communication with the community for a specified period of time. +4. **Permanent Ban** — A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7588453..db52648 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,8 @@ Thank you for your interest in contributing. KShield is a local-first security tool — every contribution helps developers write safer code without giving up their privacy. +By participating in this project, you're expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). Found a security vulnerability instead of a regular bug? See [SECURITY.md](SECURITY.md) — please don't open a public issue for it. + --- ## Before You Start @@ -34,7 +36,7 @@ Thank you for your interest in contributing. KShield is a local-first security t - **Environment** (OS + arch, Python version, Rust version, Node version) - **Relevant logs** (`~/.kshield/backend.log`, browser console, cargo output) -Security vulnerabilities should **not** be reported as public issues. Email `accounts@ytt.global` directly. +Security vulnerabilities should **not** be reported as public issues — see [SECURITY.md](SECURITY.md) for how to report them responsibly. --- @@ -106,7 +108,9 @@ All branches must fork from `main`. cd cli && cargo build && cargo test # Backend - cd backend && SQLITE_FALLBACK=true python -m pytest tests/ -v + # backend/tests/ is gitignored and not part of the public repo — verify + # backend changes by starting the server and exercising the endpoint(s) + # you touched (see docs/setup.md), not via an automated suite here. # Frontend cd frontend && npm run build && npm run lint diff --git a/README.md b/README.md index df93594..6f5e76a 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ # KShield - **The pre-commit security firewall for developers** + **The local-first security firewall for developers** - Catches hardcoded secrets, broken access control, AI hallucinations, and supply-chain risks — entirely on your machine, before a single line reaches your remote. + Catches hardcoded secrets, broken access control, AI hallucinations, and supply-chain risks — entirely on your machine, one commit or a whole repo at a time — with verified auto-remediation, not fabricated patches. - [![Build](https://img.shields.io/github/actions/workflow/status/YTT-Global/kshield/kshield-ci.yml?label=CI&style=flat-square)](https://github.com/YTT-Global/kshield/actions) + [![Build](https://img.shields.io/github/actions/workflow/status/YTT-Global/kshield/ci.yml?label=CI&style=flat-square)](https://github.com/YTT-Global/kshield/actions) [![Release](https://img.shields.io/github/v/release/YTT-Global/kshield?style=flat-square)](https://github.com/YTT-Global/kshield/releases/latest) [![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/YTTGlobal.kshield-vscode?style=flat-square&label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=YTTGlobal.kshield-vscode) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) @@ -38,7 +38,7 @@ brew install YTT-Global/tap/kshield **npm / npx (JavaScript developers):** ```bash -npx kshield init +npx @ytt-global/kshield init ``` **pip (Python developers):** @@ -106,6 +106,7 @@ To skip (not recommended): git commit --no-verify | `kshield status` | Check whether the backend and hook are running | | `kshield scan ` | Manually scan a single file | | `kshield hook` | Run a pre-commit scan (called by the git hook) | +| `kshield agent ` | Repo-wide audit — scans every tracked file at once, not just the diff | --- @@ -179,6 +180,37 @@ Python files with parse errors are flagged — truncated AI-generated code often --- +## Repo-Wide Audit + +`kshield scan`/`hook` only look at the file (or staged diff) in front of you. `kshield agent ` audits an **entire repo at once** — every git-tracked file, in one pass: + +```bash +kshield agent my-repo +``` + +``` + KShield · Agent · my-repo + Building repo graph from 127 tracked files... + + Files scanned 127 + Routes found 8 + Findings 12 + + Findings + HIGH backend/app/api/v1/actions.py:29 + Broken Access Control + POST endpoint '/suppress' has no authentication guard. + ... +``` + +It builds a full repo graph (imports, symbols, every route and its guards) before scanning, so access-control checks understand which routes are genuinely reachable and which auth dependencies are already proven to work elsewhere in your codebase — not just what's visible in a single diff. Findings you've already dismissed as false positives (`POST /api/v1/audit/dismiss-finding`) are remembered by normalized signature and auto-suppressed on future runs. + +`org-audit.sh` chains this across every repo in a GitHub org — clone, `kshield agent ` per repo, and a ranked summary from the local `audit_runs` table at the end. + +## Auto-Remediation + +Every finding that carries a patch has been **verified before it's shown to you** — kshield proposes a fix, applies it in memory, then re-runs the same check that raised the finding to confirm it's actually gone. If no fix can be proven safe, you get an explanation instead of a fabricated patch. Concretely: hardcoded secrets are extracted to `os.getenv(...)`, broken access control is fixed by reusing an authentication guard already used elsewhere in the same file (never invents a name that might not exist), and typo'd package names are corrected against a curated list of well-known packages — all with zero network calls and zero LLM involvement. + ## Key Features | Feature | Description | @@ -186,9 +218,11 @@ Python files with parse errors are flagged — truncated AI-generated code often | **Local-first** | Zero data leaves your machine — all analysis runs offline | | **Zero-friction setup** | `kshield init` does everything: hook + backend + SQLite DB | | **No Docker required** | Backend runs in a managed venv at `~/.kshield/` | -| **Auto-remediation** | Unified diff patches and ELI5 explanations for every finding | +| **Repo-wide audit** | `kshield agent` scans a whole repo at once with graph-aware access-control checks | +| **Verified remediation** | Every patch is generated, applied in-memory, and re-checked before it's shown — never a fabricated fix | +| **False-positive memory** | Dismiss a finding once; kshield recognizes the same pattern next time and stays quiet | | **Severity triage** | CRITICAL / HIGH / MEDIUM / LOW with per-rule toggles | -| **Semantic search** | pgvector embeddings for similarity search across scan history | +| **Similarity infrastructure** | pgvector embedding column on every finding — semantic ranking is on the roadmap | | **Dashboard** | React UI with Exo 2 typography, light/dark theme, custom icons, slide-over detail | | **Design system** | Reusable component library — Badge, Button, Card, CodeBlock, Drawer, and more | | **In-app docs** | Built-in How to Use page with User Guide + API Reference tabs | @@ -218,20 +252,30 @@ Python files with parse errors are flagged — truncated AI-generated code often kshield/ ├── cli/ # Rust binary │ └── src/ -│ ├── main.rs # CLI entry: init, setup, start, stop, status, hook, scan +│ ├── main.rs # CLI entry: init, setup, start, stop, status, hook, scan, agent │ ├── setup.rs # Backend lifecycle: download, venv, install, start, stop -│ ├── http.rs # Backend API calls (health check, scan) -│ ├── scanner.rs # Git staged file reader +│ ├── http.rs # Backend API calls (health check, scan, audit) +│ ├── scanner.rs # Git staged/tracked file reader │ ├── ui.rs # Colour terminal output -│ └── types.rs # Shared types (ScanResult, Anomaly, etc.) +│ └── types.rs # Shared types (ScanResult, Anomaly, AuditResult, etc.) ├── backend/ # FastAPI analysis engine │ └── app/ -│ ├── api/v1/scan.py # POST /api/v1/scan endpoint -│ ├── engine/ # entropy · ast_rules · model · sandbox · remediation +│ ├── api/v1/ +│ │ ├── scan.py # POST /api/v1/scan — single-file pipeline +│ │ ├── audit.py # POST /api/v1/audit — repo-wide pipeline (kshield agent) +│ │ ├── actions.py # POST /suppress, /apply-patch +│ │ ├── history.py # GET /scans, /telemetry +│ │ └── patterns.py # GET/POST /api/v1/patterns/auth-keywords +│ ├── engine/ +│ │ ├── entropy.py · ast_rules.py · model.py · sandbox.py # single-file scan engines +│ │ ├── graph_builder.py · access_control.py · orchestrator.py # repo-wide audit engines +│ │ ├── dependency_audit.py · quiet_office.py · pattern_archive.py +│ │ └── ksword.py # verified auto-remediation agent │ ├── models/ # SQLAlchemy ORM models │ └── db/session.py # Async session + SQLite fallback ├── frontend/ # React dashboard │ └── src/ +│ ├── api/client.ts # Backend HTTP client │ ├── components/ # Dashboard · Settings · Sidebar · Docs │ └── design-system/ # Component library │ ├── tokens.ts # Colors, radius, shadow, font tokens @@ -246,17 +290,21 @@ kshield/ │ ├── diagnostics.ts # Finding → vscode.Diagnostic mapping │ ├── hoverProvider.ts # ELI5 explanations on hover │ ├── codeActionProvider.ts # Quick Fix: apply patch / suppress rule +│ ├── statusBar.ts # Backend reachability indicator │ └── patch.ts # Unified diff applier for remediation patches -├── npm/ # npx kshield wrapper package +├── npm/ # npx @ytt-global/kshield wrapper package ├── homebrew/kshield.rb # Homebrew formula ├── install.sh # curl | bash installer +├── org-audit.sh # kshield agent, chained across every repo in a GitHub org ├── pyproject.toml # pip install kshield ├── assets/logo.svg # Brand mark, used in this README ├── LICENSE # MIT ├── CHANGELOG.md └── docs/ - ├── architecture.md - └── setup.md + ├── architecture.md # Current system diagram — see architecture-v1.md for history + ├── architecture-v1.md + ├── setup.md + └── features.md # Complete, verified feature reference + known limitations ``` --- @@ -305,7 +353,7 @@ See [docs/architecture.md](docs/architecture.md) for the full system diagram. ``` Developer Laptop │ - ├── Rust CLI ──── kshield init/hook/scan + ├── Rust CLI ──── kshield init/hook/scan (single file) · kshield agent (whole repo) │ │ │ └── manages ──► ~/.kshield/ (venv + db + pid) │ @@ -314,14 +362,19 @@ Developer Laptop HTTP / Webhooks │ FastAPI Backend (port 8000) - ┌──────────────────────┐ - │ Entropy Scanner │ - │ AST Engine │ - │ ML Classifier │ - │ Hallucination Guard │ - │ Remediation Engine │ - └──────────────────────┘ - │ + ┌────────────────────────────┐ ┌──────────────────────────────┐ + │ Single-file pipeline │ │ Repo-wide audit pipeline │ + │ Entropy Scanner │ │ Graph Builder │ + │ AST Engine │ │ Graph-Aware Access Control │ + │ Hallucination Pattern Match │ │ Orchestrator │ + │ Dependency Sandbox │ │ Dependency Hygiene / Typosquat│ + └────────────────────────────┘ │ Quiet Office (FP memory) │ + │ └──────────────────────────────┘ + └───────────────┬──────────────────┘ + ▼ + ksword — verified auto-remediation + (propose → apply → re-check → only then return a patch) + │ SQLite (local) or PostgreSQL + pgvector ``` @@ -334,13 +387,21 @@ The backend exposes a REST API at `http://localhost:8000`. Full reference is ava | Method | Endpoint | Description | |---|---|---| | `GET` | `/health` | Backend liveness check | -| `POST` | `/api/v1/scan` | Submit a file for security analysis | +| `POST` | `/api/v1/scan` | Submit a single file for security analysis (used by `hook`/`scan`) | +| `POST` | `/api/v1/audit` | Submit a whole repo's tracked files for a graph-aware audit (used by `agent`) | +| `POST` | `/api/v1/audit/dismiss-finding` | Mark a finding as a false positive so similar future findings are auto-suppressed | +| `GET` | `/api/v1/audit/runs` | List past `kshield agent` runs, ranked by severity | +| `GET` | `/api/v1/scans` | List past single-file scans | +| `GET` | `/api/v1/telemetry` | Aggregate scan/finding counts for the dashboard | +| `POST` | `/api/v1/suppress` | Globally suppress a rule type | +| `POST` | `/api/v1/apply-patch` | Apply a finding's verified remediation patch directly to the file on disk | +| `GET`/`POST` | `/api/v1/patterns/auth-keywords` | List / teach project-specific auth-guard names — e.g. `verify_org_membership` — so `access_control.py` stops flagging routes that already use them | --- ## Roadmap -- [x] Rust CLI — init, setup, start, stop, status, hook, scan +- [x] Rust CLI — init, setup, start, stop, status, hook, scan, agent - [x] Zero-friction install (curl, npx, pip, brew) - [x] SQLite mode — no Docker for first run - [x] Auto backend lifecycle management (~/.kshield/) @@ -350,10 +411,15 @@ The backend exposes a REST API at `http://localhost:8000`. Full reference is ava - [x] GitHub Actions — multi-platform release + PR scan - [x] Homebrew formula - [x] Trust Through Accuracy — 30+ secret patterns, async AST, Go/Ruby registries, test file exemption -- [ ] Connect React dashboard to live backend endpoints -- [ ] Filter chips (CRITICAL / HIGH / MEDIUM) on anomaly list -- [ ] Toast notifications for patch application +- [x] Connect React dashboard to live backend endpoints +- [x] Filter chips (CRITICAL / HIGH / MEDIUM / LOW) on anomaly list +- [x] Toast notifications for patch application - [x] VS Code extension — inline warnings as you type +- [x] Repo-wide audit — graph builder, graph-aware access control, `kshield agent`, org-wide scanning via `org-audit.sh` +- [x] False-positive memory — dismiss a finding once, similar future findings auto-suppress +- [x] Verified auto-remediation (ksword) — every patch is applied and re-checked before it's shown, not fabricated +- [ ] Extend ksword's access-control fix to reuse guards across the whole repo graph, not just the same file +- [ ] Real semantic embeddings — the pgvector column exists but isn't populated with a meaningful vector yet - [ ] Windows support - [ ] Tauri desktop build packaging @@ -361,7 +427,13 @@ The backend exposes a REST API at `http://localhost:8000`. Full reference is ava ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved. +See [CONTRIBUTING.md](CONTRIBUTING.md) for how to get involved. Participation in this project is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). + +--- + +## Security + +Found a vulnerability? Please don't open a public issue — see [SECURITY.md](SECURITY.md) for how to report it responsibly. --- @@ -375,4 +447,4 @@ See [CHANGELOG.md](CHANGELOG.md) for version history. MIT — see [LICENSE](LICENSE) -Built by [YTT Global Services](https://ytt.global) +Built for IT Teams by [YTT Global Services](https://ytt.global) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..83f9805 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +KShield exists to help developers catch security issues before they ship — so a vulnerability in KShield itself gets treated as a priority, not an afterthought. + +## Supported Versions + +Only the latest released version is supported with security fixes. There is no long-term-support branch yet — upgrade to the newest release to get a fix. + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for a security vulnerability.** Email `accounts@ytt.global` directly instead, with: + +- A description of the vulnerability and its impact +- Steps to reproduce (exact commands, request payloads, or code samples) +- Which component is affected (Rust CLI, Python backend, React dashboard, VS Code extension, or the installers) +- Your assessment of severity, if you have one + +You should receive an acknowledgement within 5 business days. We'll keep you updated as the issue is triaged, fixed, and released, and will credit you in the release notes unless you'd prefer to stay anonymous. + +## Scope + +In scope: the Rust CLI, the FastAPI backend, the React dashboard, the VS Code extension, and the four install paths (`install.sh`, the Homebrew formula, the npm wrapper, the PyPI package). + +A few things worth knowing up front rather than reported as surprises — these are tracked, known gaps rather than undiscovered vulnerabilities: + +- The backend API currently has no authentication on any endpoint. This is a reasonable default for the local-first, `127.0.0.1`-only install path, but matters if you point `KSHIELD_BACKEND` at a shared or network-reachable instance (e.g. via `docker-compose.yml`). Treat any non-localhost deployment as requiring a reverse proxy or firewall in front of it until this is addressed. +- Findings and remediation patches are generated locally from the scanned file's own content — a specially crafted source file is a more relevant threat model here than a typical web app's input validation surface. If you find a way to make the entropy/regex/AST engines misbehave (crash, hang, or produce an unsafe patch) on adversarial input, that's exactly the kind of report we want. + +## What Happens After a Report + +1. We confirm the issue and determine severity. +2. A fix is developed and tested against the existing test suite plus a regression test for the specific issue. +3. A new release is cut and the fix is documented in `CHANGELOG.md`. +4. Public disclosure happens after the fix is released, coordinated with you. diff --git a/SKILLS.md b/SKILLS.md index 5f33e05..f81f8d1 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -1,138 +1,184 @@ # AI Capabilities & System Architecture Blueprint -This document is the authoritative system manual for AI development agents (Claude Code, Cursor, Copilot) and human contributors to safely extend, modify, and optimise the **KShield** without introducing breaking structural changes. +This document is the authoritative system manual for AI development agents (Claude Code, Cursor, Copilot) and human contributors to safely extend, modify, and optimise **KShield** without introducing breaking structural changes. + +_Last verified against the codebase: 2026-07-19 (`dev` branch). If you change a schema, file, or stack item described here, update this document in the same change — a stale manual is worse than no manual._ --- ## 1. Rust CLI & Terminal Engine (`/cli`) -**Stack:** Rust · Cargo · Ratatui · Crossterm · Reqwest · Tokio +**Stack:** Rust · Clap (derive) · Reqwest (rustls-tls) · Tokio · Anyhow · Serde / serde_json / serde_yaml + +Output is plain ANSI colour codes printed directly (`src/ui.rs`) — there is no TUI framework (no Ratatui, no Crossterm) anywhere in this binary. Don't introduce one without discussing it; the terminal output is intentionally simple, scriptable, and log-friendly. ### File Responsibilities | File | Owns | |---|---| -| `src/main.rs` | Entry point, arg parsing, pre-commit hook exit-code chain | -| `src/ui.rs` | All Ratatui draw calls, layout constraints, colour palette | -| `src/scanner.rs` | Git staging ingress (`git diff --cached`), file chunking | -| `src/http.rs` | Non-blocking Reqwest calls to backend on `http://localhost:8000` | +| `src/main.rs` | Entry point, `clap` subcommand parsing, exit-code chain | +| `src/setup.rs` | Backend lifecycle — download, venv creation, pip install, start/stop, PID file | +| `src/http.rs` | Reqwest calls to the backend (`health_check`, `scan_file`, `audit_repo`) | +| `src/scanner.rs` | Git staged-file reader (`git diff --cached`), tracked-file reader for `agent` — every git subprocess is pinned to the repo root via `.current_dir()`, deliberately (see below) | +| `src/ui.rs` | All ANSI colour output — `print_blocked`, `print_clean`, `print_audit_summary`, etc. | +| `src/types.rs` | Shared types mirroring the backend's JSON contracts (`Anomaly`, `Remediation`, `AuditFinding`, `AuditResult`) | +| `src/config.rs` | `.kshield.yml` suppress-config parsing | + +### Commands + +`init` · `setup` · `start` · `stop` · `status` · `hook` · `scan ` · `agent ` + +`hook` and `scan` call `POST /api/v1/scan` (single file). `agent` reads every git-tracked file in the current repo and calls `POST /api/v1/audit` (repo-wide) — this is the CLI entry point for the graph-aware audit engine described in §2b. `org-audit.sh` at the repo root chains `kshield agent` across every non-archived repo in a GitHub org, one clone per repo, then reports from the `audit_runs` table. ### Extension Rules -- **Adding a new terminal screen**: add a new `render_*` function in `src/ui.rs` using `ratatui::layout::Constraint` arrays. Never hard-code pixel sizes — use percentage or min/max constraints so compact terminals don't clip. -- **Changing exit behaviour**: the hook **must exit `1`** when any `CRITICAL` or `HIGH` finding is unmitigated. The exit-code chain lives in `src/main.rs`. Do not swallow errors silently. -- **HTTP timeouts**: `src/http.rs` enforces a 5-second connect timeout and 30-second read timeout via `reqwest::ClientBuilder`. Do not raise these — CI runners must not hang on an unavailable backend. -- **Adding a new diff view column**: update the column width ratio in the `Layout::default().constraints()` call inside `src/ui.rs`. Always keep left/right panes equal-width for the unified diff view. +- **Adding a new terminal view**: add a `print_*` function in `src/ui.rs` using plain `println!` + the `RESET`/colour constants already defined there. Do not introduce a rendering framework. +- **Changing exit behaviour**: `hook` and `agent` **must exit `1`** when any `CRITICAL` or `HIGH` finding is unmitigated (see `cmd_hook`/`cmd_agent` in `main.rs`). Do not swallow errors silently. +- **Changing the finding schema**: `AuditFinding`/`Anomaly` in `src/types.rs` must mirror the backend's JSON response *field for field*, including the `remediation: Remediation` struct — if you add a field to a backend finding dict, add it here too or `serde` will silently drop it. +- **Never trust a git subcommand's default path-relativity.** `git diff --cached --name-only` is repo-root-relative by default; `git ls-files` is cwd-relative by default (both scope *and* format) — two different defaults for two very similar-looking commands. A real bug shipped because of this: `kshield agent` run from any subdirectory silently audited only that subdirectory, and `git show :{filename}` (used to read a staged blob) interpreted an already-root-relative path as cwd-relative and could fail to find files outside the invoking directory. Every git subprocess in `scanner.rs` now runs with `.current_dir(&root)` explicitly — don't add a new git call there without doing the same, and don't assume any git subcommand's default matches another's. --- ## 2. Asynchronous FastAPI Core (`/backend/app`) -**Stack:** Python 3.12 · FastAPI · Asyncio · HTTPX · SQLAlchemy (async) · Pydantic v2 +**Stack:** Python 3.11 · FastAPI · Asyncio · HTTPX · SQLAlchemy 2.0 (async) · Pydantic v2 · NumPy -### File Responsibilities +`tensorflow` is listed in `requirements.txt` but is **not imported anywhere in `app/`** — there is no ML model. `model.py`'s "classifier" is a hardcoded regex list. Don't build on the assumption that a model exists; if you actually need a real classifier, that's new work, not an extension of existing code. + +### Two pipelines, two schemas + +There are **two distinct entry points** with different data shapes — do not conflate them. + +**Finding dicts (internal, produced by every `engine/*.py` module and consumed by `ksword.py`):** +```python +{"line_number": int, "anomaly_type": str, "severity": str, "description": str, "code_snippet": str} +# + "filename": str (present on every finding once it leaves orchestrator.py / audit.py) +``` +Valid `severity`: `"CRITICAL"` · `"HIGH"` · `"MEDIUM"` · `"LOW"`. There is no `"INFO"` level anywhere in this codebase. + +**API response shape (`POST /api/v1/scan`, `POST /api/v1/audit`) — renamed for the client contract:** +```python +{"line": int, "type": str, "severity": str, "description": str, "remediation": {"explanation": str, "patch_diff": str}} +``` +The rename happens in `app/api/v1/scan.py`'s `computed_vulnerabilities` construction. If you change either shape, update the other side of the rename *and* `cli/src/types.rs`, `frontend/src/types/scan.ts`, and `vscode-extension/src/types.ts` in the same change. + +### File Responsibilities — single-file pipeline (`POST /api/v1/scan`) | File | Owns | |---|---| -| `app/main.py` | FastAPI app factory, lifespan handler, middleware | -| `app/api/v1/scan.py` | POST `/v1/scan` route — orchestrates all engine calls | -| `app/engine/ast_rules.py` | Python AST visitor rules | -| `app/engine/entropy.py` | Regex patterns + Shannon entropy scanner | -| `app/engine/sandbox.py` | Async PyPI / npm registry verification | -| `app/engine/remediation.py` | Unified diff patch builder + ELI5 text generator | -| `app/engine/model.py` | Local TensorFlow token classifier | -| `app/db/session.py` | Async SQLAlchemy engine + session factory | +| `app/main.py` | FastAPI app factory, lifespan (`init_db`/`close_db`), CORS | +| `app/api/v1/scan.py` | Route orchestration: entropy + AST + hallucination + sandbox + ksword, persists `Scan`/`Vulnerability` rows | +| `app/engine/entropy.py` | Named-token secret regexes + Shannon-entropy fallback scanner | +| `app/engine/ast_rules.py` | Single-file AST visitor — Broken Access Control, Syntax Violation | +| `app/engine/model.py` | Regex-based "AI Structural Hallucination" pattern matcher (not ML) | +| `app/engine/sandbox.py` | Async PyPI / npm / Go proxy / RubyGems registry verification, with a `collect_only` batching mode used by the audit pipeline | -### Extension Rules +### File Responsibilities — repo-wide audit pipeline (`POST /api/v1/audit`, i.e. `kshield agent`) -**Adding a new AST rule** -1. Open `app/engine/ast_rules.py`. -2. Subclass `ast.NodeVisitor`. -3. Your `visit_*` method must `append` to `self.findings` using exactly this schema: - ```python - {"line": int, "type": str, "severity": str, "description": str} - ``` - Valid `severity` values: `"CRITICAL"` · `"HIGH"` · `"MEDIUM"` · `"LOW"` · `"INFO"` -4. Register the visitor in the `run_ast_checks(source: str)` coordinator function at the bottom of the file. - -**Adding a new registry mirror (e.g., Cargo, RubyGems)** +| File | Owns | +|---|---| +| `app/api/v1/audit.py` | Route orchestration for `run_audit`, persists `AuditRun` rows, attaches ksword remediation per finding | +| `app/engine/graph_builder.py` | Builds a `RepoGraph` (imports, symbols, routes, parse errors) from every file in one pass — Python via `ast`, JS/TS via regex | +| `app/engine/access_control.py` | Graph-aware Broken Access Control — same-file guard reuse, public-path exemption, sensitive-path severity escalation, webhook signature-guard recognition | +| `app/engine/orchestrator.py` | `run_audit()` — sequences graph build → access control → dependency registry checks (batched, concurrency-bounded) → per-file entropy/hallucination/dependency scans → suppression pass | +| `app/engine/dependency_audit.py` | Declared-dependency parsing (`requirements.txt` / `pyproject.toml` / `package.json`) + Levenshtein-distance typosquat detection against a curated popular-package list | +| `app/engine/quiet_office.py` | Signature-based false-positive suppression — a dismissed finding's normalized description is remembered and matched against future findings of the same `anomaly_type` | +| `app/engine/pattern_archive.py` | Persists repo-specific extra auth-keyword strings (via `Configuration` rows) so `access_control.py` learns project-specific guard names over time | + +### Remediation (`app/engine/ksword.py`) + +**Not an LLM agent and not a template engine.** Every strategy runs propose → apply-in-memory → re-run-the-same-check-that-raised-the-finding → only return the diff if that re-check confirms the finding actually cleared. No network calls, no model inference. This replaced the old `app/engine/remediation.py`, which fabricated patches referencing symbols (`AuthenticationGuard`) that didn't exist in the target file and corrupted files on `git apply` — the verify-before-return step is the entire point; do not add a strategy that skips it. + +Current strategies: secret → `os.getenv(...)` extraction (Hardcoded Secret / High Entropy Credential), access-control fix by reusing a `Depends()`/`Security()` guard already proven elsewhere in the same file (never invents a guard name), typosquat import correction (Possible Typosquat). Everything else (Dependency Hallucination, Syntax Violation, AI Structural Hallucination, Undeclared Dependency) is explanation-only by design — a rule engine cannot safely fabricate business logic or guess a real package name, so it says so instead of guessing. + +The access-control strategy's "guard already proven elsewhere in the same file" check is stricter than it sounds: the reused name must also pass `access_control.py`'s own `_AUTH_KEYWORDS`/`_looks_like_auth` filter, not just match the shape `Depends(name)`. A real bug (found dogfooding `kshield agent` on this repo) reused `get_db_session` as a "guard" — a genuine `Depends()` call, just not an auth one — because nothing checked whether `name` actually looked auth-related; the patch applied cleanly and even "passed verification." That's because verification, at the time, went through `ast_rules.py`'s checker, which has the identical blind spot (any `Depends()` counts as a guard). Verification now goes through `access_control.py`'s graph-aware `check_access_control` instead — if you add a new access-control remediation path, verify against that checker, not `ast_rules.py`'s simpler one. + +**Adding a new remediation strategy**: add a `_fix_()` proposer and a `_verify__fixed()` checker in `ksword.py`, wire both into `construct_remediation_patch()`'s dispatch. The verify function must call back into the *same engine function* that produces that finding type — never assert correctness by inspecting your own patch's text. + +**Adding a new AST-based finding (single-file path)** +1. Open `app/engine/ast_rules.py`, subclass/extend `ast.NodeVisitor`. +2. Append to the violations list using the internal schema above — `line_number`/`anomaly_type`, not `line`/`type`. +3. If the same class of finding should also run repo-wide with graph context, add the graph-aware version to `access_control.py` (or a new `engine/*.py` module) and wire it into `orchestrator.py::run_audit`, not `ast_rules.py` — the two pipelines intentionally don't share detection code (see the comment block at the top of `orchestrator.py`). + +**Adding a new registry mirror (e.g., Cargo, RubyGems is already done)** 1. Open `app/engine/sandbox.py`. -2. Add an async function that uses a shared `httpx.AsyncClient` (the module-level singleton). -3. Cache responses with the in-memory `TTLCache` using the package name as the key — never hit a registry twice for the same package in one scan session. -4. Return `{"package": str, "exists": bool, "registry": str}`. +2. Add a case to `evaluate_dependency_hallucinations` using the existing `_check_cached` helper (handles both the direct-check and `collect_only`-batching modes automatically — don't call `_check_registry` directly from a new code path or you'll break the audit pipeline's bulk pre-check). +3. Return findings using the internal schema (`anomaly_type: "Dependency Hallucination"`, `severity: "CRITICAL"`). -**Changing the scan route response shape** -- The Pydantic response model is defined in `app/api/v1/scan.py`. Update it there, then update the matching TypeScript type in `frontend/src/types/scan.ts` and the Rust struct in `cli/src/http.rs`. +**Changing the scan or audit response shape** +- `app/api/v1/scan.py` and `app/api/v1/audit.py` own their respective Pydantic response shapes. Update the matching TypeScript type in `frontend/src/types/scan.ts`, `vscode-extension/src/types.ts`, and the Rust structs in `cli/src/types.rs` in the same change. --- -## 3. Database Vector Topology (`/backend/app/models`, PostgreSQL + pgvector) +## 3. Database (`/backend/app/models`, `/backend/app/db`) -**Stack:** PostgreSQL 16 · pgvector · asyncpg · SQLModel · Alembic +**Stack:** SQLAlchemy 2.0 async (declarative `Base`, no ORM abstraction layer like SQLModel) · `aiosqlite` (local/managed installs) · `asyncpg` + pgvector (production). There is **no Alembic** in this codebase — SQLite schemas are created via `Base.metadata.create_all` plus a small hand-written additive migration list in `app/db/session.py::_sqlite_migrate`; PostgreSQL uses the plain SQL in `backend/migrations/init.sql`. ### Schema Overview | Model file | Table | Notes | |---|---|---| -| `models/scans.py` | `scans` | One row per scan request, links to N vulnerabilities | -| `models/vulnerabilities.py` | `vulnerabilities` | Finding rows; carries 1536-dim vector embedding | -| `models/false_positives.py` | `false_positives` | Developer-confirmed FP overrides | -| `models/configurations.py` | `configurations` | Per-repo rule enable/disable flags | +| `models/scans.py` | `scans` | One row per `/api/v1/scan` request | +| `models/audit_runs.py` | `audit_runs` | One row per `kshield agent` run — file/finding/severity counts, read by `org-audit.sh` | +| `models/vulnerabilities.py` | `vulnerabilities` | Finding rows; carries a `Vector(1536)` embedding | +| `models/false_positives.py` | `false_positives` | Global rule suppressions (`file_signature="*"`) and quiet-office signature dismissals (`signature` column) | +| `models/configurations.py` | `configurations` | Key/value store — currently used only for `pattern_archive.py`'s extra auth keywords | ### Extension Rules -- **Vector dimension is fixed at 1536.** If you swap the embedding source (e.g., replace the local model with an external LLM), you must update the `Vector(1536)` column type in `models/vulnerabilities.py` **and** the `init.sql` column definition to the new dimension before running migrations. -- **Cosine similarity queries** must use the SQLAlchemy vector operator wrapper, not raw SQL strings: - ```python - # Correct - stmt = select(Vulnerability).order_by(Vulnerability.embedding.cosine_distance(query_vec)).limit(5) - # Wrong — breaks portability - stmt = text("SELECT * FROM vulnerabilities ORDER BY embedding <=> :v LIMIT 5") - ``` -- **Telemetry pipeline**: The optional anonymised telemetry serialiser lives in `app/engine/telemetry.py`. It strips file paths and repo identifiers before serialising. Never add fields that could leak user identity. The pipeline is opt-in via `TELEMETRY_ENABLED=true` env var. +- **The 1536-dim embedding is not semantically meaningful today.** `model.py::generate_embedding_vector` seeds a random vector from an MD5 hash of the finding's description text — it is deterministic (same text → same vector) but carries no real semantic signal, so any cosine-similarity feature built on `vulnerabilities.embedding` today is comparing structured noise. If you build a real semantic-search feature, replace this generator first; don't assume the column already holds something meaningful. +- **Vector dimension is fixed at 1536.** If you do wire up a real embedding source, update `Vector(1536)` in `models/vulnerabilities.py` **and** `migrations/init.sql` together. +- **SQLite additive changes**: add new `ALTER TABLE` statements to `_sqlite_migrate`'s list in `app/db/session.py` — they run inside a `try/except: pass` so they're safe to re-run against an already-migrated DB. --- -## 4. Tauri + React Dashboard (`/frontend`) +## 4. React Dashboard + Tauri (`/frontend`, `/src-tauri`) -**Stack:** Vite · React 18 · TypeScript · Tailwind CSS · Tauri v2 +**Stack:** Vite · React 19 · TypeScript · Tailwind CSS v4 · Tauri 2 + +The dashboard is a real client of the live backend, not a mock — `frontend/src/api/client.ts` calls `GET /api/v1/scans`, `GET /api/v1/telemetry`, `POST /api/v1/suppress`, `POST /api/v1/apply-patch` against `http://localhost:8000`. ### File Responsibilities | File | Owns | |---|---| -| `src/App.tsx` | Root router, global providers | -| `src/components/Dashboard.tsx` | Main scan results view | -| `src/components/Settings.tsx` | Rule configuration UI | -| `src-tauri/src/main.rs` | Tauri commands, IPC bridge | -| `src-tauri/tauri.conf.json` | Window config, allowed domains, CSP | +| `src/App.tsx` | Root layout, view routing | +| `src/components/Dashboard.tsx` | Main scan-history view, polling (`fetchData`), toast state, patch-apply flow | +| `src/components/Settings.tsx` | Backend URL + rule configuration UI | +| `src/components/Docs.tsx` | In-app "How to Use" — User Guide + API Reference tabs | +| `src/design-system/` | Token file + component library (`Badge`, `Button`, `Card`, `CodeBlock`, `Drawer`, etc.) | +| `src-tauri/src/main.rs` | Tauri commands, IPC bridge (desktop packaging is not yet finished — see README roadmap) | ### Extension Rules -- **All OS-level operations** (file I/O, process spawning) must go through a Tauri command declared in `src-tauri/src/main.rs` and invoked via `invoke()` from `@tauri-apps/api/core`. Never call Node.js APIs or shell exec from the React layer. -- **API keys and secrets** must never appear in the frontend bundle. Any backend URL override goes in the Tauri `allowlist` config — not in a `.env` file committed to the repo. -- **Theme**: The colour system is defined in `tailwind.config.js` under the `dark` variant. All new components must use CSS variable tokens (`bg-surface`, `text-primary`, etc.) — never hardcode hex values. This keeps both the browser and the Tauri window frame visually consistent. -- **Adding a new dashboard panel**: create a component in `src/components/`, register a route in `App.tsx`, and add a nav entry in `src/components/Sidebar.tsx`. Do not break the existing route structure. +- **All OS-level operations** (file I/O, process spawning) must go through a Tauri command in `src-tauri/src/main.rs`, invoked via `invoke()` from `@tauri-apps/api/core`. Never call Node.js APIs or shell exec from the React layer. +- **Styling**: use the design-system tokens in `src/design-system/tokens.ts`, not raw hex values, so light/dark theming stays consistent between the browser and the Tauri window. +- **Adding a new dashboard panel**: create a component in `src/components/`, wire it into `App.tsx`, add a nav entry in `src/components/Sidebar.tsx`. +- **Adding a new backend call**: add it to `src/api/client.ts`'s `api` object — every dashboard data fetch goes through that one file, not ad-hoc `fetch()` calls scattered across components. --- -## 5. GitHub Actions CI Pipeline (`.github/workflows/kshield-ci.yml`) +## 5. GitHub Actions CI Pipeline (`.github/workflows/ci.yml`) + +**Trigger:** `push` to `dev`, `pull_request` targeting `dev`/`master`. + +### What it actually does -**Trigger:** `pull_request` on `main` and `develop` +Four independent jobs — `cli` (cargo build + test), `backend` (pip install, an import-sanity check, `pytest`), `frontend` (`tsc --noEmit` + `npm run build`), `vscode-extension` (`npm run compile`) — plus a fifth, `pr-scan`, that only runs on pull requests: -### What it does +1. Checks out the PR branch with full history (`fetch-depth: 0`). +2. Computes `git diff --name-only origin/...HEAD`, filtered to real files (excludes lockfiles). +3. Starts the backend (`SQLITE_FALLBACK=true uvicorn`) directly on the runner — no self-hosted infrastructure, no Docker. +4. For each changed file, `POST`s it to `http://127.0.0.1:8000/api/v1/scan` and collects `.anomalies`. +5. Posts one PR review via `actions/github-script` + `github.rest.pulls.createReview`, with one inline comment per finding. -1. Checks out the PR branch. -2. Identifies changed files using `git diff --name-only origin/main...HEAD`. -3. Sends the changed file payloads to a self-hosted runner running the Docker stack. -4. Receives structured findings JSON from the backend. -5. Posts inline PR review comments via the GitHub REST API (`POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews`). +There is a separate `release.yml` (tag-triggered multi-platform binary + PyPI build), `deploy-pages.yml` (frontend → GitHub Pages), and `codeql.yml` (weekly + push/PR static analysis, scoped to `python` and `javascript-typescript` — CodeQL has no Rust support, so the CLI's only coverage is `cargo build`/`cargo test` in `ci.yml`) — not covered in detail here. ### Extension Rules -- **Adding a new severity label**: update the `SEVERITY_EMOJI` map inside the workflow's inline Python script, then update the matching constant in `cli/src/main.rs` to keep exit-code behaviour aligned. -- **Changing the backend URL**: set the `KSHIELD_BACKEND` repository secret — never hard-code the host in the YAML file. -- **Matrix builds** (multi-language repos): extend the `strategy.matrix.language` array and add a corresponding `if:` condition guard on the relevant scan step. +- **The backend `import check` step in `ci.yml` explicitly imports from `app.engine.ksword` and `app.engine.orchestrator`.** If you rename or remove either module, this step breaks the whole `backend` job — update it in the same change (this exact class of bug is what broke the old `remediation.py` reference here before it was fixed). +- **Changing the backend URL for `pr-scan`**: the job starts its own backend on the runner; there is no `KSHIELD_BACKEND` secret to configure for CI today. +- **Adding a new language to the audit matrix**: extend `sandbox.py`/`dependency_audit.py` per §2, not this workflow file — `pr-scan` calls the generic `/api/v1/scan` endpoint and doesn't need per-language CI changes. --- @@ -151,13 +197,14 @@ This document is the authoritative system manual for AI development agents (Clau | `src/codeActionProvider.ts` | Quick Fix actions — apply patch / suppress rule | | `src/patch.ts` | Applies unified-diff `patch_diff` strings from remediation findings | | `src/statusBar.ts` | Backend reachability indicator | +| `src/types.ts` | Mirrors the `/api/v1/scan` response contract — keep in lockstep with `cli/src/types.rs` and `frontend/src/types/scan.ts` | ### Extension Rules -- **The extension never bundles or starts the backend.** It only talks to it over HTTP at `kshield.backendUrl` (default `http://127.0.0.1:8000`). Do not add process-spawning logic here — that belongs to the CLI (`cli/src/setup.rs`). -- **Packaging**: `package.json` must keep a valid `repository` field and the package must ship with a `LICENSE` file (copied from the repo root) — `vsce package` treats both as required for a warning-free `.vsix`. Do not remove either without also updating `.vscodeignore`. -- **Auto-apply is patch-only**: only findings carrying a `patch_diff` (currently Broken Access Control) can go through `codeActionProvider.ts`'s apply-fix path. All other finding types must fall back to "Suppress This Rule" — do not fabricate a patch for finding types the backend doesn't provide one for. -- **Changing the finding schema**: if `app/api/v1/scan.py`'s response model changes, update `src/types.ts` in lockstep (mirrors the same contract used by `frontend/src/types/scan.ts` and `cli/src/types.rs`). +- **The extension never bundles or starts the backend.** It only talks to it over HTTP at `kshield.backendUrl` (default `http://127.0.0.1:8000`). Process-spawning belongs to the CLI (`cli/src/setup.rs`). +- **Packaging**: `package.json` must keep a valid `repository` field and the package must ship with a `LICENSE` file (copied from the repo root) — `vsce package` treats both as required for a warning-free `.vsix`. +- **Auto-apply is patch-only, not type-specific.** `codeActionProvider.ts`'s apply-fix path should key off whether a finding's `remediation.patch_diff` is non-empty, not off a hardcoded list of finding types — ksword decides per-finding, per-file whether a safe patch exists (e.g. Broken Access Control now only gets a patch when a reusable guard exists elsewhere in the same file), and that decision can change file-to-file. All findings without a patch fall back to "Suppress This Rule". +- **Changing the finding schema**: if `app/api/v1/scan.py`'s response model changes, update `src/types.ts` in lockstep with `frontend/src/types/scan.ts` and `cli/src/types.rs`. --- @@ -165,9 +212,9 @@ This document is the authoritative system manual for AI development agents (Clau | # | Rule | |---|---| -| 1 | CLI must exit `1` on any unmitigated `CRITICAL` or `HIGH` finding. | -| 2 | Vector dimension is 1536 across Python model, SQL schema, and any external embedding provider. | -| 3 | Registry sandbox must cache per-session — never make two HTTP requests for the same package name. | -| 4 | All OS calls from the frontend go through Tauri IPC — never direct. | -| 5 | AST rule output schema `{line, type, severity, description}` is the contract between engine and API; changing it breaks the CLI renderer and the GitHub annotation poster simultaneously. | -| 6 | Telemetry serialiser must strip all path and identity fields before transmission. | +| 1 | CLI must exit `1` on any unmitigated `CRITICAL` or `HIGH` finding, for both `hook`/`scan` and `agent`. | +| 2 | Internal engine findings use `{line_number, anomaly_type, severity, description, code_snippet}`; the `/api/v1/scan` and `/api/v1/audit` JSON response renames to `{line, type, severity, description, remediation}`. Changing either shape without updating the other breaks the CLI renderer, the dashboard, the VS Code extension, and the CI PR-comment poster simultaneously. | +| 3 | `ksword.py` must never return a non-empty `patch_diff` without having re-run the originating check against the patched content and confirmed the finding cleared. This is the one invariant that exists because it was broken before — do not regress it. | +| 4 | Registry sandbox checks (`sandbox.py`) must be cached per audit run via `registry_cache` — never issue two HTTP requests for the same package name within one `/api/v1/audit` call. | +| 5 | All OS-level calls from the frontend go through Tauri IPC — never direct Node.js/`fetch`-to-filesystem tricks. | +| 6 | Vector dimension is 1536 across the Python model and SQL schema — but see §3: this column is not currently populated with a real embedding, so don't build features that assume otherwise without fixing the generator first. | diff --git a/backend/Dockerfile b/backend/Dockerfile index de3cdbe..c347043 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,8 @@ -FROM python:3.12-slim +FROM python:3.11-slim WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ diff --git a/backend/app/api/v1/actions.py b/backend/app/api/v1/actions.py index 2af3bb0..95780be 100644 --- a/backend/app/api/v1/actions.py +++ b/backend/app/api/v1/actions.py @@ -65,7 +65,7 @@ async def apply_patch(body: ApplyPatchRequest, db: AsyncSession = Depends(get_db raise HTTPException(status_code=404, detail="Scan record not found") # Re-generate the patch (we don't store the full patch in the DB) - from app.engine.remediation import construct_remediation_patch + from app.engine.ksword import construct_remediation_patch # Try to find the file relative to common repo roots filename = scan.filename @@ -77,20 +77,17 @@ async def apply_patch(body: ApplyPatchRequest, db: AsyncSession = Depends(get_db file_path = next((p for p in candidates if p.exists()), None) if not file_path: - # Can't find file — return the patch diff for the client to apply manually - try: - content = "" - patch = construct_remediation_patch(filename, content, vuln.anomaly_type, vuln.line_number) - except Exception: - patch = {"explanation": "", "patch_diff": ""} + # Can't find the file on disk at all — no content to safely patch against. return { "status": "patch_only", "message": "File not found on disk — copy the patch and apply manually.", - "patch_diff": patch["patch_diff"], + "patch_diff": "", } content = file_path.read_text() - patch = construct_remediation_patch(str(file_path), content, vuln.anomaly_type, vuln.line_number) + patch = construct_remediation_patch( + str(file_path), content, vuln.anomaly_type, vuln.line_number, vuln.description + ) if not patch["patch_diff"]: return {"status": "no_patch", "message": "No automated patch available for this finding."} diff --git a/backend/app/api/v1/audit.py b/backend/app/api/v1/audit.py new file mode 100644 index 0000000..d4ec37a --- /dev/null +++ b/backend/app/api/v1/audit.py @@ -0,0 +1,123 @@ +import uuid +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import get_db_session +from app.engine.orchestrator import run_audit +from app.engine.quiet_office import record_dismissal +from app.engine.ksword import construct_remediation_patch +from app.models.audit_runs import AuditRun +from app.api.v1.scan import SuppressConfig + +router = APIRouter(prefix="/audit", tags=["Repo-Wide Audit"]) + + +class AuditFile(BaseModel): + filename: str = Field(...) + content: str = Field(...) + + +class AuditRequest(BaseModel): + name: str = Field(...) + files: list[AuditFile] = Field(...) + suppress: SuppressConfig = Field(default_factory=SuppressConfig) + + +class DismissFindingRequest(BaseModel): + description: str = Field(...) + anomaly_type: str = Field(...) + justification: str = Field(default="") + + +@router.post("", response_model=dict) +async def process_repo_audit(payload: AuditRequest, db: AsyncSession = Depends(get_db_session)): + file_tuples = [(f.filename, f.content) for f in payload.files] + result = await run_audit(file_tuples, db, payload.suppress.model_dump()) + graph = result["graph"] + all_findings = result["findings"] + + active_findings = [f for f in all_findings if not f.get("suppressed")] + quieted_findings = [f for f in all_findings if f.get("suppressed")] + + content_by_file = {f.filename: f.content for f in payload.files} + for finding in active_findings: + content = content_by_file.get(finding.get("filename")) + finding["remediation"] = ( + construct_remediation_patch( + finding["filename"], content, finding["anomaly_type"], + finding["line_number"], finding.get("description", ""), + ) + if content is not None + else {"explanation": "", "patch_diff": ""} + ) + + db.add(AuditRun( + id=str(uuid.uuid4()), + name=payload.name, + file_count=len(graph.files), + findings_count=len(active_findings), + critical_count=sum(1 for f in active_findings if f["severity"] == "CRITICAL"), + high_count=sum(1 for f in active_findings if f["severity"] == "HIGH"), + )) + + return { + "name": payload.name, + "file_count": len(graph.files), + "route_count": len(graph.routes), + "symbol_count": sum(len(v) for v in graph.symbols.values()), + "import_edge_count": sum(len(v) for v in graph.imports.values()), + "parse_errors": graph.parse_errors, + "findings_count": len(active_findings), + "quieted_count": len(quieted_findings), + "findings": active_findings, + "quieted": quieted_findings, + "routes": [ + { + "path": r.path, + "method": r.method, + "handler": r.handler, + "filename": r.filename, + "line": r.line, + "decorators": r.decorators, + } + for r in graph.routes + ], + } + + +@router.post("/dismiss-finding", response_model=dict) +async def dismiss_finding(payload: DismissFindingRequest, db: AsyncSession = Depends(get_db_session)): + """Marks a specific finding as a false positive. Future findings with a + closely matching description (same anomaly_type) get auto-quieted instead + of re-flagged — see quiet_office.py.""" + await record_dismissal(payload.description, payload.anomaly_type, payload.justification, db) + return {"status": "dismissed", "anomaly_type": payload.anomaly_type} + + +@router.get("/runs", response_model=dict) +async def list_audit_runs(db: AsyncSession = Depends(get_db_session)): + """Every past run, sorted so the repos that need attention first come + first — CRITICAL findings weighted above HIGH.""" + result = await db.execute(select(AuditRun)) + runs = result.scalars().all() + + weighted = sorted( + runs, + key=lambda r: (r.critical_count * 3 + r.high_count), + reverse=True, + ) + + return { + "runs": [ + { + "name": r.name, + "file_count": r.file_count, + "findings_count": r.findings_count, + "critical_count": r.critical_count, + "high_count": r.high_count, + "created_at": r.created_at.isoformat(), + } + for r in weighted + ] + } diff --git a/backend/app/api/v1/patterns.py b/backend/app/api/v1/patterns.py new file mode 100644 index 0000000..6a7ca93 --- /dev/null +++ b/backend/app/api/v1/patterns.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.session import get_db_session +from app.engine.pattern_archive import load_extra_auth_keywords, add_extra_auth_keyword + +router = APIRouter(prefix="/patterns", tags=["Pattern Archive"]) + + +class AuthKeywordRequest(BaseModel): + keyword: str = Field(...) + + +@router.get("/auth-keywords", response_model=dict) +async def list_auth_keywords(db: AsyncSession = Depends(get_db_session)): + return {"keywords": await load_extra_auth_keywords(db)} + + +@router.post("/auth-keywords", response_model=dict) +async def add_auth_keyword(payload: AuthKeywordRequest, db: AsyncSession = Depends(get_db_session)): + keywords = await add_extra_auth_keyword(payload.keyword, db) + return {"keywords": keywords} diff --git a/backend/app/api/v1/scan.py b/backend/app/api/v1/scan.py index 391cab6..2ab4353 100644 --- a/backend/app/api/v1/scan.py +++ b/backend/app/api/v1/scan.py @@ -9,7 +9,7 @@ from app.engine.ast_rules import run_ast_structural_scan from app.engine.model import sequence_classifier_node from app.engine.sandbox import evaluate_dependency_hallucinations -from app.engine.remediation import construct_remediation_patch +from app.engine.ksword import construct_remediation_patch from app.engine.suppress import apply as apply_suppressions from app.models.scans import Scan from app.models.vulnerabilities import Vulnerability @@ -80,7 +80,8 @@ async def process_code_pipeline_evaluation(payload: ScanRequest, db: AsyncSessio payload.filename, payload.content, issue["anomaly_type"], - issue["line_number"] + issue["line_number"], + issue["description"], ) if not is_suppressed else {"explanation": "", "patch_diff": ""} embedding_matrix = sequence_classifier_node.generate_embedding_vector(issue["description"]) diff --git a/backend/app/db/session.py b/backend/app/db/session.py index fb5f08c..b7092f9 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -8,7 +8,9 @@ if _SQLITE_FALLBACK: _db_dir = Path.home() / ".kshield" _db_dir.mkdir(parents=True, exist_ok=True) - DATABASE_URL = f"sqlite+aiosqlite:///{_db_dir}/kshield.db" + # DATABASE_URL override lets tests point at an isolated DB (e.g. in-memory) + # instead of the real local install at ~/.kshield/kshield.db. + DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite+aiosqlite:///{_db_dir}/kshield.db") else: DATABASE_URL = os.getenv( "DATABASE_URL", @@ -41,6 +43,7 @@ async def _sqlite_migrate(conn) -> None: """Apply additive schema changes to existing SQLite DBs that predate them.""" migrations = [ "ALTER TABLE vulnerabilities ADD COLUMN suppressed BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE false_positives ADD COLUMN signature TEXT", ] for sql in migrations: try: @@ -51,7 +54,7 @@ async def _sqlite_migrate(conn) -> None: async def init_db() -> None: if _SQLITE_FALLBACK: - from app.models import scans, vulnerabilities, false_positives, configurations # noqa: F401 + from app.models import scans, vulnerabilities, false_positives, configurations, audit_runs # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) await _sqlite_migrate(conn) diff --git a/backend/app/engine/access_control.py b/backend/app/engine/access_control.py new file mode 100644 index 0000000..f2df4ec --- /dev/null +++ b/backend/app/engine/access_control.py @@ -0,0 +1,76 @@ +from app.engine.access_patterns import PUBLIC_PATHS, MUTATION_METHODS +from app.engine.graph_builder import RouteInfo + +# Starter list — a name matching any of these is treated as a real auth guard, +# not just "some dependency exists". Deliberately excludes generic words like +# "user" or "session" that show up in unrelated dependencies (get_db_session). +_AUTH_KEYWORDS = ( + "auth", "current_user", "require_auth", "login", "permission", + "token", "credential", "oauth", "jwt", "authorize", "authenticate", + "access_control", "verify_user", "tenant", "signature", "hmac", +) + +# Path segments that mark a route as touching something worth more than a shrug — +# an unguarded /admin or /payment route is a different kind of problem than an +# unguarded /widgets route, even if both are technically "unauthenticated GET". +_SENSITIVE_PATH_KEYWORDS = ( + "admin", "payment", "billing", "account", "user", "secret", "credential", + "internal", "config", "export", "delete", "role", "permission", "invoice", +) + +# Escalates one step up the ladder when the route also touches sensitive data. +_SEVERITY_ESCALATION = {"MEDIUM": "HIGH", "HIGH": "CRITICAL"} + + +def _looks_like_auth(name: str, keywords: tuple[str, ...]) -> bool: + lowered = name.lower() + return any(keyword in lowered for keyword in keywords) + + +def _touches_sensitive_data(path: str) -> bool: + lowered = path.lower() + return any(keyword in lowered for keyword in _SENSITIVE_PATH_KEYWORDS) + + +def check_access_control(routes: list[RouteInfo], extra_auth_keywords: tuple[str, ...] = ()) -> list[dict]: + keywords = _AUTH_KEYWORDS + tuple(extra_auth_keywords) + findings = [] + + for route in routes: + if route.path in PUBLIC_PATHS: + continue + + if any(_looks_like_auth(name, keywords) for name in route.guard_names if name): + continue + + is_mutation = route.method.lower() in MUTATION_METHODS + severity = "HIGH" if is_mutation else "MEDIUM" + + is_sensitive = _touches_sensitive_data(route.path) + if is_sensitive: + severity = _SEVERITY_ESCALATION.get(severity, severity) + + path_hint = f" '{route.path}'" if route.path else "" + sensitivity_hint = " This route also touches sensitive data." if is_sensitive else "" + + guard_hint = "" + named_guards = [n for n in route.guard_names if n] + if named_guards: + guard_hint = ( + f" It does have a dependency ({', '.join(named_guards)}), " + f"but that name doesn't look auth-related." + ) + + findings.append({ + "line_number": route.line, + "anomaly_type": "Broken Access Control", + "severity": severity, + "description": ( + f"{route.method} endpoint{path_hint} '{route.handler}' has no authentication guard." + f"{guard_hint}{sensitivity_hint} Add Depends(get_current_user) or a similarly named auth dependency." + ), + "code_snippet": f"def {route.handler}(...):", + "filename": route.filename, + }) + + return findings diff --git a/backend/app/engine/access_patterns.py b/backend/app/engine/access_patterns.py new file mode 100644 index 0000000..f7a5b45 --- /dev/null +++ b/backend/app/engine/access_patterns.py @@ -0,0 +1,9 @@ +PUBLIC_PATHS: frozenset[str] = frozenset([ + "/", "/health", "/healthz", "/health-check", "/healthcheck", + "/ping", "/pong", "/ready", "/readiness", "/liveness", "/alive", + "/docs", "/redoc", "/openapi.json", "/openapi.yaml", + "/metrics", "/status", "/version", "/info", + "/favicon.ico", "/robots.txt", "/sitemap.xml", +]) + +MUTATION_METHODS: frozenset[str] = frozenset(["post", "put", "delete", "patch"]) diff --git a/backend/app/engine/ast_rules.py b/backend/app/engine/ast_rules.py index 832ec80..f74cbe7 100644 --- a/backend/app/engine/ast_rules.py +++ b/backend/app/engine/ast_rules.py @@ -1,16 +1,6 @@ import ast -# Paths that are intentionally public — skip authentication check -_PUBLIC_PATHS: frozenset = frozenset([ - "/", "/health", "/healthz", "/health-check", "/healthcheck", - "/ping", "/pong", "/ready", "/readiness", "/liveness", "/alive", - "/docs", "/redoc", "/openapi.json", "/openapi.yaml", - "/metrics", "/status", "/version", "/info", - "/favicon.ico", "/robots.txt", "/sitemap.xml", -]) - -# HTTP methods that mutate state — unauthenticated access is a harder violation -_MUTATION_METHODS: frozenset = frozenset(["post", "put", "delete", "patch"]) +from app.engine.access_patterns import PUBLIC_PATHS as _PUBLIC_PATHS, MUTATION_METHODS as _MUTATION_METHODS def _route_path(decorator: ast.expr) -> str | None: diff --git a/backend/app/engine/dependency_audit.py b/backend/app/engine/dependency_audit.py new file mode 100644 index 0000000..c04da4f --- /dev/null +++ b/backend/app/engine/dependency_audit.py @@ -0,0 +1,281 @@ +import json +import re +import tomllib + +from app.engine.sandbox import _STDLIB_MODULES + +# A deliberately small, high-confidence list of well-known packages per ecosystem. +# This is what typosquat detection measures distance against — not exhaustive, +# just the names an attacker (or a hallucinating AI) is most likely to imitate. +_POPULAR_PYTHON = frozenset([ + "requests", "numpy", "pandas", "flask", "django", "fastapi", "boto3", + "click", "pytest", "sqlalchemy", "pydantic", "httpx", "uvicorn", "jinja2", + "cryptography", "pillow", "scipy", "matplotlib", "tensorflow", "torch", + "celery", "redis", "psycopg2", "pymongo", "beautifulsoup4", "selenium", + "pyyaml", "aiohttp", "starlette", "alembic", "gunicorn", "black", "flake8", + "mypy", "setuptools", "wheel", "virtualenv", "poetry", "typer", +]) + +_POPULAR_NPM = frozenset([ + "react", "react-dom", "lodash", "axios", "express", "webpack", "typescript", + "eslint", "vue", "angular", "jquery", "moment", "chalk", "commander", + "dotenv", "jest", "babel", "next", "vite", "tailwindcss", "prettier", + "redux", "rxjs", "socket.io", "mongoose", "passport", "cors", "uuid", +]) + +_MANIFEST_BASENAMES = {"requirements.txt", "pyproject.toml", "package.json"} + +# Node core modules — JS's equivalent of Python's stdlib, not installed dependencies. +_NODE_BUILTINS = frozenset([ + "fs", "path", "http", "https", "os", "child_process", "crypto", "events", + "stream", "util", "url", "querystring", "buffer", "net", "tls", "dns", + "cluster", "assert", "readline", "zlib", "vm", "worker_threads", "process", + "module", "timers", "string_decoder", "punycode", "perf_hooks", +]) + +# Provided by a host runtime at execution time rather than installed as a +# package — e.g. "vscode" is injected by the VS Code extension host. +_AMBIENT_MODULES = frozenset(["vscode"]) + + +def _levenshtein(a: str, b: str) -> int: + if a == b: + return 0 + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + curr = [i] + [0] * len(b) + for j, cb in enumerate(b, 1): + cost = 0 if ca == cb else 1 + curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) + prev = curr + return prev[-1] + + +def _normalize(name: str) -> str: + return name.lower().replace("_", "-") + + +def _closest_popular(name: str, popular: frozenset[str]) -> tuple[str, int] | None: + normalized = _normalize(name) + if normalized in popular: + return None + best: tuple[str, int] | None = None + for candidate in popular: + # Skip names too different in length to plausibly be a typo + if abs(len(candidate) - len(normalized)) > 2: + continue + dist = _levenshtein(normalized, candidate) + if dist == 0 or dist > 2: + continue + if best is None or dist < best[1]: + best = (candidate, dist) + return best + + +def _parse_requirements_txt(content: str) -> set[str]: + names = set() + for line in content.splitlines(): + line = line.split("#", 1)[0].strip() + if not line or line.startswith("-"): + continue + pkg = re.split(r"[<>=!~;\[]", line, 1)[0].strip() + if pkg: + names.add(_normalize(pkg)) + return names + + +def _dep_spec_name(spec: str) -> str: + """Extract the bare package name from a PEP 508 dependency string like + "requests>=2.0" or "some-pkg[extra]<2.0,>=1.0".""" + return re.split(r"[<>=!~;\[\s]", spec, 1)[0].strip() + + +def _parse_pyproject_toml(content: str) -> set[str]: + # Real bug found in the adk-python pilot: this was previously skipped + # entirely ("better to under-report than misparse"), so a repo declaring + # dependencies only via pyproject.toml (no requirements.txt) had every + # single non-stdlib import read as "undeclared" — 255 false positives in + # one repo. tomllib (stdlib since Python 3.11) makes real parsing safe. + try: + data = tomllib.loads(content) + except tomllib.TOMLDecodeError: + return set() + + names: set[str] = set() + + project = data.get("project") + if isinstance(project, dict): + for dep in project.get("dependencies") or []: + if isinstance(dep, str): + pkg = _dep_spec_name(dep) + if pkg: + names.add(_normalize(pkg)) + optional = project.get("optional-dependencies") + if isinstance(optional, dict): + for group in optional.values(): + if isinstance(group, list): + for dep in group: + if isinstance(dep, str): + pkg = _dep_spec_name(dep) + if pkg: + names.add(_normalize(pkg)) + + tool = data.get("tool") + poetry = tool.get("poetry") if isinstance(tool, dict) else None + if isinstance(poetry, dict): + for section in ("dependencies", "dev-dependencies"): + deps = poetry.get(section) + if isinstance(deps, dict): + names.update(_normalize(n) for n in deps if n.lower() != "python") + group = poetry.get("group") + if isinstance(group, dict): + for group_data in group.values(): + if not isinstance(group_data, dict): + continue + group_deps = group_data.get("dependencies") + if isinstance(group_deps, dict): + names.update(_normalize(n) for n in group_deps if n.lower() != "python") + + return names + + +def _parse_package_json(content: str) -> set[str]: + try: + data = json.loads(content) + except (json.JSONDecodeError, ValueError): + return set() + names = set() + for key in ("dependencies", "devDependencies"): + names.update(_normalize(n) for n in data.get(key, {}).keys()) + return names + + +def workspace_package_names(files: list[tuple[str, str]]) -> set[str]: + """Package names declared via a package.json's own "name" field. In a + monorepo, a workspace package like "@webstudio-is/template" is imported + with an npm-scoped-looking specifier even though it's this codebase's own + code, not a real external dependency — raw_first_party_names alone can't + catch this since it only matches bare directory/file-stem names, not a + package's self-declared scoped identity. Unnormalized, matching + raw_first_party_names' contract for sandbox.py's raw token comparison.""" + names: set[str] = set() + for filename, content in files: + if filename.rsplit("/", 1)[-1] != "package.json": + continue + try: + data = json.loads(content) + except (json.JSONDecodeError, ValueError): + continue + name = data.get("name") + if isinstance(name, str) and name: + names.add(name) + return names + + +def parse_declared_dependencies(files: list[tuple[str, str]]) -> set[str]: + declared: set[str] = set() + for filename, content in files: + basename = filename.rsplit("/", 1)[-1] + if basename == "requirements.txt": + declared |= _parse_requirements_txt(content) + elif basename == "package.json": + declared |= _parse_package_json(content) + elif basename == "pyproject.toml": + declared |= _parse_pyproject_toml(content) + return declared + + +def raw_first_party_names(imports_by_file: dict[str, list[str]]) -> set[str]: + """Directory names and file stems that exist in this batch, unmodified — + an import matching one of these is this codebase's own code, not a + dependency. Unnormalized so sandbox.py can compare its raw import tokens + directly; _first_party_names below normalizes on top of this for this + module's own (case/dash-insensitive) matching.""" + names: set[str] = set() + for filename in imports_by_file: + parts = filename.split("/") + names.update(parts[:-1]) + names.add(parts[-1].rsplit(".", 1)[0]) + return names + + +def _first_party_names(imports_by_file: dict[str, list[str]]) -> set[str]: + return {_normalize(n) for n in raw_first_party_names(imports_by_file)} + + +def check_dependency_hygiene( + imports_by_file: dict[str, list[str]], + declared: set[str], + registry_verified: dict[str, bool] | None = None, +) -> list[dict]: + """No network call of its own — registry_verified is sandbox.py's already- + resolved cache (package name -> exists on the registry), passed in so an + independently real package isn't also flagged as a typosquat of some + other, unrelated well-known name. Real bug found in the adk-python + pilot: 'retry' (a real PyPI package) is edit-distance 2 from 'poetry', + and 'mcp' (Anthropic's real Model Context Protocol SDK) is edit-distance + 2 from 'mypy' — both genuinely published packages, neither squatting on + the other, just short names close together in edit-distance space.""" + findings: list[dict] = [] + first_party = _first_party_names(imports_by_file) + verified = registry_verified or {} + + for filename, imports in imports_by_file.items(): + if not filename.endswith((".py", ".js", ".jsx", ".ts", ".tsx")): + continue + + popular = _POPULAR_PYTHON if filename.endswith(".py") else _POPULAR_NPM + + for imp in imports: + if imp.startswith((".", "/", "@/")): + continue # relative/local import (or a TS/Vite path alias like + # "@/hooks") — not a package at all, even though it starts + # with "@" the same way a real scoped npm package would + # Modern Node code increasingly uses the "node:" protocol prefix + # for built-ins (e.g. "node:crypto") — same module, different spelling. + bare = imp[5:] if imp.lower().startswith("node:") else imp + if bare.lower() in _STDLIB_MODULES or bare.lower() in _NODE_BUILTINS: + continue + normalized = _normalize(imp) + if ( + normalized in declared + or normalized in popular + or normalized in first_party + or normalized in _AMBIENT_MODULES + ): + continue + + match = None if verified.get(imp) is True else _closest_popular(imp, popular) + if match: + candidate, dist = match + severity = "CRITICAL" if dist == 1 else "HIGH" + findings.append({ + "line_number": 1, + "anomaly_type": "Possible Typosquat", + "severity": severity, + "description": ( + f"Import '{imp}' is not declared as a dependency and is very close " + f"(edit distance {dist}) to the well-known package '{candidate}'. " + f"This may be a typo or a typosquatted package." + ), + "code_snippet": f"import {imp}", + "filename": filename, + }) + else: + findings.append({ + "line_number": 1, + "anomaly_type": "Undeclared Dependency", + "severity": "LOW", + "description": ( + f"Import '{imp}' is used in {filename} but not declared in " + f"requirements.txt or package.json found in this audit." + ), + "code_snippet": f"import {imp}", + "filename": filename, + }) + + return findings diff --git a/backend/app/engine/entropy.py b/backend/app/engine/entropy.py index b03e936..74adaf0 100644 --- a/backend/app/engine/entropy.py +++ b/backend/app/engine/entropy.py @@ -70,6 +70,18 @@ def _is_allowlisted(literal: str) -> bool: return any(p.match(literal) for p in _ALLOWLIST_RE) +def _looks_like_placeholder(matched_text: str) -> bool: + """A named-pattern match normally bypasses entropy scoring entirely + (real secrets are trusted CRITICAL regardless of shape). But a template + value like "sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" matches the shape + while having almost no actual randomness — low character diversity is a + reliable, safe signal for this, since a real generated secret is very + unlikely to repeat characters this much.""" + if not matched_text: + return False + return len(set(matched_text)) / len(matched_text) < 0.3 + + def analyze_entropy_and_secrets(code_data: str) -> List[Dict[str, Any]]: findings: List[Dict[str, Any]] = [] lines = code_data.splitlines() @@ -79,7 +91,8 @@ def analyze_entropy_and_secrets(code_data: str) -> List[Dict[str, Any]]: # 1. Named-pattern (signature) check — CRITICAL; suppresses entropy for this line for name, pattern in SIGNATURE_REGEX.items(): - if re.search(pattern, line): + match = re.search(pattern, line) + if match and not _looks_like_placeholder(match.group(0)): findings.append({ "line_number": idx, "anomaly_type": "Hardcoded Secret", diff --git a/backend/app/engine/graph_builder.py b/backend/app/engine/graph_builder.py new file mode 100644 index 0000000..43dd26c --- /dev/null +++ b/backend/app/engine/graph_builder.py @@ -0,0 +1,199 @@ +import ast +import re +from dataclasses import dataclass, field + + +@dataclass +class RouteInfo: + path: str + method: str + handler: str + filename: str + line: int + decorators: list[str] = field(default_factory=list) + guard_names: list[str] = field(default_factory=list) + + +@dataclass +class RepoGraph: + files: list[str] = field(default_factory=list) + imports: dict[str, list[str]] = field(default_factory=dict) + symbols: dict[str, list[str]] = field(default_factory=dict) + routes: list[RouteInfo] = field(default_factory=list) + parse_errors: dict[str, str] = field(default_factory=dict) + + +_ROUTE_METHODS = {"get", "post", "put", "delete", "patch", "options", "head"} +_JS_IMPORT_RE = re.compile(r"""(?:import\s+.*?from\s+|require\()\s*['"]([^'"]+)['"]""") + + +def _decorator_method(node: ast.expr) -> str: + if isinstance(node, ast.Call): + return _decorator_method(node.func) + if isinstance(node, ast.Attribute): + return node.attr + if isinstance(node, ast.Name): + return node.id + return "" + + +def _decorator_qualname(node: ast.expr) -> str: + if isinstance(node, ast.Call): + return _decorator_qualname(node.func) + if isinstance(node, ast.Attribute): + base = _decorator_qualname(node.value) + return f"{base}.{node.attr}" if base else node.attr + if isinstance(node, ast.Name): + return node.id + return "" + + +def _route_path(node: ast.expr) -> str: + if isinstance(node, ast.Call) and node.args: + first = node.args[0] + if isinstance(first, ast.Constant) and isinstance(first.value, str): + return first.value + return "" + + +def _guard_target_name(call: ast.Call) -> str: + """The callable passed to Depends(...) / Security(...), e.g. "get_current_user".""" + if call.args: + arg = call.args[0] + if isinstance(arg, ast.Name): + return arg.id + if isinstance(arg, ast.Attribute): + return arg.attr + return "" + + +def _guard_names_from_call_list(value: ast.expr) -> list[str]: + names = [] + if isinstance(value, ast.List): + for elt in value.elts: + if isinstance(elt, ast.Call) and isinstance(elt.func, ast.Name) and elt.func.id in ("Depends", "Security"): + names.append(_guard_target_name(elt)) + return names + + +def _guard_names_from_decorator(dec: ast.expr) -> list[str]: + """Dependencies declared on the route itself: @app.get(..., dependencies=[Depends(x)]).""" + if not isinstance(dec, ast.Call): + return [] + for kw in dec.keywords: + if kw.arg == "dependencies": + return _guard_names_from_call_list(kw.value) + return [] + + +def _guard_names_from_signature(args: ast.arguments) -> list[str]: + """Dependencies injected via the handler's own parameters, e.g. user: User = Depends(get_current_user).""" + names = [] + all_defaults = [*args.defaults, *(d for d in args.kw_defaults if d is not None)] + for d in all_defaults: + if isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id in ("Depends", "Security"): + names.append(_guard_target_name(d)) + return names + + +# Real bug found in the retail-quick-commerce pilot: a Razorpay webhook route +# calls verify_webhook_signature(body, signature) in its own body — a real, +# working guard — but reads as completely unguarded, identical to one that +# does nothing, because Depends()/Security() is the only guard shape the +# checks above can see. A webhook is called by a third party's servers, not +# a logged-in user, so it can never carry a Depends(get_current_user) +# regardless of how well it's actually protected. +_BODY_GUARD_KEYWORDS = ("signature", "hmac") + + +def _call_name(node: ast.Call) -> str: + func = node.func + if isinstance(func, ast.Name): + return func.id + if isinstance(func, ast.Attribute): + return func.attr + return "" + + +def _body_guard_names(body: list[ast.stmt]) -> list[str]: + names = [] + for stmt in body: + for sub in ast.walk(stmt): + if isinstance(sub, ast.Call): + name = _call_name(sub) + if name and any(keyword in name.lower() for keyword in _BODY_GUARD_KEYWORDS): + names.append(name) + return names + + +def _extract_python(filename: str, content: str, graph: RepoGraph) -> None: + try: + tree = ast.parse(content, filename=filename) + except SyntaxError as e: + graph.parse_errors[filename] = str(e) + graph.imports[filename] = [] + graph.symbols[filename] = [] + return + + imports: list[str] = [] + symbols: list[str] = [] + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.extend(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.module: + imports.append(node.module.split(".")[0]) + elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + symbols.append(node.name) + for dec in node.decorator_list: + if _decorator_method(dec) in _ROUTE_METHODS: + guard_names = ( + _guard_names_from_decorator(dec) + + _guard_names_from_signature(node.args) + + _body_guard_names(node.body) + ) + graph.routes.append(RouteInfo( + path=_route_path(dec), + method=_decorator_method(dec).upper(), + handler=node.name, + filename=filename, + line=node.lineno, + decorators=[_decorator_qualname(d) for d in node.decorator_list], + guard_names=guard_names, + )) + elif isinstance(node, ast.ClassDef): + symbols.append(node.name) + + graph.imports[filename] = sorted(set(imports)) + graph.symbols[filename] = symbols + + +def _js_package_root(raw_import: str) -> str: + """Reduce a subpath import like "react-dom/client" to its package root + "react-dom" — matches sandbox.py's own resolution for npm hallucination + checks, so every consumer of graph.imports sees the same package names.""" + if raw_import.startswith("@"): + return "/".join(raw_import.split("/")[:2]) + return raw_import.split("/")[0] + + +def _extract_generic(filename: str, content: str, graph: RepoGraph) -> None: + roots = {_js_package_root(raw) for raw in _JS_IMPORT_RE.findall(content)} + graph.imports[filename] = sorted(roots) + graph.symbols[filename] = [] + + +def build_repo_graph(files: list[tuple[str, str]]) -> RepoGraph: + graph = RepoGraph(files=[filename for filename, _ in files]) + + for filename, content in files: + if filename.endswith(".py"): + _extract_python(filename, content, graph) + elif filename.endswith((".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs")): + _extract_generic(filename, content, graph) + else: + graph.imports[filename] = [] + graph.symbols[filename] = [] + + return graph diff --git a/backend/app/engine/ksword.py b/backend/app/engine/ksword.py new file mode 100644 index 0000000..1743dbf --- /dev/null +++ b/backend/app/engine/ksword.py @@ -0,0 +1,379 @@ +"""ksword — kshield's local-first remediation agent. + +Not a template engine: remediation.py's old failure mode was returning a +fabricated patch that referenced names ('AuthenticationGuard', 'app') that +didn't exist in the target file, corrupting it on `git apply`. Every +strategy here instead proposes a fix, applies it in-memory, then re-runs the +*same check* that raised the original finding against the patched result — +a patch is only ever returned if that re-check confirms the finding is +actually gone. If a strategy can't produce something it can prove works, it +declines and falls back to an honest explanation-only response. No network +calls, no model inference — every decision is made from the file's own AST +or text, which is what keeps this local-first. +""" + +import ast +import difflib +import re + +from app.engine.entropy import analyze_entropy_and_secrets +from app.engine.dependency_audit import _closest_popular, _POPULAR_PYTHON, _POPULAR_NPM +from app.engine.graph_builder import build_repo_graph +from app.engine.access_control import check_access_control, _AUTH_KEYWORDS, _looks_like_auth + +_ALL_POPULAR = _POPULAR_PYTHON | _POPULAR_NPM + + +# ── shared helpers ─────────────────────────────────────────────────────────── + +def _unified_diff(filename: str, original: str, patched: str) -> str: + # actions.py's apply-patch runs `git apply` with cwd set to the target + # file's own directory, expecting the default -p1 strip to land on the + # bare filename relative to that cwd. filename is frequently an absolute + # path (the CLI always sends one — see scanner.rs/cmd_scan) — using it + # verbatim here would produce "a//abs/path", which git apply rejects + # outright ("invalid path") since the -p1-stripped remainder is still + # absolute. The basename is what -p1 needs to resolve correctly either way. + display_name = filename.rsplit("/", 1)[-1] + diff = "\n".join(difflib.unified_diff( + original.splitlines(), patched.splitlines(), + fromfile=f"a/{display_name}", tofile=f"b/{display_name}", lineterm="", + )) + # `"\n".join` never terminates the final line. `git apply` (actions.py's + # apply-patch writes this string to a file verbatim, with no newline of + # its own added) treats a hunk's last line without a trailing newline as + # "corrupt patch at line N" and refuses the whole file — every strategy + # in this module goes through this one function, so fixing it here once + # is what keeps every caller (API response, CLI/dashboard/VSCode display, + # and the actual `git apply` step) consistent. + return diff + "\n" if diff else diff + + +def _parses(code: str) -> bool: + try: + ast.parse(code) + return True + except SyntaxError: + return False + + +def _rebuild(lines: list[str], original_code: str) -> str: + code = "\n".join(lines) + return code + "\n" if original_code.endswith("\n") else code + + +def _no_patch(explanation: str) -> dict: + return {"explanation": explanation, "patch_diff": ""} + + +def _fallback(issue_type: str, reason: str) -> dict: + return _no_patch(f"ELI5: Found a '{issue_type}' issue, but {reason}. Review the line manually.") + + +# ── secrets — extract to an environment variable ──────────────────────────── + +_ASSIGNMENT_RE = re.compile( + r'^(?P\s*)(?P[A-Za-z_][A-Za-z0-9_]*)' + r'(?P\s*:\s*[A-Za-z_][A-Za-z0-9_.\[\], ]*)?' + r'\s*=\s*(?P[\'"])(?P.*?)(?P=quote)\s*$' +) + + +def _env_var_name(identifier: str) -> str: + return re.sub(r"[^A-Za-z0-9]", "_", identifier).upper() + + +def _ensure_import(lines: list[str], module: str) -> list[str]: + if any(re.match(rf"^\s*import\s+{module}\b", ln) for ln in lines): + return lines + insert_at = 1 if lines and lines[0].startswith("#!") else 0 + return [*lines[:insert_at], f"import {module}", *lines[insert_at:]] + + +def _fix_secret(filename: str, original_code: str, line_target: int) -> tuple[str, str] | None: + if not filename.endswith(".py"): + return None + lines = original_code.splitlines() + if not (1 <= line_target <= len(lines)): + return None + + match = _ASSIGNMENT_RE.match(lines[line_target - 1]) + if not match: + return None + + env_name = _env_var_name(match.group("name")) + annotation = match.group("annotation") or "" + new_line = f'{match.group("indent")}{match.group("name")}{annotation} = os.getenv("{env_name}")' + + patched_lines = lines.copy() + patched_lines[line_target - 1] = new_line + patched_lines = _ensure_import(patched_lines, "os") + + explanation = ( + f"ELI5: Moved the hardcoded value on line {line_target} into an environment " + f"variable — set {env_name} in your environment (or a .env file loaded at " + f"startup) instead of committing the real value." + ) + return _rebuild(patched_lines, original_code), explanation + + +def _verify_secret_fixed(patched_code: str) -> bool: + if not _parses(patched_code): + return False + findings = analyze_entropy_and_secrets(patched_code) + return not any(f["anomaly_type"] in ("Hardcoded Secret", "High Entropy Credential") for f in findings) + + +# ── broken access control — reuse a guard already proven in this file ─────── + +_ROUTE_DECORATOR_METHODS = {"get", "post", "put", "delete", "patch", "options", "head", "route"} +_GUARD_CALL_NAMES = {"Depends", "Security"} + + +def _decorator_call(dec: ast.expr) -> ast.Call | None: + return dec if isinstance(dec, ast.Call) else None + + +def _decorator_method(dec: ast.expr) -> str: + call = _decorator_call(dec) + func = call.func if call else dec + if isinstance(func, ast.Attribute): + return func.attr + if isinstance(func, ast.Name): + return func.id + return "" + + +def _guard_name_from_call(call: ast.Call) -> str | None: + if call.args and isinstance(call.args[0], ast.Name): + return call.args[0].id + return None + + +def _existing_guard_in_file(tree: ast.Module, skip_lineno: int) -> tuple[str, str] | None: + """A Depends()/Security() call already used successfully by some *other* + route in this file — (call_name, guard_target) reused as-is instead of + inventing a name that might not exist, since fabricating one is exactly + what made the old remediation engine unsafe. + + Real bug found dogfooding this on kshield's own repo: without the + _looks_like_auth filter below, this returned get_db_session — a real + Depends() call, just not an auth one — as the "existing guard" to reuse. + The patch applied cleanly and even passed verification (see below), but + added no actual authentication. access_control.py's own test suite + already locks in that get_db_session must never count as a guard + (test_db_session_only_dependency_still_flagged); reusing its exact + keyword filter here is what makes ksword's notion of "a real guard" + match the checker that actually re-verifies the fix.""" + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or node.lineno == skip_lineno: + continue + + defaults = [*node.args.defaults, *(d for d in node.args.kw_defaults if d is not None)] + for d in defaults: + if isinstance(d, ast.Call) and isinstance(d.func, ast.Name) and d.func.id in _GUARD_CALL_NAMES: + name = _guard_name_from_call(d) + if name and _looks_like_auth(name, _AUTH_KEYWORDS): + return d.func.id, name + + for dec in node.decorator_list: + call = _decorator_call(dec) + if not call: + continue + for kw in call.keywords: + if kw.arg != "dependencies" or not isinstance(kw.value, ast.List): + continue + for elt in kw.value.elts: + if isinstance(elt, ast.Call) and isinstance(elt.func, ast.Name) and elt.func.id in _GUARD_CALL_NAMES: + name = _guard_name_from_call(elt) + if name and _looks_like_auth(name, _AUTH_KEYWORDS): + return elt.func.id, name + return None + + +def _route_decorator_for(node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.Call | None: + for dec in node.decorator_list: + if _decorator_method(dec) in _ROUTE_DECORATOR_METHODS: + call = _decorator_call(dec) + if call is not None: + return call + return None + + +def _fix_access_control(filename: str, original_code: str, line_target: int) -> tuple[str, str, str] | None: + if not filename.endswith(".py"): + return None + try: + tree = ast.parse(original_code) + except SyntaxError: + return None + + target = next( + (n for n in ast.walk(tree) + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.lineno == line_target), + None, + ) + if target is None: + return None + + decorator = _route_decorator_for(target) + if decorator is None or decorator.lineno != decorator.end_lineno: + # Multi-line decorator, or no route decorator found at this exact + # line — text-splicing a single line is unsafe here, decline. + return None + + guard = _existing_guard_in_file(tree, target.lineno) + if guard is None: + return None # nothing proven-real in this file to reuse — don't invent a name + call_name, guard_target = guard + + lines = original_code.splitlines() + dec_line = lines[decorator.lineno - 1] + close_paren = dec_line.rfind(")") + if close_paren == -1: + return None + + insertion = f", dependencies=[{call_name}({guard_target})]" + patched_lines = lines.copy() + patched_lines[decorator.lineno - 1] = dec_line[:close_paren] + insertion + dec_line[close_paren:] + + explanation = ( + f"ELI5: This route had no auth guard, but {call_name}({guard_target}) is " + f"already used to guard other routes in this file — reused it here instead " + f"of inventing a new one." + ) + return _rebuild(patched_lines, original_code), explanation, target.name + + +def _verify_access_control_fixed(filename: str, patched_code: str, handler_name: str) -> bool: + # Verifies against access_control.py's graph-aware checker, not + # ast_rules.py's simpler one — ast_rules.py treats any Depends() call as + # a guard (that's the exact bug M2 fixes for the audit path; see + # access_control.py's own test suite), so it would happily "confirm" a + # patch that reuses a non-auth dependency like get_db_session. Since + # _existing_guard_in_file above only ever proposes a name that already + # passes the same auth-keyword filter, this can only make verification + # stricter than the old ast_rules.py check, never looser. + if not _parses(patched_code): + return False + graph = build_repo_graph([(filename, patched_code)]) + findings = check_access_control(graph.routes) + return not any( + f["anomaly_type"] == "Broken Access Control" and handler_name in f.get("code_snippet", "") + for f in findings + ) + + +# ── possible typosquat — correct the import to the well-known name ───────── + +_TYPOSQUAT_RE = re.compile(r"Import '([^']+)'.*well-known package '([^']+)'") + + +def _fix_typosquat(original_code: str, line_target: int, bad_name: str, good_name: str) -> tuple[str, str] | None: + lines = original_code.splitlines() + if not (1 <= line_target <= len(lines)) or bad_name not in lines[line_target - 1]: + return None + + patched_lines = lines.copy() + patched_lines[line_target - 1] = lines[line_target - 1].replace(bad_name, good_name) + explanation = ( + f"ELI5: '{bad_name}' is one edit away from the well-known package '{good_name}' " + f"and isn't declared as a dependency — corrected the import. Double check this " + f"is really what you meant before committing." + ) + return _rebuild(patched_lines, original_code), explanation + + +def _verify_typosquat_fixed(filename: str, patched_code: str, line_target: int, bad_name: str) -> bool: + if filename.endswith(".py") and not _parses(patched_code): + return False + lines = patched_code.splitlines() + if not (1 <= line_target <= len(lines)): + return False + tokens = re.findall(r"[A-Za-z_][A-Za-z0-9_\-]*", lines[line_target - 1]) + return bad_name not in tokens + + +# ── dependency hallucination — no safe patch, but a grounded suggestion ──── + +_DEP_NAME_RE = re.compile(r"[Pp]ackage '([^']+)'|module '([^']+)'|gem '([^']+)'") + + +def _explain_dependency_hallucination(description: str) -> str: + match = _DEP_NAME_RE.search(description) + name = next((g for g in match.groups() if g), None) if match else None + suggestion = _closest_popular(name, _ALL_POPULAR) if name else None + if suggestion: + candidate, _dist = suggestion + return ( + f"ELI5: This package wasn't found on the registry. Closest well-known " + f"name: '{candidate}' — check if that's what you meant. If not, verify " + f"the exact package name and publisher before installing it." + ) + return ( + "ELI5: This package wasn't found on the registry, and no similar well-known " + "package name was found either. Double check the exact name and publisher " + "before installing it — this may be a fully hallucinated dependency." + ) + + +# ── entry point ────────────────────────────────────────────────────────────── + +def construct_remediation_patch( + filename: str, + original_code: str, + issue_type: str, + line_target: int, + description: str = "", +) -> dict: + if issue_type in ("Hardcoded Secret", "High Entropy Credential"): + proposal = _fix_secret(filename, original_code, line_target) + if proposal: + patched_code, explanation = proposal + if _verify_secret_fixed(patched_code): + return {"explanation": explanation, "patch_diff": _unified_diff(filename, original_code, patched_code)} + return _fallback(issue_type, "no assignment of the form NAME = \"literal\" was found on this line to safely rewrite") + + if issue_type == "Broken Access Control": + proposal = _fix_access_control(filename, original_code, line_target) + if proposal: + patched_code, explanation, handler_name = proposal + if _verify_access_control_fixed(filename, patched_code, handler_name): + return {"explanation": explanation, "patch_diff": _unified_diff(filename, original_code, patched_code)} + return _fallback( + issue_type, + "no other route in this file has a working auth guard to safely reuse — " + "add one manually, e.g. dependencies=[Depends(get_current_user)], and make " + "sure it's imported", + ) + + if issue_type == "Possible Typosquat": + match = _TYPOSQUAT_RE.search(description) + if match: + bad_name, good_name = match.group(1), match.group(2) + proposal = _fix_typosquat(original_code, line_target, bad_name, good_name) + if proposal: + patched_code, explanation = proposal + if _verify_typosquat_fixed(filename, patched_code, line_target, bad_name): + return {"explanation": explanation, "patch_diff": _unified_diff(filename, original_code, patched_code)} + return _fallback(issue_type, "could not confidently identify the intended package name from this line") + + if issue_type == "Dependency Hallucination": + return _no_patch(_explain_dependency_hallucination(description)) + + if issue_type == "Syntax Violation": + return _no_patch( + "ELI5: This file doesn't parse as valid Python — often the tail end of an " + "AI-generated edit that got cut off mid-statement. Compare against the last " + "known-good version and complete the truncated block manually; no automated " + "fix is safe here since the original intent isn't recoverable." + ) + + if issue_type == "AI Structural Hallucination": + return _no_patch( + "ELI5: This looks like placeholder or stub content that still needs real " + "logic — an automated patch can't safely guess the intended implementation, " + "so this needs a manual pass." + ) + + return _fallback(issue_type, "no remediation strategy is registered for this finding type yet") diff --git a/backend/app/engine/model.py b/backend/app/engine/model.py index de9c7a7..0caa1ec 100644 --- a/backend/app/engine/model.py +++ b/backend/app/engine/model.py @@ -5,6 +5,22 @@ # Test file patterns — skip classification to avoid noisy false positives in test suites _TEST_FILE_RE = re.compile(r"(^|/)test[_-]|[_-]test\.py$|/tests?/", re.I) +# Prose files where a trailing "..." is normal English (trailing off, a list +# continuation) rather than an incomplete code stub. Only the bare-ellipsis +# pattern is prose-prone — every other indicator needs a specific suspicious +# phrase that's unlikely to appear innocuously in documentation. +_DOC_FILE_RE = re.compile(r"\.(md|mdx|rst|txt)$", re.I) +_BARE_ELLIPSIS_PATTERN = r"\.\.\.$" + +# Real bug found auditing kshield's own repo (and a user's separate security- +# scanner file, aiCodeAnalysis.js): a line that itself *defines* a detection +# pattern — "password.*=.*['\"]password['\"]" sitting in a rules table like +# this one — contains the same substrings its own rule looks for, and matches +# itself. Genuine credential/stub code never contains raw regex metacharacters +# like these; a line that does is almost certainly a pattern definition, not +# a literal value, so skip matching against it entirely. +_LOOKS_LIKE_PATTERN_DEFINITION_RE = re.compile(r"\.\*|\\s\b|\\d\b|\\w\b|\[\^|\[:=\]") + # Indicators organised by category — all matched case-insensitively against individual lines. # Format: (pattern_string, severity, category_label) _INDICATORS: list[tuple[str, str, str]] = [ @@ -87,9 +103,14 @@ def process_inference_eval(self, code_data: str, filename: str = "") -> list: anomalies = [] lines = code_data.splitlines() + is_doc_file = bool(_DOC_FILE_RE.search(filename)) for index, text_line in enumerate(lines, 1): + if _LOOKS_LIKE_PATTERN_DEFINITION_RE.search(text_line): + continue for compiled, raw_pattern, severity, label in _COMPILED: + if is_doc_file and raw_pattern == _BARE_ELLIPSIS_PATTERN: + continue if compiled.search(text_line): anomalies.append({ "line_number": index, diff --git a/backend/app/engine/orchestrator.py b/backend/app/engine/orchestrator.py new file mode 100644 index 0000000..57ab04f --- /dev/null +++ b/backend/app/engine/orchestrator.py @@ -0,0 +1,157 @@ +import asyncio +import re + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.engine.graph_builder import build_repo_graph +from app.engine.access_control import check_access_control +from app.engine.entropy import analyze_entropy_and_secrets +from app.engine.model import sequence_classifier_node +import app.engine.sandbox as sandbox +from app.engine.sandbox import evaluate_dependency_hallucinations +from app.engine.dependency_audit import ( + parse_declared_dependencies, + check_dependency_hygiene, + raw_first_party_names, + workspace_package_names, + _normalize, +) +from app.engine.pattern_archive import load_extra_auth_keywords +from app.engine.quiet_office import suppress_similar_findings +from app.engine.suppress import apply as apply_suppressions +from app.models.false_positives import FalsePositive + +from sqlalchemy import select + +# How many registry lookups run at once during the bulk pre-check. Real bug +# found in the y-n8n pilot (13,000+ files, hundreds of genuinely unique +# packages): checking them one at a time sequentially took minutes and +# blocked the whole server, even with per-package caching already in place. +_REGISTRY_CONCURRENCY = 20 + +# ast_rules.py's own "Broken Access Control" detection is intentionally not called +# here — check_access_control (graph-aware) supersedes it. Its "Syntax Violation" +# detection is also not re-run here; graph_builder already attempted to parse every +# file, so its parse_errors are reused directly instead of parsing each file twice. + +# Lockfiles are full of long base64 integrity hashes that read as high-entropy +# "secrets" to entropy.py but never appear one-at-a-time in a commit diff (which is +# the only place these engines have run until now). A repo-wide audit sees them +# for the first time, so they need an explicit exclusion here. +_GENERATED_FILENAMES = frozenset([ + "package-lock.json", "yarn.lock", "pnpm-lock.yaml", + "Cargo.lock", "poetry.lock", "Pipfile.lock", + "composer.lock", "Gemfile.lock", "go.sum", +]) + + +def _is_generated_file(filename: str) -> bool: + return filename.rsplit("/", 1)[-1] in _GENERATED_FILENAMES + + +# Real bug found in the adk-python pilot: 261 of 326 "Broken Access Control" +# findings (80%) were routes defined inside test files or contributing/samples +# — a pytest test calling a route function directly to unit-test it doesn't +# need Depends(get_current_user), and never runs as a reachable production +# endpoint in the first place. Same shape model.py already uses for its own +# test-file skip. +_TEST_FILE_RE = re.compile(r"(^|/)test[_-]|[_-]test\.py$|/tests?/", re.I) + + +def _is_test_file(filename: str) -> bool: + return bool(_TEST_FILE_RE.search(filename)) + + +def _syntax_violation_findings(parse_errors: dict[str, str]) -> list[dict]: + return [ + { + "line_number": 1, + "anomaly_type": "Syntax Violation", + "severity": "MEDIUM", + "description": f"Python syntax error: {err}. This often indicates truncated AI-generated code.", + "code_snippet": "", + "filename": filename, + } + for filename, err in parse_errors.items() + ] + + +async def run_audit(files: list[tuple[str, str]], db: AsyncSession, suppress: dict | None = None) -> dict: # type: ignore[type-arg] + graph = build_repo_graph(files) + + extra_auth_keywords = await load_extra_auth_keywords(db) + production_routes = [r for r in graph.routes if not _is_test_file(r.filename)] + findings = list(check_access_control(production_routes, tuple(extra_auth_keywords))) + findings.extend(_syntax_violation_findings(graph.parse_errors)) + + workspace_names = workspace_package_names(files) + declared = parse_declared_dependencies(files) | {_normalize(n) for n in workspace_names} + + first_party = frozenset(raw_first_party_names(graph.imports) | workspace_names) + registry_cache: dict = {} + + # Pass 1 — collect every (package, url) that would need a registry check, + # across the whole repo, with zero network calls (collect_only short-circuits + # each check to a no-op placeholder). Then check them all at once, bounded, + # concurrently — not one at a time, once per file. + pending: set[tuple[str, str]] = set() + for filename, content in files: + if _is_generated_file(filename): + continue + await evaluate_dependency_hallucinations(content, filename, first_party, collect_only=pending) + + if pending: + sem = asyncio.Semaphore(_REGISTRY_CONCURRENCY) + + async def _bounded_check(package: str, url: str) -> tuple[str, bool]: + async with sem: + return package, await sandbox._check_registry(url, package) + + for package, exists in await asyncio.gather(*[_bounded_check(pkg, url) for pkg, url in pending]): + registry_cache[package] = exists + + # Runs after the registry pass above (not before it, like M5 originally + # had it) so a package that independently resolves on the registry isn't + # also flagged as typosquatting some other, unrelated well-known name. + findings.extend(check_dependency_hygiene(graph.imports, declared, registry_cache)) + + # Pass 2 — the real pass. Every registry_cache lookup is now a cache hit, + # so this no longer waits on the network at all. + for filename, content in files: + if _is_generated_file(filename): + continue + + per_file_findings = ( + analyze_entropy_and_secrets(content) + + sequence_classifier_node.process_inference_eval(content, filename) + + await evaluate_dependency_hallucinations(content, filename, first_party, registry_cache) + ) + + for finding in per_file_findings: + findings.append({**finding, "filename": filename}) + + # Real gap found in review: this repo-wide path used to ignore both + # .kshield.yml's rules/severities/paths config and the dashboard's global + # "Suppress Rule" action entirely — only quiet_office's dismissed-finding + # memory applied here, unlike the single-file /api/v1/scan path (scan.py), + # which already merges both. A rule suppressed via the dashboard, or a + # severity/path ignored via .kshield.yml, would silently reappear the + # moment you ran `kshield agent` instead of `kshield hook` — same config, + # two different outcomes. Mirrors scan.py's merge exactly. + fp_result = await db.execute(select(FalsePositive).where(FalsePositive.file_signature == "*")) + global_suppressed_rules = [fp.rule_id for fp in fp_result.scalars().all()] + + merged_suppress = dict(suppress or {}) + merged_suppress["rules"] = list(set(merged_suppress.get("rules", []) + global_suppressed_rules)) + + content_by_file = dict(files) + findings = [ + apply_suppressions([finding], finding.get("filename", ""), content_by_file.get(finding.get("filename", ""), ""), merged_suppress)[0] + for finding in findings + ] + + # quiet_office runs last and is order-safe (sticky) — it never un-suppresses + # a finding the pass above already suppressed for an unrelated reason. + findings = await suppress_similar_findings(findings, db) + + return {"graph": graph, "findings": findings} # type: ignore[return-value] diff --git a/backend/app/engine/pattern_archive.py b/backend/app/engine/pattern_archive.py new file mode 100644 index 0000000..428ff68 --- /dev/null +++ b/backend/app/engine/pattern_archive.py @@ -0,0 +1,38 @@ +import json +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.configurations import Configuration + +_AUTH_KEYWORDS_CONFIG_KEY = "access_control.extra_auth_keywords" + + +async def load_extra_auth_keywords(db: AsyncSession) -> list[str]: + result = await db.execute(select(Configuration).where(Configuration.key == _AUTH_KEYWORDS_CONFIG_KEY)) + row = result.scalar_one_or_none() + if not row: + return [] + try: + return json.loads(row.value) + except (json.JSONDecodeError, ValueError): + return [] + + +async def add_extra_auth_keyword(keyword: str, db: AsyncSession) -> list[str]: + """One guard's newly recognized lock shape, added once, applied everywhere — + every future audit's access_control check picks this up automatically.""" + normalized = keyword.strip().lower() + + result = await db.execute(select(Configuration).where(Configuration.key == _AUTH_KEYWORDS_CONFIG_KEY)) + row = result.scalar_one_or_none() + existing = json.loads(row.value) if row else [] + + if normalized and normalized not in existing: + existing.append(normalized) + + if row: + row.value = json.dumps(existing) + else: + db.add(Configuration(key=_AUTH_KEYWORDS_CONFIG_KEY, value=json.dumps(existing))) + + return existing diff --git a/backend/app/engine/quiet_office.py b/backend/app/engine/quiet_office.py new file mode 100644 index 0000000..3c88986 --- /dev/null +++ b/backend/app/engine/quiet_office.py @@ -0,0 +1,69 @@ +import re +import uuid +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.false_positives import FalsePositive + +# Findings are template-generated strings, not free-form prose — the only +# parts that vary between two "same kind of problem" findings are quoted +# identifiers (import names, handler names, paths) and bare file paths. +# Stripping those out collapses "Import 'foo' is used in a.py..." and +# "Import 'bar' is used in b.py..." to the same signature, so a dismissal +# recognizes the pattern, not just the one exact string. +_QUOTED_RE = re.compile(r"'[^']*'|\"[^\"]*\"") +_PATH_RE = re.compile(r"\b[\w./-]+\.(?:py|js|ts|tsx|jsx|json|go|rb|txt|yaml|yml)\b") + + +def _signature(description: str) -> str: + text = _QUOTED_RE.sub("", description) + text = _PATH_RE.sub("", text) + return text.strip().lower() + + +async def suppress_similar_findings(findings: list[dict], db: AsyncSession) -> list[dict]: + """Tags each finding suppressed=True if its normalized signature matches + a previously dismissed false positive of the same anomaly type.""" + if not findings: + return findings + + result = await db.execute(select(FalsePositive).where(FalsePositive.signature.is_not(None))) + known = result.scalars().all() + + by_rule: dict[str, list[FalsePositive]] = {} + for fp in known: + by_rule.setdefault(fp.rule_id, []).append(fp) + + output = [] + for finding in findings: + candidates = by_rule.get(finding.get("anomaly_type", ""), []) + # Sticky — a prior pass (e.g. suppress.py's .kshield.yml/global-rule + # check) may have already suppressed this finding for an unrelated + # reason; never flip that back to False just because it doesn't also + # match a dismissed signature. + suppressed = finding.get("suppressed", False) + reason = None + if candidates: + sig = _signature(finding.get("description", "")) + for fp in candidates: + if fp.signature == sig: + suppressed = True + reason = fp.justification or "matches a previously dismissed finding" + break + + entry = {**finding, "suppressed": suppressed} + if reason: + entry["suppressed_reason"] = reason + output.append(entry) + + return output + + +async def record_dismissal(description: str, anomaly_type: str, justification: str, db: AsyncSession) -> None: + db.add(FalsePositive( + id=str(uuid.uuid4()), + file_signature="*", + rule_id=anomaly_type, + justification=justification, + signature=_signature(description), + )) diff --git a/backend/app/engine/remediation.py b/backend/app/engine/remediation.py deleted file mode 100644 index 2e214f0..0000000 --- a/backend/app/engine/remediation.py +++ /dev/null @@ -1,36 +0,0 @@ -import difflib - -def construct_remediation_patch(filename: str, original_code: str, issue_type: str, line_target: int) -> dict: - code_lines = original_code.splitlines() - remediated_lines = code_lines.copy() - - explanation = "Review your code logic manually." - adjusted = False - - # Structural generation rule engine targets access vulnerabilities - if issue_type == "Broken Access Control" and line_target <= len(code_lines): - target_idx = line_target - 1 - current_declaration = code_lines[target_idx] - if "def " in current_declaration and not adjusted: - indentation_space = len(current_declaration) - len(current_declaration.lstrip()) - whitespace = " " * indentation_space - remediated_lines.insert(target_idx, f"{whitespace}@app.get('/unverified-access-fix', dependencies=[Depends(AuthenticationGuard)])") - explanation = "ELI5: The scanner noticed that this endpoint doesn't check for permissions. We added an authentication decorator immediately before your function declaration to restrict unauthorized requests safely." - adjusted = True - - if not adjusted: - explanation = f"ELI5: Discovered raw signature violation profile matching '{issue_type}' conditions. Audit the exposed line carefully and scrub credentials or unverified imports." - - # Compute a standard Git Patch Diff cleanly - diff_generator = difflib.unified_diff( - code_lines, - remediated_lines, - fromfile=f"a/{filename}", - tofile=f"b/{filename}", - lineterm="" - ) - - return { - "explanation": explanation, - "patch_diff": "\n".join(list(diff_generator)) - } diff --git a/backend/app/engine/sandbox.py b/backend/app/engine/sandbox.py index ad4759c..26f3564 100644 --- a/backend/app/engine/sandbox.py +++ b/backend/app/engine/sandbox.py @@ -32,6 +32,24 @@ def _go_is_stdlib(module_path: str) -> bool: return "." not in first_segment +# Real bug found in the Project-TEC and adk-python pilots: `import cv2` and +# `import yaml` are genuinely real, widely-used packages — just published on +# PyPI under a different name than the module you import. Checking the +# import name itself against the registry always 404s for these, reading as +# a hallucinated package when it's actually a well-known naming mismatch. +_PYPI_NAME_ALIASES: dict[str, str] = { + "yaml": "PyYAML", + "dateutil": "python-dateutil", + "googleapiclient": "google-api-python-client", + "cv2": "opencv-python", + "sklearn": "scikit-learn", + "pil": "Pillow", + "jwt": "PyJWT", + "dotenv": "python-dotenv", + "attr": "attrs", +} + + def _find_import_line(lines: list[str], token: str) -> int: """Return the 1-based line number of the first line containing `token`.""" for i, line in enumerate(lines, 1): @@ -40,6 +58,26 @@ def _find_import_line(lines: list[str], token: str) -> int: return 1 +# Real bug found in the ytt-chrome-extensions pilot: the JS/TS import regex +# below treats a bare "from" as enough to start a match, so English prose in +# a JSDoc comment — "The response type from 'proxy.settings.get'" — reads as +# an import statement. Comments never contain real imports, so strip them +# before extracting; string literals are preserved (a "//" inside a URL +# string like "https://x" must survive). Not a full JS parser — good enough +# for this regex-based extraction, not used anywhere line numbers matter. +_JS_COMMENT_OR_STRING_RE = re.compile( + r"""(?P//[^\n]*|/\*.*?\*/)|(?P"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)""", + re.DOTALL, +) + + +def _strip_js_comments(code: str) -> str: + def _replace(m: re.Match) -> str: + return " " if m.group("comment") is not None else m.group("string") + + return _JS_COMMENT_OR_STRING_RE.sub(_replace, code) + + async def _check_registry(url: str, package: str) -> bool: async with httpx.AsyncClient(timeout=2.5) as client: try: @@ -50,19 +88,58 @@ async def _check_registry(url: str, package: str) -> bool: return True # network unavailable — don't block commit -async def evaluate_dependency_hallucinations(code_data: str, filename: str) -> list: +async def evaluate_dependency_hallucinations( + code_data: str, + filename: str, + first_party: frozenset = frozenset(), + registry_cache: dict | None = None, + collect_only: set | None = None, +) -> list: + """first_party: names of local packages/modules that exist elsewhere in + the same repo (e.g. a "deals/" directory with its own __init__.py) — used + only by the repo-wide /audit path, which has that context. The single-file + /scan path has no repo to derive this from, so it defaults to empty and + behaves exactly as before. + + registry_cache: shared across every file in one /audit run, so a package + imported in 50 files gets checked against the registry once, not 50 + times. Real bug found in the webstudio pilot — without this, a repo-wide + audit of a large monorepo timed out entirely, re-checking the same + handful of popular packages hundreds of times over. + + collect_only: real bug found in the y-n8n pilot (13,000+ files) — even + with the cache above, a repo with hundreds of genuinely unique packages + still checks them one at a time, sequentially, which alone can take + minutes and blocks the whole server. When this is a set (not None), no + network call happens at all — every candidate (package, url) is just + recorded here instead. The caller runs one bulk pass over everything + collected across all files (see orchestrator.py), concurrently, then + calls this function again for real with registry_cache pre-populated — + so the second pass is pure cache lookups, no sequential waiting.""" findings: list = [] lines = code_data.splitlines() + async def _check_cached(url: str, package: str) -> bool: + if collect_only is not None: + collect_only.add((package, url)) + return True # placeholder — this pass's findings are discarded + if registry_cache is not None and package in registry_cache: + return registry_cache[package] + result = await _check_registry(url, package) + if registry_cache is not None: + registry_cache[package] = result + return result + # ── Python ──────────────────────────────────────────────────────────────── if filename.endswith(".py"): imports = re.findall(r"^(?:import|from)\s+([a-zA-Z0-9_]+)", code_data, re.MULTILINE) seen: set[str] = set() for pkg in imports: - if pkg in seen or pkg in _STDLIB_MODULES: + if pkg in seen or pkg in _STDLIB_MODULES or pkg in first_party: continue seen.add(pkg) - if not await _check_registry(PYPI_URL_PATTERN.format(pkg=pkg), pkg): + registry_name = _PYPI_NAME_ALIASES.get(pkg.lower(), pkg) + if not await _check_cached(PYPI_URL_PATTERN.format(pkg=registry_name), pkg): line_no = _find_import_line(lines, pkg) findings.append({ "line_number": line_no, @@ -79,7 +156,7 @@ async def evaluate_dependency_hallucinations(code_data: str, filename: str) -> l elif filename.endswith((".js", ".ts", ".tsx", ".jsx", ".mjs", ".cjs")): raw = re.findall( r"""(?:import|from|require\()\s*['"]([a-zA-Z0-9_\-\/@][a-zA-Z0-9_\-\/.]*)['"]""", - code_data, + _strip_js_comments(code_data), ) seen = set() for raw_pkg in raw: @@ -88,10 +165,10 @@ async def evaluate_dependency_hallucinations(code_data: str, filename: str) -> l if raw_pkg.startswith("@") else raw_pkg.split("/")[0] ) - if pkg in seen or pkg.startswith((".", "/")): + if pkg in seen or pkg.startswith((".", "/", "@/")) or pkg in first_party: continue seen.add(pkg) - if not await _check_registry(NPM_URL_PATTERN.format(pkg=pkg), pkg): + if not await _check_cached(NPM_URL_PATTERN.format(pkg=pkg), pkg): line_no = _find_import_line(lines, raw_pkg) findings.append({ "line_number": line_no, @@ -110,11 +187,11 @@ async def evaluate_dependency_hallucinations(code_data: str, filename: str) -> l go_imports = re.findall(r'"([a-zA-Z0-9_.\-/]+)"', code_data) seen = set() for module in go_imports: - if module in seen or _go_is_stdlib(module): + if module in seen or _go_is_stdlib(module) or module in first_party: continue seen.add(module) encoded = module.replace("/", "%2F") - if not await _check_registry(GO_PROXY_URL_PATTERN.format(module=encoded), module): + if not await _check_cached(GO_PROXY_URL_PATTERN.format(module=encoded), module): line_no = _find_import_line(lines, module) findings.append({ "line_number": line_no, @@ -140,10 +217,10 @@ async def evaluate_dependency_hallucinations(code_data: str, filename: str) -> l ]) seen = set() for gem in ruby_gems: - if gem in seen or gem in _RUBY_STDLIB: + if gem in seen or gem in _RUBY_STDLIB or gem in first_party: continue seen.add(gem) - if not await _check_registry(RUBYGEMS_URL_PATTERN.format(gem=gem), gem): + if not await _check_cached(RUBYGEMS_URL_PATTERN.format(gem=gem), gem): line_no = _find_import_line(lines, gem) findings.append({ "line_number": line_no, diff --git a/backend/app/engine/suppress.py b/backend/app/engine/suppress.py index 03e8a10..36fc66b 100644 --- a/backend/app/engine/suppress.py +++ b/backend/app/engine/suppress.py @@ -30,9 +30,10 @@ def apply(findings: list[dict], filename: str, content: str, suppress: dict) -> result = [] for finding in findings: suppressed = ( - file_suppressed - or finding.get("severity", "").upper() in blocked_severities - or finding.get("anomaly_type", "").lower() in blocked_rules + finding.get("suppressed", False) # sticky — a prior pass (e.g. quiet_office) + or file_suppressed # may already have suppressed this finding; + or finding.get("severity", "").upper() in blocked_severities # never flip it + or finding.get("anomaly_type", "").lower() in blocked_rules # back to False or finding.get("line_number", 0) in ignored_lines ) result.append({**finding, "suppressed": suppressed}) diff --git a/backend/app/main.py b/backend/app/main.py index 37c4095..b05d102 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,8 @@ from app.api.v1.scan import router as api_v1_router from app.api.v1.history import router as history_router from app.api.v1.actions import router as actions_router +from app.api.v1.audit import router as audit_router +from app.api.v1.patterns import router as patterns_router logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger("kshield") @@ -42,6 +44,8 @@ async def lifespan(app: FastAPI): app.include_router(api_v1_router, prefix="/api/v1") app.include_router(history_router, prefix="/api/v1") app.include_router(actions_router, prefix="/api/v1") +app.include_router(audit_router, prefix="/api/v1") +app.include_router(patterns_router, prefix="/api/v1") @app.get("/health", tags=["System"]) async def health_check(): diff --git a/backend/app/models/audit_runs.py b/backend/app/models/audit_runs.py new file mode 100644 index 0000000..af47968 --- /dev/null +++ b/backend/app/models/audit_runs.py @@ -0,0 +1,14 @@ +from datetime import datetime, timezone +from sqlalchemy import Column, String, DateTime, Integer +from app.db.session import Base + +class AuditRun(Base): + __tablename__ = "audit_runs" + + id = Column(String, primary_key=True, index=True) + name = Column(String, nullable=False, index=True) + file_count = Column(Integer, default=0, nullable=False) + findings_count = Column(Integer, default=0, nullable=False) + critical_count = Column(Integer, default=0, nullable=False) + high_count = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/backend/app/models/false_positives.py b/backend/app/models/false_positives.py index 57e2144..08a003e 100644 --- a/backend/app/models/false_positives.py +++ b/backend/app/models/false_positives.py @@ -9,4 +9,10 @@ class FalsePositive(Base): file_signature = Column(String, nullable=False, index=True) # MD5/SHA256 file calculation rule_id = Column(String, nullable=False, index=True) justification = Column(Text, nullable=True) + # Normalized template of the dismissed finding's description (quoted + # identifiers and file paths stripped) — see quiet_office.py. Deliberately + # not an embedding: findings are template-generated text, not free-form + # prose, so matching the structural shell is both simpler and more + # reliable than cosine similarity on a hash-seeded vector would be. + signature = Column(Text, nullable=True, index=True) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) diff --git a/backend/requirements.txt b/backend/requirements.txt index 874afcd..435ea1b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,12 +3,11 @@ uvicorn[standard]>=0.29 httpx>=0.27 sqlalchemy[asyncio]>=2.0 greenlet>=3.0 -sqlmodel>=0.0.18 asyncpg>=0.29 aiosqlite>=0.20 pydantic>=2.7 numpy>=1.26 -tensorflow>=2.16 pgvector>=0.3 -cachetools>=5.3 python-multipart>=0.0.9 +pytest>=8.0 +pytest-asyncio>=0.23 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/test_engines.py b/backend/tests/test_engines.py deleted file mode 100644 index 3b60bae..0000000 --- a/backend/tests/test_engines.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Accuracy regression tests for the four analysis engine modules. - -Run: SQLITE_FALLBACK=true python -m pytest tests/test_engines.py -v -""" -import pytest -from app.engine.entropy import analyze_entropy_and_secrets -from app.engine.ast_rules import run_ast_structural_scan -from app.engine.model import sequence_classifier_node -# sandbox tests need network; mark them separately - - -# ─────────────────────────── helpers ──────────────────────────────────────── - -def _types(findings): - return [f["anomaly_type"] for f in findings] - -def _severities(findings): - return [f["severity"] for f in findings] - - -# ════════════════════════════ entropy.py ════════════════════════════════════ - -class TestEntropy: - # ── TRUE POSITIVES ────────────────────────────────────────────────────── - - def test_github_pat_detected(self): - code = "token = 'ghp_abcdefghijklmnopqrstuvwxyz123456789012'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - assert any(f["severity"] == "CRITICAL" for f in r) - - def test_github_fine_grained_pat_detected(self): - code = "tok = 'github_pat_" + "A" * 82 + "'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - - def test_aws_key_detected(self): - code = "key = 'AKIAIOSFODNN7EXAMPLE'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - - def test_openai_key_detected(self): - code = "key = 'TEST_OPENAI_KEY_XYZ_123456789'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - - def test_stripe_live_key_detected(self): - code = "STRIPE_KEY = 'TEST_STRIPE_KEY_XYZ_123456789'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - - def test_slack_bot_token_detected(self): - code = "slack = 'TEST_SLACK_TOKEN_XYZ_123456789'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - - def test_npm_token_detected(self): - code = "NPM_TOKEN = 'npm_abcdefghijklmnopqrstuvwxyz1234567890'" - r = analyze_entropy_and_secrets(code) - assert any(f["anomaly_type"] == "Hardcoded Secret" for f in r) - - def test_high_entropy_string_detected(self): - # Random-looking 40-char string with no named-pattern match - code = "secret = 'xK3mP9qL2nV7wR4tY8uI1oA6sD0fG5hJ'" - r = analyze_entropy_and_secrets(code) - assert len(r) >= 1 - - # ── FALSE POSITIVE SUPPRESSION ────────────────────────────────────────── - - def test_uuid_not_flagged(self): - code = "uid = '123e4567-e89b-12d3-a456-426614174000'" - r = analyze_entropy_and_secrets(code) - assert not any(f["anomaly_type"] == "High Entropy Credential" for f in r) - - def test_sha256_hash_not_flagged(self): - code = "checksum = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'" - r = analyze_entropy_and_secrets(code) - assert not any(f["anomaly_type"] == "High Entropy Credential" for f in r) - - def test_short_string_not_flagged(self): - code = "key = 'short'" - r = analyze_entropy_and_secrets(code) - assert r == [] - - def test_plain_english_not_flagged(self): - code = "msg = 'Hello, this is a normal string value for testing'" - r = analyze_entropy_and_secrets(code) - assert r == [] - - def test_semver_not_flagged(self): - code = "version = '1.2.3-rc.1'" - r = analyze_entropy_and_secrets(code) - assert r == [] - - def test_no_duplicate_per_line(self): - # A line that matches a named pattern should produce exactly 1 finding - code = "x = 'ghp_abcdefghijklmnopqrstuvwxyz123456789012'" - r = analyze_entropy_and_secrets(code) - assert len([f for f in r if f["line_number"] == 1]) == 1 - - -# ════════════════════════════ ast_rules.py ══════════════════════════════════ - -class TestAstRules: - # ── TRUE POSITIVES ────────────────────────────────────────────────────── - - def test_async_route_without_auth_detected(self): - code = """ -from fastapi import APIRouter -router = APIRouter() - -@router.get('/items') -async def list_items(): - return [] -""" - r = run_ast_structural_scan(code, "routes.py") - assert any(f["anomaly_type"] == "Broken Access Control" for f in r) - - def test_sync_route_without_auth_detected(self): - code = """ -from fastapi import APIRouter -router = APIRouter() - -@router.post('/data') -def create_data(): - return {} -""" - r = run_ast_structural_scan(code, "routes.py") - assert any(f["anomaly_type"] == "Broken Access Control" for f in r) - - def test_mutation_endpoint_is_high_severity(self): - code = """ -@app.delete('/resource') -async def delete_resource(): - pass -""" - r = run_ast_structural_scan(code, "main.py") - assert any(f["severity"] == "HIGH" for f in r) - - def test_get_endpoint_without_auth_is_medium(self): - code = """ -@app.get('/secret-data') -async def get_secret(): - return {} -""" - r = run_ast_structural_scan(code, "main.py") - assert any(f["severity"] == "MEDIUM" for f in r) - - # ── FALSE POSITIVE SUPPRESSION ────────────────────────────────────────── - - def test_public_health_path_not_flagged(self): - code = """ -@app.get('/health') -async def health(): - return {"status": "ok"} -""" - r = run_ast_structural_scan(code, "main.py") - assert r == [] - - def test_docs_path_not_flagged(self): - code = """ -@app.get('/docs') -async def docs(): - pass -""" - r = run_ast_structural_scan(code, "main.py") - assert r == [] - - def test_route_with_depends_not_flagged(self): - code = """ -from fastapi import Depends -@app.get('/profile') -async def profile(user=Depends(get_current_user)): - return user -""" - r = run_ast_structural_scan(code, "routes.py") - assert r == [] - - def test_route_with_decorator_dependencies_not_flagged(self): - code = """ -from fastapi import Depends -@app.get('/admin', dependencies=[Depends(require_admin)]) -async def admin_panel(): - return {} -""" - r = run_ast_structural_scan(code, "routes.py") - assert r == [] - - def test_non_python_file_not_checked(self): - code = "@app.get('/items')\nasync def items(): pass" - r = run_ast_structural_scan(code, "routes.ts") - assert r == [] - - def test_plain_function_not_flagged(self): - code = """ -def helper(x): - return x + 1 -""" - r = run_ast_structural_scan(code, "utils.py") - assert r == [] - - def test_syntax_error_reported(self): - code = "def broken(:" - r = run_ast_structural_scan(code, "bad.py") - assert any(f["anomaly_type"] == "Syntax Violation" for f in r) - - -# ════════════════════════════ model.py ══════════════════════════════════════ - -class TestModel: - def _run(self, code, filename="app.py"): - return sequence_classifier_node.process_inference_eval(code, filename) - - # ── TRUE POSITIVES ────────────────────────────────────────────────────── - - def test_todo_verify_production_detected(self): - r = self._run("# TODO: verify with production keys before deploy") - assert r, "Expected at least one finding" - - def test_fake_password_detected(self): - r = self._run("password = 'fake_password_123'") - assert any(f["severity"] in ("HIGH", "MEDIUM") for f in r) - - def test_credential_stub_detected(self): - r = self._run("password = 'password'") - assert r - - def test_hallucinated_import_detected(self): - r = self._run("from internal_ai_test import mock_db") - assert r - - def test_ai_artifact_detected(self): - r = self._run("# Replace this with your actual API key here") - assert r - - def test_raise_not_implemented_detected(self): - r = self._run(" raise NotImplementedError") - assert r - - def test_auth_bypass_detected(self): - r = self._run("# auth bypass for testing — disable auth") - assert r - - # ── FALSE POSITIVE SUPPRESSION ────────────────────────────────────────── - - def test_test_file_skipped(self): - # Nothing should fire inside a test file - r = self._run("def test_auth(): pass\n# TODO: add more assertions", "tests/test_auth.py") - assert r == [] - - def test_normal_code_not_flagged(self): - r = self._run("def compute(x, y):\n return x + y\n") - assert r == [] - - def test_one_finding_per_line(self): - # A line matching multiple patterns should produce exactly 1 finding - r = self._run("password = 'fake_password' # TODO: verify with production") - lines = [f["line_number"] for f in r] - assert lines.count(1) == 1 - - # ── Embedding ──────────────────────────────────────────────────────────── - - def test_embedding_is_1536_dims(self): - vec = sequence_classifier_node.generate_embedding_vector("hello world") - assert len(vec) == 1536 - - def test_embedding_is_deterministic(self): - a = sequence_classifier_node.generate_embedding_vector("same text") - b = sequence_classifier_node.generate_embedding_vector("same text") - assert a == b - - def test_embedding_is_unit_vector(self): - import math - vec = sequence_classifier_node.generate_embedding_vector("unit test") - norm = math.sqrt(sum(v * v for v in vec)) - assert abs(norm - 1.0) < 1e-6 - - -# ════════════════════════════ sandbox.py (offline) ══════════════════════════ -# These tests mock network calls so no real HTTP requests are made. - -class TestSandboxOffline: - @pytest.fixture - def mock_not_found(self, monkeypatch): - async def fake_check(url, package): - return False # simulate 404 - import app.engine.sandbox as sb - monkeypatch.setattr(sb, "_check_registry", fake_check) - - @pytest.fixture - def mock_found(self, monkeypatch): - async def fake_check(url, package): - return True # simulate 200 - import app.engine.sandbox as sb - monkeypatch.setattr(sb, "_check_registry", fake_check) - - @pytest.mark.asyncio - async def test_hallucinated_pypi_package(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "import totally_fake_package_xyz\n" - r = await evaluate_dependency_hallucinations(code, "app.py") - assert any(f["anomaly_type"] == "Dependency Hallucination" for f in r) - - @pytest.mark.asyncio - async def test_real_pypi_package_not_flagged(self, mock_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "import requests\n" - r = await evaluate_dependency_hallucinations(code, "app.py") - assert r == [] - - @pytest.mark.asyncio - async def test_stdlib_not_checked(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "import os\nimport sys\nimport re\n" - r = await evaluate_dependency_hallucinations(code, "app.py") - assert r == [] - - @pytest.mark.asyncio - async def test_npm_hallucinated_package(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "import { foo } from 'totally-fake-npm-pkg-xyz';\n" - r = await evaluate_dependency_hallucinations(code, "index.ts") - assert any(f["anomaly_type"] == "Dependency Hallucination" for f in r) - - @pytest.mark.asyncio - async def test_relative_import_not_checked(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "import { helper } from './utils';\n" - r = await evaluate_dependency_hallucinations(code, "app.ts") - assert r == [] - - @pytest.mark.asyncio - async def test_go_stdlib_not_checked(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = 'import (\n "fmt"\n "os"\n "net/http"\n)\n' - r = await evaluate_dependency_hallucinations(code, "main.go") - assert r == [] - - @pytest.mark.asyncio - async def test_go_external_module_hallucination(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = 'import "github.com/totally/fake-module"\n' - r = await evaluate_dependency_hallucinations(code, "main.go") - assert any(f["anomaly_type"] == "Dependency Hallucination" for f in r) - - @pytest.mark.asyncio - async def test_ruby_gem_hallucination(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "gem 'totally_fake_gem_xyz', '~> 1.0'\n" - r = await evaluate_dependency_hallucinations(code, "Gemfile") - assert any(f["anomaly_type"] == "Dependency Hallucination" for f in r) - - @pytest.mark.asyncio - async def test_correct_line_number_reported(self, mock_not_found): - from app.engine.sandbox import evaluate_dependency_hallucinations - code = "# some comment\n\nimport totally_fake_package_xyz\n" - r = await evaluate_dependency_hallucinations(code, "app.py") - hallucinations = [f for f in r if f["anomaly_type"] == "Dependency Hallucination"] - assert hallucinations - assert hallucinations[0]["line_number"] == 3 # not 1 diff --git a/cli/src/http.rs b/cli/src/http.rs index 845cf2a..b8144ac 100644 --- a/cli/src/http.rs +++ b/cli/src/http.rs @@ -3,7 +3,7 @@ use reqwest::Client; use crate::config::SuppressConfig; use crate::setup; -use crate::types::{ScanPayload, ScanResult}; +use crate::types::{AuditFile, AuditPayload, AuditResult, ScanPayload, ScanResult}; const DEFAULT_BACKEND: &str = "http://127.0.0.1:8000"; @@ -55,6 +55,38 @@ pub async fn scan_file( Ok(response.json::().await?) } +/// Sends a batch of files to the backend audit endpoint for a repo-wide graph build. +pub async fn audit_repo( + client: &Client, + name: &str, + files: Vec, + suppress: SuppressConfig, +) -> Result { + let url = format!("{}/api/v1/audit", backend_url()); + let payload = AuditPayload { + name: name.to_string(), + files, + suppress, + }; + + // Longer than scan_file's timeout — this is a whole-repo batch, not the hook path, + // so it's fine (and expected) to take longer than a single-file scan. + let response = client + .post(&url) + .json(&payload) + .timeout(std::time::Duration::from_secs(120)) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + anyhow::bail!("Backend returned {}: {}", status, body); + } + + Ok(response.json::().await?) +} + /// Tries to start the backend, then polls until it becomes healthy. /// Fully async — no nested runtime, no blocking thread spawning. pub async fn try_start_backend() -> bool { diff --git a/cli/src/main.rs b/cli/src/main.rs index c6eaab1..15f13b4 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -40,6 +40,12 @@ enum Commands { /// Path to the file to scan file: std::path::PathBuf, }, + /// Repo-wide audit: scans every git-tracked file at once (not just the diff), + /// with graph-aware access-control checks and verified remediation patches. + Agent { + /// Identifies this run — e.g. the repo name + name: String, + }, } #[tokio::main] @@ -53,6 +59,7 @@ async fn main() -> Result<()> { Commands::Status => cmd_status().await, Commands::Hook => cmd_hook().await, Commands::Scan { file } => cmd_scan(file).await, + Commands::Agent { name } => cmd_agent(name).await, } } @@ -279,7 +286,14 @@ async fn cmd_hook() -> Result<()> { async fn cmd_scan(file: std::path::PathBuf) -> Result<()> { let content = std::fs::read_to_string(&file)?; - let filename = file.to_string_lossy().to_string(); + // Canonicalize so the backend's apply-patch file lookup (which runs from a + // different process, with its own unrelated cwd) gets an unambiguous path — + // a relative path here would be resolved against the wrong directory later. + let filename = file + .canonicalize() + .unwrap_or(file) + .to_string_lossy() + .to_string(); let client = Client::new(); if !http::health_check(&client).await { @@ -302,6 +316,55 @@ async fn cmd_scan(file: std::path::PathBuf) -> Result<()> { Ok(()) } +// ── agent (repo-wide graph audit) ─────────────────────────────────────────────── + +async fn cmd_agent(name: String) -> Result<()> { + let in_git = std::process::Command::new("git") + .args(["rev-parse", "--git-dir"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if !in_git { + eprintln!(" Not a git repository. Run inside a git repo."); + std::process::exit(1); + } + + let tracked = scanner::get_tracked_files()?; + + let client = Client::new(); + if !http::health_check(&client).await { + eprintln!("Backend is not running. Start it with: kshield start"); + std::process::exit(1); + } + + ui::print_agent_header(&name, tracked.len()); + + let files = tracked + .into_iter() + .map(|f| types::AuditFile { filename: f.filename, content: f.content }) + .collect(); + + // Real gap found in review: `.kshield.yml` was only ever loaded by + // cmd_scan/cmd_hook — `agent` silently ignored it entirely, so a + // suppressed rule/severity/path could still show up (and fail the + // build) in a repo-wide audit despite being configured to skip it + // everywhere else. + let suppress = config::load(); + let result = http::audit_repo(&client, &name, files, suppress).await?; + ui::print_audit_summary(&result); + + let has_critical = result + .findings + .iter() + .any(|f| matches!(f.severity.as_str(), "CRITICAL" | "HIGH")); + if has_critical { + std::process::exit(1); + } + + Ok(()) +} + // ── hook installer ──────────────────────────────────────────────────────────── fn install_hook() -> Result<()> { diff --git a/cli/src/scanner.rs b/cli/src/scanner.rs index 04b3b6b..a322ede 100644 --- a/cli/src/scanner.rs +++ b/cli/src/scanner.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Result}; +use std::path::PathBuf; use std::process::Command; pub struct StagedFile { @@ -6,10 +7,46 @@ pub struct StagedFile { pub content: String, } +/// `git diff --cached --name-only` / `git ls-files` both return paths relative to +/// the repo root — not the caller's current directory, which may be a subdirectory +/// of the repo. The backend's apply-patch endpoint looks the file up by that exact +/// `filename` string later (from a different process, with its own unrelated cwd), +/// so a repo-relative path is nearly always wrong by the time it gets there. Resolve +/// to an absolute path here, once, so it's unambiguous no matter who reads it next. +fn repo_root() -> Result { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output()?; + + if !output.status.success() { + bail!( + "git rev-parse --show-toplevel failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(PathBuf::from( + String::from_utf8(output.stdout)?.trim().to_string(), + )) +} + /// Returns the list of staged files with their content as they exist in the index. +/// +/// Every git subprocess here is pinned to the repo root via `.current_dir(&root)`, +/// deliberately, not left to inherit the caller's cwd. Two real bugs came from +/// trusting git's default path-relativity: `git diff --cached --name-only` happens +/// to already be repo-root-relative by default, which made an earlier version of +/// this function look correct when tested from a subdirectory — but `git show +/// :{filename}` interprets that same repo-relative path *relative to cwd*, so a +/// staged file outside the invoking subdirectory would silently fail to resolve. +/// Pinning cwd to root removes the ambiguity for every call, not just the ones a +/// test happened to exercise. pub fn get_staged_files() -> Result> { + let root = repo_root()?; + let output = Command::new("git") .args(["diff", "--cached", "--name-only", "--diff-filter=ACM"]) + .current_dir(&root) .output()?; if !output.status.success() { @@ -28,15 +65,17 @@ pub fn get_staged_files() -> Result> { let mut staged = Vec::new(); for filename in filenames { - // Read content from the git index (staged version, not working tree) + // Read content from the git index (staged version, not working tree). let blob = Command::new("git") .args(["show", &format!(":{}", filename)]) + .current_dir(&root) .output(); match blob { Ok(out) if out.status.success() => { if let Ok(content) = String::from_utf8(out.stdout) { - staged.push(StagedFile { filename, content }); + let absolute = root.join(&filename).to_string_lossy().to_string(); + staged.push(StagedFile { filename: absolute, content }); } // Binary files silently skipped (UTF-8 parse fails) } @@ -49,6 +88,50 @@ pub fn get_staged_files() -> Result> { Ok(staged) } +/// Returns every git-tracked file in the repo with its current working-tree content. +/// Used for full-repo audits (`kshield agent `), unlike `get_staged_files` +/// which only covers the pre-commit diff. +pub fn get_tracked_files() -> Result> { + let root = repo_root()?; + + // Real bug: `git ls-files` (unlike `git diff`) defaults to a scope AND a + // path format both relative to the caller's cwd, not the repo root — + // running `kshield agent` from any subdirectory silently audited only + // that subdirectory's files (or, combined with the absolute-path + // reconstruction below, could produce paths that don't exist at all). + // Pinning `.current_dir(&root)` fixes both the scope (whole repo, not + // just cwd's subtree) and the path format (root-relative) in one move. + let output = Command::new("git") + .args(["ls-files"]) + .current_dir(&root) + .output()?; + + if !output.status.success() { + bail!( + "git ls-files failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + let filenames: Vec = String::from_utf8(output.stdout)? + .lines() + .filter(|l| !l.is_empty()) + .map(String::from) + .collect(); + + let mut tracked = Vec::new(); + + for filename in filenames { + let absolute = root.join(&filename); + // Binary files silently skipped (UTF-8 read fails) + if let Ok(content) = std::fs::read_to_string(&absolute) { + tracked.push(StagedFile { filename: absolute.to_string_lossy().to_string(), content }); + } + } + + Ok(tracked) +} + /// Returns the short SHA of the current HEAD commit (empty string on a brand-new repo). pub fn get_head_sha() -> String { Command::new("git") diff --git a/cli/src/types.rs b/cli/src/types.rs index f586091..f3e1d60 100644 --- a/cli/src/types.rs +++ b/cli/src/types.rs @@ -33,3 +33,50 @@ pub struct ScanPayload { pub commit_sha: Option, pub suppress: crate::config::SuppressConfig, } + +#[derive(Debug, Serialize)] +pub struct AuditFile { + pub filename: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct AuditPayload { + pub name: String, + pub files: Vec, + pub suppress: crate::config::SuppressConfig, +} + +#[derive(Debug, Deserialize)] +pub struct AuditRoute { + pub path: String, + pub method: String, + pub handler: String, + pub filename: String, + pub line: u32, +} + +#[derive(Debug, Deserialize)] +pub struct AuditFinding { + pub line_number: u32, + pub anomaly_type: String, + pub severity: String, + pub description: String, + pub filename: String, + pub remediation: Remediation, +} + +#[derive(Debug, Deserialize)] +pub struct AuditResult { + #[allow(dead_code)] + pub name: String, + pub file_count: u32, + pub route_count: u32, + pub symbol_count: u32, + pub import_edge_count: u32, + pub parse_errors: std::collections::HashMap, + pub findings_count: u32, + pub quieted_count: u32, + pub findings: Vec, + pub routes: Vec, +} diff --git a/cli/src/ui.rs b/cli/src/ui.rs index 3b81aad..49c312d 100644 --- a/cli/src/ui.rs +++ b/cli/src/ui.rs @@ -106,6 +106,86 @@ pub fn print_clean(file_count: usize) { println!(); } +pub fn print_agent_header(name: &str, file_count: usize) { + println!(); + println!("{BOLD}{CYAN} KShield · Agent · {name} {RESET}"); + println!( + "{DIM} Building repo graph from {file_count} tracked file{}...{RESET}", + if file_count == 1 { "" } else { "s" } + ); + println!(); +} + +pub fn print_audit_summary(result: &crate::types::AuditResult) { + println!(" {BOLD}Files scanned{RESET} {}", result.file_count); + println!(" {BOLD}Routes found{RESET} {}", result.route_count); + println!(" {BOLD}Symbols indexed{RESET} {}", result.symbol_count); + println!(" {BOLD}Import edges{RESET} {}", result.import_edge_count); + println!(" {BOLD}Findings{RESET} {}", result.findings_count); + if result.quieted_count > 0 { + println!( + " {DIM}Quieted{RESET} {} {DIM}(suppressed by .kshield.yml, a global rule, or a previous dismissal){RESET}", + result.quieted_count + ); + } + + if !result.parse_errors.is_empty() { + println!( + " {YELLOW}Parse errors{RESET} {}", + result.parse_errors.len() + ); + for (filename, err) in &result.parse_errors { + println!(" {DIM}{filename}: {err}{RESET}"); + } + } + + if !result.findings.is_empty() { + println!(); + println!(" {BOLD}Findings{RESET}"); + for f in &result.findings { + println!( + " {} {DIM}{}:{}{RESET}", + severity_badge(&f.severity), + f.filename, + f.line_number + ); + println!(" {BOLD}{}{RESET}", f.anomaly_type); + println!(" {DIM}{}{RESET}", f.description); + if !f.remediation.explanation.is_empty() { + println!(" {CYAN}↳ {}{RESET}", f.remediation.explanation); + } + if !f.remediation.patch_diff.is_empty() { + println!(); + for line in f.remediation.patch_diff.lines().take(12) { + let col = if line.starts_with('+') && !line.starts_with("+++") { + GREEN + } else if line.starts_with('-') && !line.starts_with("---") { + RED + } else { + DIM + }; + println!(" {col}{line}{RESET}"); + } + } + println!(" {DIM}─────────────────────────────────────────{RESET}"); + } + } + + if !result.routes.is_empty() { + println!(); + println!(" {BOLD}Routes{RESET}"); + for r in &result.routes { + let path = if r.path.is_empty() { "(no path literal)" } else { &r.path }; + println!( + " {CYAN}{:<7}{RESET} {:<28} {DIM}-> {} ({}:{}){RESET}", + r.method, path, r.handler, r.filename, r.line + ); + } + } + + println!(); +} + pub fn print_backend_warning() { println!(); println!("{YELLOW}{BOLD} ⚠ KShield backend is not running {RESET}"); diff --git a/docs/algorithms.md b/docs/algorithms.md new file mode 100644 index 0000000..e56f228 --- /dev/null +++ b/docs/algorithms.md @@ -0,0 +1,147 @@ +# KShield — Detection Algorithms + +Companion to [features.md](features.md) (what each detector does and its +known gaps) and [architecture.md](architecture.md) (how the system is +wired together) — this is the third layer: the actual math behind each +detector, for anyone tuning thresholds or extending the engine. Every +formula and threshold below is quoted directly from the current source, +not paraphrased from memory — check the linked file if it's been retuned +since. + +--- + +## Shannon entropy — secret detection + +`backend/app/engine/entropy.py`, `_shannon_entropy()` + +``` +H(s) = -Σ p(c)·log₂(p(c)) over each unique character c in s + where p(c) = count(c) / len(s) +``` + +Measures how "random-looking" a string is, in bits per character. A +repeated or structured string (English text, an identifier, `"aaaaaaa"`) +scores low; a generated secret (`"kX9$mZ2pQvR8..."`) scores close to the +theoretical max (`log₂(alphabet size)`). + +- **Threshold:** flagged as `High Entropy Credential` (HIGH) if score > + **4.8** and the quoted literal is ≥ **24 characters** (`_ENTROPY_THRESHOLD`, + `_MIN_SECRET_LENGTH`). +- **Runs only as a fallback** — 30+ named signatures (GitHub/AWS/Stripe/ + OpenAI/Anthropic/etc. token shapes) are checked first via regex and + trusted CRITICAL outright; entropy only evaluates lines with no named + match. +- **Allowlist before scoring:** UUIDs, MD5/SHA-1/SHA-256/SHA-512 hex, + semver strings, and short base64 are excluded — all can score high on + entropy without being secrets. +- **Placeholder filter, applied to named-pattern matches too:** a template + value shaped like a real key (`sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`) + is rejected if fewer than 30% of its characters are unique + (`len(set(s)) / len(s) < 0.3`) — a real generated secret is very unlikely + to repeat one character that much. + +--- + +## Levenshtein edit distance — typosquat detection + +`backend/app/engine/dependency_audit.py`, `_levenshtein()` / `_closest_popular()` + +Standard dynamic-programming edit distance — minimum single-character +insertions/deletions/substitutions to turn one string into another — +implemented with the O(min(n,m)) space optimization (two rolling rows +instead of a full matrix), O(n·m) time. + +Every import not already declared/stdlib/first-party is compared against +a curated popular-package list (`_POPULAR_PYTHON`, `_POPULAR_NPM`): + +- Length pre-filter: skip candidates whose length differs by more than 2 + (pure speed — cuts the comparison set before running the DP). +- **Distance 1 → CRITICAL, distance 2 → HIGH, distance 0 or > 2 → ignored** + (0 means it *is* the popular package; > 2 is no longer a plausible typo). +- **Cross-checked against real registry existence before flagging.** + Real bug found dogfooding against `adk-python`: `retry` (a real PyPI + package) is edit-distance 2 from `poetry`, and `mcp` (Anthropic's real + SDK) is edit-distance 2 from `mypy` — two genuinely independent published + packages, not squatting on each other, just short names close together + in edit-distance space. Fix: a name already confirmed to exist on the + registry (`sandbox.py`'s cache, built once per repo-wide audit — see + `orchestrator.py`'s two-pass batching) is never flagged, regardless of + distance. + +--- + +## Signature normalization — false-positive memory + +`backend/app/engine/quiet_office.py` + +Not a distance metric — a normalize-then-exact-match approach. Findings +are template-generated, so the only parts that vary between two "same +kind of problem" findings are quoted identifiers and file paths: + +``` +signature(description) = lowercase( + description + .replace(/'[^']*'|"[^"]*"/, "") # quoted identifiers + .replace(/\b[\w./-]+\.(py|js|ts|...)\b/, "") # file paths +) +``` + +Dismissing one finding stores this normalized signature; every future +finding of the *same anomaly_type* gets its own signature computed and +compared for an **exact match** against stored ones. This is why +dismissing "Import `foo` used in `a.py`..." also auto-suppresses "Import +`bar` used in `b.py`..." — same shape, different variable — without ever +needing a similarity threshold to tune. + +Suppression here is sticky-additive, not overriding: a prior pass +(`.kshield.yml`/global-suppress rules, applied earlier in +`orchestrator.py`) may have already marked a finding suppressed for an +unrelated reason, and this step never flips that back to unsuppressed just +because it doesn't also match a dismissed signature. + +--- + +## Not real math: the "embedding" vector + +`backend/app/engine/model.py`, `generate_embedding_vector()` + +Flagged here explicitly because the name is misleading — this is not an +embedding model: + +```python +seed = int(hashlib.md5(text_payload.encode()).hexdigest(), 16) % (2**32) +rng = np.random.RandomState(seed) +vec = rng.randn(1536) +return (vec / np.linalg.norm(vec)).tolist() +``` + +It's a hash-seeded random unit vector in R¹⁵³⁶ (1536 chosen to match real +embedding-model dimensionality, so it fits the `pgvector` column shape). +Deterministic per exact input text, which is the only property it +actually has in common with a real embedding — **two findings with +similar-but-not-identical descriptions get essentially unrelated random +vectors**, because MD5 has no notion of semantic closeness. Any +cosine-similarity search built on this column today would be comparing +structured noise, not meaning. Already called out in +[features.md's Known Limitations](features.md#known-limitations); repeated +here because it's the one place in the engine where the name (`sequence +_classifier_node`, `generate_embedding_vector`) actively implies real ML +that isn't there — the actual "AI Structural Hallucination" detection is +100% curated regex (`_INDICATORS`, ~40 hand-written patterns), not a +classifier. Making this real would mean replacing this function with an +actual embedding-model call (local or API) — a real feature to build, not +a bug to fix in the current function. + +--- + +## Quick reference + +| Detector | Technique | Complexity | Threshold | +|---|---|---|---| +| Hardcoded Secret | Regex signature match | O(patterns × lines) | Named shape match | +| High Entropy Credential | Shannon entropy | O(n) per literal | > 4.8 bits/char, ≥ 24 chars | +| Possible Typosquat | Levenshtein distance | O(n·m) per candidate, length-filtered | distance ≤ 2 | +| False-positive memory | Signature normalize + exact match | O(1) per stored signature | Exact match only | +| AI Structural Hallucination | Regex pattern match | O(patterns × lines) | Named pattern match | +| Broken Access Control | AST/graph guard lookup | O(routes × files) | No `Depends()`/`Security()` found | +| Vulnerability embedding | *(not real — see above)* | — | — | diff --git a/docs/architecture-v1.md b/docs/architecture-v1.md new file mode 100644 index 0000000..a751aa9 --- /dev/null +++ b/docs/architecture-v1.md @@ -0,0 +1,244 @@ +# KShield — System Architecture — v1 (original) + +_This is a preserved historical snapshot: the system as of v1.1.0, before the repo-wide audit engine (`kshield agent`) and the `ksword` remediation agent existed — single-file scan/hook path only. Do not edit this file when the architecture changes again; add a new version instead and link it from [architecture.md](architecture.md), which always points at the current one._ + +**For the current architecture, see [architecture.md](architecture.md).** + +--- + +## Overview + +KShield is a **local-first** security analysis system. All code scanning, ML inference, and remediation generation happen on the developer's machine. No source code is transmitted to external servers. + +Four entry points: +- **`kshield init`** — one-time setup per repo: installs hook, downloads backend, starts it +- **Rust CLI** — `hook` subcommand runs on every `git commit`, blocks CRITICAL/HIGH findings +- **React Dashboard** — real-time telemetry UI, optionally wrapped in a Tauri native window +- **VS Code Extension** — inline diagnostics as you type, talking to the same local backend + +--- + +## Full System Diagram + +``` +╔═══════════════════════════════════════════════════════════════════════════╗ +║ DEVELOPER MACHINE ║ +╠═══════════════════════════════════════════════════════════════════════════╣ +║ ║ +║ Developer runs: kshield init (once per repo) ║ +║ │ ║ +║ ▼ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ Rust CLI (kshield binary) │ ║ +║ │ │ ║ +║ │ Commands Lifecycle │ ║ +║ │ · init → setup + hook · setup.rs downloads backend │ ║ +║ │ · hook → pre-commit scan · setup.rs creates venv │ ║ +║ │ · scan → manual file scan · setup.rs installs pip deps │ ║ +║ │ · start → spawn backend · setup.rs spawns uvicorn │ ║ +║ │ · stop → kill backend · PID tracked in backend.pid │ ║ +║ │ · status → health check · Logs in backend.log │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ │ manages │ colour TUI output ║ +║ ▼ ▼ (ANSI via ui.rs) ║ +║ ┌─────────────────────────────────┐ ┌────────────────────────────────┐ ║ +║ │ ~/.kshield/ │ │ Terminal │ ║ +║ │ ├── backend/ (Python src) │ │ COMMIT BLOCKED · 2 issues │ ║ +║ │ ├── venv/ (Python env) │ │ CRITICAL server.py:12 │ ║ +║ │ ├── kshield.db (SQLite) │ │ ↳ Move to env var │ ║ +║ │ ├── backend.pid │ └────────────────────────────────┘ ║ +║ │ └── backend.log │ ║ +║ └─────────────────────────────────┘ ║ +║ │ ║ +║ also writes │ .git/hooks/pre-commit (calls kshield hook) ║ +║ │ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ Tauri Desktop App (optional) │ ║ +║ │ · Native OS window wrapping the React dashboard │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ │ ║ +║ Renders │ ║ +║ ▼ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ React 19 Dashboard (port 5173 dev / port 3000 prod) │ ║ +║ │ · Security telemetry · Slide-over anomaly detail │ ║ +║ │ · Rule toggles · Light / dark theme │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ VS Code Extension (.vsix, installed locally or via Marketplace) │ ║ +║ │ · Scan on save (debounced) · Diagnostics + hover ELI5 │ ║ +║ │ · Quick Fix: apply patch / suppress rule · Status bar health check │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ ║ +╚═════════════════════════════╪═════════════════════════════════════════════╝ + │ HTTP (127.0.0.1:8000) + ▼ +╔═══════════════════════════════════════════════════════════════════════════╗ +║ FASTAPI BACKEND (port 8000) ║ +╠═══════════════════════════════════════════════════════════════════════════╣ +║ ║ +║ POST /api/v1/scan ║ +║ │ ║ +║ ▼ ║ +║ ┌──────────────────────┐ ┌─────────────────────────────────────────┐ ║ +║ │ Regex / Entropy │ │ Python AST Engine │ ║ +║ │ Scanner │ │ (structural access control checks) │ ║ +║ └──────────────────────┘ └─────────────────────────────────────────┘ ║ +║ │ │ ║ +║ └───────────────┬───────────────┘ ║ +║ ▼ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ Offline ML Classifier (NumPy — AI hallucination pattern matching) │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ Hallucination Guard (async PyPI / npm / Go proxy / RubyGems) │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ │ ║ +║ ▼ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ Remediation Engine (unified diff patches + ELI5 explanations) │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ │ Async SQLAlchemy ║ +║ ▼ ║ +║ ┌──────────────────────────────────┐ ┌──────────────────────────────┐ ║ +║ │ SQLite (local installs) │ │ PostgreSQL 16 + pgvector │ ║ +║ │ ~/.kshield/kshield.db │ │ (Docker Compose / prod) │ ║ +║ └──────────────────────────────────┘ └──────────────────────────────┘ ║ +║ ║ +╚═══════════════════════════════════════════════════════════════════════════╝ + ▲ + │ GitHub REST API — PR inline annotations +╔═══════════════════════════════════════════════════════════════════════════╗ +║ GITHUB ACTIONS CLOUD PIPELINE ║ +║ CI : push/PR → build CLI + backend + frontend, run import checks ║ +║ CD : tag push (v*) → build 4-platform binaries → publish GitHub Release ║ +╚═══════════════════════════════════════════════════════════════════════════╝ +``` + +--- + +## Install Flow + +``` +Developer + │ + ├─ curl -fsSL .../install.sh | bash + │ │ + │ └─ detects platform (macOS arm64 / x86, Linux arm64 / x86) + │ downloads kshield-{platform}.tar.gz from GitHub Releases + │ installs binary to /usr/local/bin/ + │ + └─ kshield init (inside a git repo) + │ + ├─ writes .git/hooks/pre-commit + ├─ runs setup.rs: + │ find_backend_dir() → ~/.kshield/backend/ + │ download_backend() → backend.tar.gz from GitHub Releases + │ create_venv() → ~/.kshield/venv/ + │ install_requirements() → pip install -r requirements.txt + └─ start_backend_process() → uvicorn in background +``` + +--- + +## Data Flow (Pre-Commit Scan) + +``` +1. Developer runs git commit +2. .git/hooks/pre-commit executes kshield hook +3. scanner.rs reads staged files via git diff --cached --name-only +4. scanner.rs reads file content from git index via git show : +5. http.rs sends POST /api/v1/scan for each file +6. Backend pipeline runs in sequence: + a. Regex/entropy scanner → hardcoded secrets, high-entropy strings + b. AST engine → broken access control, syntax violations + c. ML classifier → AI hallucination placeholder patterns + d. Hallucination guard → PyPI / npm / Go proxy / RubyGems verification + e. Remediation engine → diff patches + ELI5 explanations +7. Results stored in SQLite / PostgreSQL (with pgvector embeddings) +8. http.rs receives ScanResult JSON +9. ui.rs renders colour output to terminal +10. If CRITICAL or HIGH found → exit code 1 → commit blocked +11. If clean → exit code 0 → commit proceeds +``` + +--- + +## Release Flow + +``` +Developer pushes git tag v1.0.0 + │ + └─ GitHub Actions: release.yml + │ + ├─ build matrix: + │ aarch64-apple-darwin (macos-14 runner) + │ x86_64-apple-darwin (macos-13 runner) + │ x86_64-unknown-linux-gnu (ubuntu-22.04 runner) + │ aarch64-unknown-linux-gnu (ubuntu-22.04 + cross) + │ + ├─ package-backend job: + │ tar -czf backend.tar.gz backend/app backend/requirements.txt + │ + └─ publish job: + merges checksums + patches homebrew/kshield.rb with real SHA256s + creates GitHub Release with all .tar.gz + checksums.txt +``` + +--- + +## Component–File Map + +| Layer | Directory | Key Files | +|---|---|---| +| Rust CLI entry | `cli/src/` | `main.rs` — subcommands, init, hook, scan | +| Backend lifecycle | `cli/src/` | `setup.rs` — download, venv, install, start, stop, pid | +| Backend API client | `cli/src/` | `http.rs` — health check, scan_file, try_start_backend | +| Git integration | `cli/src/` | `scanner.rs` — staged files, HEAD SHA | +| Terminal output | `cli/src/` | `ui.rs` — ANSI colour output for all commands | +| Shared types | `cli/src/` | `types.rs` — ScanResult, Anomaly, ScanPayload | +| FastAPI router | `backend/app/api/v1/` | `scan.py` | +| Analysis engine | `backend/app/engine/` | `entropy.py` · `ast_rules.py` · `model.py` · `sandbox.py` · `remediation.py` | +| ORM models | `backend/app/models/` | `scans.py` · `vulnerabilities.py` · `false_positives.py` · `configurations.py` | +| DB session | `backend/app/db/` | `session.py` — SQLite fallback, async session, pool config | +| SQL migrations | `backend/migrations/` | `init.sql` — used for PostgreSQL; SQLite auto-creates via ORM | +| React app | `frontend/src/` | `App.tsx` · `main.tsx` · `index.css` | +| React components | `frontend/src/components/` | `Dashboard.tsx` · `Settings.tsx` · `Sidebar.tsx` | +| TypeScript types | `frontend/src/types/` | `scan.ts` | +| VS Code extension | `vscode-extension/src/` | `extension.ts` · `apiClient.ts` · `diagnostics.ts` · `hoverProvider.ts` · `codeActionProvider.ts` · `patch.ts` | +| npm wrapper | `npm/` | `package.json` · `bin/kshield.js` · `scripts/install.js` | +| pip package | `/` | `pyproject.toml` · `backend/kshield_backend/cli.py` | +| curl installer | `/` | `install.sh` | +| Homebrew formula | `homebrew/` | `kshield.rb` | +| Tauri wrapper | `src-tauri/` | `src/main.rs` · `tauri.conf.json` | +| CI pipeline | `.github/workflows/` | `kshield-ci.yml` · `release.yml` | +| Licensing | `/`, `vscode-extension/` | `LICENSE` (MIT, root project + bundled into the extension `.vsix`) | +| Docs | `docs/` | `architecture.md` · `setup.md` | + +--- + +## Port Reference + +| Service | Port | Mode | +|---|---|---| +| FastAPI backend | 8000 | All modes | +| React dev server | 5173 | Development | +| Nginx (prod frontend) | 3000 | Docker Compose | +| PostgreSQL | 5432 | Docker Compose / production | +| SQLite | — | Local / managed install | + +--- + +## Security Boundaries + +- All source code stays on the developer's machine +- FastAPI binds to `127.0.0.1` only — not reachable from other machines +- SQLite database stored in `~/.kshield/` — local to the user +- PostgreSQL binds to the Docker internal network only +- GitHub Actions PR scanner receives only the **changed line diff**, not full file content +- No API keys or credentials required for local operation +- CORS restricted to explicit origin list (not `*`) diff --git a/docs/architecture.md b/docs/architecture.md index 8f67c74..d3f1526 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,17 +1,82 @@ # KShield — System Architecture +_This document always reflects the current architecture. Prior versions are kept as separate files, not sections — see [architecture-v1.md](architecture-v1.md) for the pre-audit-engine snapshot (v1.1.0 and earlier). When the architecture changes again, save the current content of this file as `architecture-v.md` before editing it, and link the new snapshot from here._ + ## Overview -KShield is a **local-first** security analysis system. All code scanning, ML inference, and remediation generation happen on the developer's machine. No source code is transmitted to external servers. +KShield is a **local-first** security analysis system. All code scanning, pattern matching, and remediation generation happen on the developer's machine. No source code is transmitted to external servers, and no LLM or external model is called at any point — every finding and every patch is produced by local regex/AST/graph logic. -Four entry points: +Five entry points: - **`kshield init`** — one-time setup per repo: installs hook, downloads backend, starts it -- **Rust CLI** — `hook` subcommand runs on every `git commit`, blocks CRITICAL/HIGH findings +- **Rust CLI, single-file path** — `hook` (every `git commit`) and `scan ` block CRITICAL/HIGH findings for one file +- **Rust CLI, repo-wide path** — `agent ` audits every git-tracked file in one pass, graph-aware; `org-audit.sh` chains this across a whole GitHub org - **React Dashboard** — real-time telemetry UI, optionally wrapped in a Tauri native window - **VS Code Extension** — inline diagnostics as you type, talking to the same local backend --- +## Architecture Diagram (v2) + +_Reflects `dev` as of 2026-07-19 — CHANGELOG **[Unreleased]** (post-1.1.0), covering the repo-wide audit engine and the `ksword` remediation agent. For the pre-audit-engine system, see [architecture-v1.md](architecture-v1.md)._ + +```mermaid +flowchart TB + subgraph Dev["Developer Machine"] + direction TB + CLI["Rust CLI (kshield)"] + Hook[".git/hooks/pre-commit"] + Dashboard["React Dashboard\n(browser or Tauri window)"] + VSCode["VS Code Extension"] + OrgAudit["org-audit.sh\n(loops kshield agent across a GitHub org)"] + end + + Hook -->|"git commit"| CLI + OrgAudit -->|"per repo"| CLI + + CLI -->|"hook / scan (file)"| ScanAPI + CLI -->|"agent (name)"| AuditAPI + VSCode -->|"on save"| ScanAPI + Dashboard -->|"GET /scans, /telemetry\nPOST /suppress, /apply-patch"| ActionsAPI + + subgraph Backend["FastAPI Backend — 127.0.0.1:8000"] + direction TB + ScanAPI["POST /api/v1/scan"] + AuditAPI["POST /api/v1/audit"] + ActionsAPI["/api/v1/scans · /telemetry\n/suppress · /apply-patch"] + + subgraph SingleFile["Single-file engines"] + direction LR + Entropy["entropy.py\nsecrets + Shannon entropy"] + AstRules["ast_rules.py\naccess control + syntax"] + Model["model.py\nregex hallucination patterns"] + Sandbox["sandbox.py\nPyPI/npm/Go/RubyGems check"] + end + + subgraph RepoWide["Repo-wide audit engines"] + direction LR + Graph["graph_builder.py\nimports, symbols, routes"] + AccessControl["access_control.py\ngraph-aware, reuses real guards"] + Orchestrator["orchestrator.py\nsequences the audit"] + DepAudit["dependency_audit.py\ntyposquat + undeclared deps"] + QuietOffice["quiet_office.py +\npattern_archive.py\nfalse-positive memory"] + end + + Ksword["ksword.py\npropose → apply → re-check → return\n(only if the finding actually cleared)"] + + ScanAPI --> SingleFile --> Ksword + AuditAPI --> Graph --> AccessControl --> Orchestrator --> DepAudit --> QuietOffice --> Ksword + ActionsAPI -.->|"apply-patch re-derives the patch"| Ksword + + Ksword --> DB[("SQLite (local)\nor PostgreSQL 16 + pgvector")] + SingleFile -.-> DB + Orchestrator -.-> DB + end + + Backend -->|"GitHub REST API\nPR inline review comments"| CIPipeline["GitHub Actions\nci.yml pr-scan job"] +``` + +--- + ## Full System Diagram ``` @@ -19,34 +84,42 @@ Four entry points: ║ DEVELOPER MACHINE ║ ╠═══════════════════════════════════════════════════════════════════════════╣ ║ ║ -║ Developer runs: kshield init (once per repo) ║ +║ Developer runs: kshield init (once per repo) ║ ║ │ ║ ║ ▼ ║ ║ ┌─────────────────────────────────────────────────────────────────────┐ ║ -║ │ Rust CLI (kshield binary) │ ║ +║ │ Rust CLI (kshield binary) │ ║ ║ │ │ ║ -║ │ Commands Lifecycle │ ║ -║ │ · init → setup + hook · setup.rs downloads backend │ ║ -║ │ · hook → pre-commit scan · setup.rs creates venv │ ║ -║ │ · scan → manual file scan · setup.rs installs pip deps │ ║ -║ │ · start → spawn backend · setup.rs spawns uvicorn │ ║ -║ │ · stop → kill backend · PID tracked in backend.pid │ ║ -║ │ · status → health check · Logs in backend.log │ ║ +║ │ Commands Lifecycle │ ║ +║ │ · init → setup + hook · setup.rs downloads backend │ ║ +║ │ · hook → pre-commit scan · setup.rs creates venv │ ║ +║ │ · scan → manual file scan · setup.rs installs pip deps │ ║ +║ │ · agent → repo-wide audit · setup.rs spawns uvicorn │ ║ +║ │ · start → spawn backend · PID tracked in backend.pid │ ║ +║ │ · stop → kill backend · Logs in backend.log │ ║ +║ │ · status → health check │ ║ ║ └─────────────────────────────────────────────────────────────────────┘ ║ -║ │ manages │ colour TUI output ║ +║ │ manages │ colour terminal output ║ ║ ▼ ▼ (ANSI via ui.rs) ║ ║ ┌─────────────────────────────────┐ ┌────────────────────────────────┐ ║ -║ │ ~/.kshield/ │ │ Terminal │ ║ -║ │ ├── backend/ (Python src) │ │ COMMIT BLOCKED · 2 issues │ ║ -║ │ ├── venv/ (Python env) │ │ CRITICAL server.py:12 │ ║ -║ │ ├── kshield.db (SQLite) │ │ ↳ Move to env var │ ║ -║ │ ├── backend.pid │ └────────────────────────────────┘ ║ -║ │ └── backend.log │ ║ -║ └─────────────────────────────────┘ ║ +║ │ ~/.kshield/ │ │ Terminal │ ║ +║ │ ├── backend/ (Python src) │ │ COMMIT BLOCKED · 2 issues │ ║ +║ │ ├── venv/ (Python env) │ │ CRITICAL server.py:12 │ ║ +║ │ ├── kshield.db (SQLite) │ │ ↳ os.getenv("API_KEY") │ ║ +║ │ ├── backend.pid │ │ · or, for `agent`: │ ║ +║ │ └── backend.log │ │ Files scanned 127 · Findings 12│ ║ +║ └─────────────────────────────────┘ └────────────────────────────────┘ ║ ║ │ ║ -║ also writes │ .git/hooks/pre-commit (calls kshield hook) ║ +║ also writes │ .git/hooks/pre-commit (calls kshield hook) ║ ║ │ ║ ║ ┌─────────────────────────────────────────────────────────────────────┐ ║ +║ │ org-audit.sh (optional, org-wide) │ ║ +║ │ · Clones every non-archived repo in a GitHub org (gh CLI) │ ║ +║ │ · Runs `kshield agent ` per repo, in parallel │ ║ +║ │ · Reports a ranked summary from the local audit_runs table │ ║ +║ └─────────────────────────────────────────────────────────────────────┘ ║ +║ ║ +║ ┌─────────────────────────────────────────────────────────────────────┐ ║ ║ │ Tauri Desktop App (optional) │ ║ ║ │ · Native OS window wrapping the React dashboard │ ║ ║ └─────────────────────────────────────────────────────────────────────┘ ║ @@ -54,9 +127,9 @@ Four entry points: ║ Renders │ ║ ║ ▼ ║ ║ ┌─────────────────────────────────────────────────────────────────────┐ ║ -║ │ React 19 Dashboard (port 5173 dev / port 3000 prod) │ ║ -║ │ · Security telemetry · Slide-over anomaly detail │ ║ -║ │ · Rule toggles · Light / dark theme │ ║ +║ │ React 19 Dashboard (port 5173 dev / port 3000 prod) │ ║ +║ │ · Security telemetry · Slide-over anomaly detail · Filter chips │ ║ +║ │ · Rule toggles · Light / dark theme · Toast notifications │ ║ ║ └─────────────────────────────────────────────────────────────────────┘ ║ ║ ║ ║ ┌─────────────────────────────────────────────────────────────────────┐ ║ @@ -72,43 +145,62 @@ Four entry points: ║ FASTAPI BACKEND (port 8000) ║ ╠═══════════════════════════════════════════════════════════════════════════╣ ║ ║ -║ POST /api/v1/scan ║ -║ │ ║ -║ ▼ ║ -║ ┌──────────────────────┐ ┌─────────────────────────────────────────┐ ║ -║ │ Regex / Entropy │ │ Python AST Engine │ ║ -║ │ Scanner │ │ (structural access control checks) │ ║ -║ └──────────────────────┘ └─────────────────────────────────────────┘ ║ -║ │ │ ║ -║ └───────────────┬───────────────┘ ║ -║ ▼ ║ +║ POST /api/v1/scan (single file) POST /api/v1/audit (whole repo) ║ +║ │ │ ║ +║ ▼ ▼ ║ +║ ┌─────────────────────────┐ ┌─────────────────────────────────┐ ║ +║ │ Regex / Entropy Scanner │ │ graph_builder.py │ ║ +║ │ (entropy.py) │ │ one RepoGraph: imports, symbols,│ ║ +║ ├─────────────────────────┤ │ every route + its guards │ ║ +║ │ AST Engine │ ├─────────────────────────────────┤ ║ +║ │ (ast_rules.py) │ │ access_control.py │ ║ +║ ├─────────────────────────┤ │ graph-aware access control — │ ║ +║ │ Hallucination Patterns │ │ reuses real guards, escalates │ ║ +║ │ (model.py, regex-based) │ │ severity on sensitive paths │ ║ +║ ├─────────────────────────┤ ├─────────────────────────────────┤ ║ +║ │ Dependency Sandbox │ │ orchestrator.py │ ║ +║ │ (sandbox.py — PyPI / npm │ │ sequences the audit; batches │ ║ +║ │ / Go proxy / RubyGems) │ │ every registry lookup in the │ ║ +║ └─────────────────────────┘ │ repo into one concurrent pass │ ║ +║ │ ├─────────────────────────────────┤ ║ +║ │ │ dependency_audit.py │ ║ +║ │ │ typosquat (Levenshtein) + │ ║ +║ │ │ undeclared-dependency checks │ ║ +║ │ ├─────────────────────────────────┤ ║ +║ │ │ quiet_office.py + │ ║ +║ │ │ pattern_archive.py │ ║ +║ │ │ false-positive memory by │ ║ +║ │ │ normalized finding signature │ ║ +║ │ └─────────────────────────────────┘ ║ +║ │ │ ║ +║ └───────────────────┬───────────────────┘ ║ +║ ▼ ║ ║ ┌─────────────────────────────────────────────────────────────────────┐ ║ -║ │ Offline ML Classifier (NumPy — AI hallucination pattern matching) │ ║ -║ └─────────────────────────────────────────────────────────────────────┘ ║ -║ │ ║ -║ ▼ ║ -║ ┌─────────────────────────────────────────────────────────────────────┐ ║ -║ │ Hallucination Guard (async PyPI / npm / Go proxy / RubyGems) │ ║ -║ └─────────────────────────────────────────────────────────────────────┘ ║ -║ │ ║ -║ ▼ ║ -║ ┌─────────────────────────────────────────────────────────────────────┐ ║ -║ │ Remediation Engine (unified diff patches + ELI5 explanations) │ ║ +║ │ ksword.py — verified auto-remediation │ ║ +║ │ propose fix → apply in-memory → re-run the SAME check that raised │ ║ +║ │ the finding → return the diff only if that re-check confirms it │ ║ +║ │ actually cleared. No network calls, no LLM/model inference. │ ║ ║ └─────────────────────────────────────────────────────────────────────┘ ║ ║ │ Async SQLAlchemy ║ ║ ▼ ║ ║ ┌──────────────────────────────────┐ ┌──────────────────────────────┐ ║ ║ │ SQLite (local installs) │ │ PostgreSQL 16 + pgvector │ ║ -║ │ ~/.kshield/kshield.db │ │ (Docker Compose / prod) │ ║ +║ │ ~/.kshield/kshield.db │ │ (Docker Compose / prod) │ ║ +║ │ scans · audit_runs · │ │ │ ║ +║ │ vulnerabilities · false_positives│ │ │ ║ ║ └──────────────────────────────────┘ └──────────────────────────────┘ ║ ║ ║ ╚═══════════════════════════════════════════════════════════════════════════╝ ▲ - │ GitHub REST API — PR inline annotations + │ GitHub REST API — PR inline review comments ╔═══════════════════════════════════════════════════════════════════════════╗ -║ GITHUB ACTIONS CLOUD PIPELINE ║ -║ CI : push/PR → build CLI + backend + frontend, run import checks ║ -║ CD : tag push (v*) → build 4-platform binaries → publish GitHub Release ║ +║ GITHUB ACTIONS CI/CD (.github/workflows/) ║ +║ ci.yml : push/PR → cli + backend + frontend + vscode-extension ║ +║ build/test jobs, plus pr-scan (posts /api/v1/scan ║ +║ findings as inline PR review comments) ║ +║ release.yml : tag push (v*) → build 4-platform CLI binaries + PyPI, ║ +║ publish GitHub Release ║ +║ deploy-pages.yml : frontend → GitHub Pages ║ ╚═══════════════════════════════════════════════════════════════════════════╝ ``` @@ -151,7 +243,9 @@ Developer b. AST engine → broken access control, syntax violations c. ML classifier → AI hallucination placeholder patterns d. Hallucination guard → PyPI / npm / Go proxy / RubyGems verification - e. Remediation engine → diff patches + ELI5 explanations + e. ksword → proposes a patch, applies it in-memory, re-runs the + check that raised the finding, and only returns the + diff if that re-check confirms it actually cleared 7. Results stored in SQLite / PostgreSQL (with pgvector embeddings) 8. http.rs receives ScanResult JSON 9. ui.rs renders colour output to terminal @@ -161,6 +255,35 @@ Developer --- +## Data Flow (Repo-Wide Audit) + +``` +1. Developer runs kshield agent +2. scanner.rs reads every git-tracked file (not just staged) via git ls-files +3. http.rs sends one POST /api/v1/audit with all files in the payload +4. Backend pipeline (orchestrator.py::run_audit) runs in sequence: + a. graph_builder.py → one RepoGraph: imports, symbols, every route + its guards, + parse errors — built once, reused by every check below + b. access_control.py → graph-aware Broken Access Control, using real guard names + already proven elsewhere in the repo + c. Syntax Violation findings reused directly from the graph build (no re-parsing) + d. Dependency registry checks run as one batched, concurrency-bounded pass across + every file first (collect_only mode) — not sequentially per file + e. Per-file entropy / hallucination / dependency-hallucination scans reuse that + registry cache + f. dependency_audit.py → Possible Typosquat / Undeclared Dependency findings + g. quiet_office.py → findings matching a previously dismissed signature are + tagged suppressed, not re-flagged + h. ksword → same verify-before-return remediation as the scan path, + attached per finding +5. Result persisted as one AuditRun row (file/finding/severity counts) +6. http.rs receives AuditResult JSON +7. ui.rs renders the summary + findings + routes to terminal +8. If any active CRITICAL or HIGH finding remains → exit code 1 +``` + +--- + ## Release Flow ``` @@ -189,29 +312,35 @@ Developer pushes git tag v1.0.0 | Layer | Directory | Key Files | |---|---|---| -| Rust CLI entry | `cli/src/` | `main.rs` — subcommands, init, hook, scan | +| Rust CLI entry | `cli/src/` | `main.rs` — subcommands: init, hook, scan, agent | | Backend lifecycle | `cli/src/` | `setup.rs` — download, venv, install, start, stop, pid | -| Backend API client | `cli/src/` | `http.rs` — health check, scan_file, try_start_backend | -| Git integration | `cli/src/` | `scanner.rs` — staged files, HEAD SHA | -| Terminal output | `cli/src/` | `ui.rs` — ANSI colour output for all commands | -| Shared types | `cli/src/` | `types.rs` — ScanResult, Anomaly, ScanPayload | -| FastAPI router | `backend/app/api/v1/` | `scan.py` | -| Analysis engine | `backend/app/engine/` | `entropy.py` · `ast_rules.py` · `model.py` · `sandbox.py` · `remediation.py` | -| ORM models | `backend/app/models/` | `scans.py` · `vulnerabilities.py` · `false_positives.py` · `configurations.py` | +| Backend API client | `cli/src/` | `http.rs` — health check, scan_file, audit_repo | +| Git integration | `cli/src/` | `scanner.rs` — staged files (scan/hook) and tracked files (agent) | +| Terminal output | `cli/src/` | `ui.rs` — ANSI colour output, incl. `print_audit_summary` | +| Shared types | `cli/src/` | `types.rs` — ScanResult, Anomaly, AuditResult, AuditFinding, Remediation | +| Org-wide audit | `/` | `org-audit.sh` — loops `kshield agent` across every repo in a GitHub org | +| FastAPI router — single-file | `backend/app/api/v1/` | `scan.py` | +| FastAPI router — repo-wide | `backend/app/api/v1/` | `audit.py` | +| FastAPI router — actions/history | `backend/app/api/v1/` | `actions.py` (suppress, apply-patch) · `history.py` (scans, telemetry) · `patterns.py` (auth-keywords) | +| Single-file scan engines | `backend/app/engine/` | `entropy.py` · `ast_rules.py` · `model.py` · `sandbox.py` | +| Repo-wide audit engines | `backend/app/engine/` | `graph_builder.py` · `access_control.py` · `orchestrator.py` · `dependency_audit.py` · `quiet_office.py` · `pattern_archive.py` | +| Remediation agent | `backend/app/engine/` | `ksword.py` — verified patch generation, shared by both pipelines | +| ORM models | `backend/app/models/` | `scans.py` · `audit_runs.py` · `vulnerabilities.py` · `false_positives.py` · `configurations.py` | | DB session | `backend/app/db/` | `session.py` — SQLite fallback, async session, pool config | | SQL migrations | `backend/migrations/` | `init.sql` — used for PostgreSQL; SQLite auto-creates via ORM | | React app | `frontend/src/` | `App.tsx` · `main.tsx` · `index.css` | -| React components | `frontend/src/components/` | `Dashboard.tsx` · `Settings.tsx` · `Sidebar.tsx` | +| React API client | `frontend/src/api/` | `client.ts` — the only place that calls the backend | +| React components | `frontend/src/components/` | `Dashboard.tsx` · `Settings.tsx` · `Sidebar.tsx` · `Docs.tsx` | | TypeScript types | `frontend/src/types/` | `scan.ts` | -| VS Code extension | `vscode-extension/src/` | `extension.ts` · `apiClient.ts` · `diagnostics.ts` · `hoverProvider.ts` · `codeActionProvider.ts` · `patch.ts` | +| VS Code extension | `vscode-extension/src/` | `extension.ts` · `apiClient.ts` · `diagnostics.ts` · `hoverProvider.ts` · `codeActionProvider.ts` · `patch.ts` · `statusBar.ts` | | npm wrapper | `npm/` | `package.json` · `bin/kshield.js` · `scripts/install.js` | | pip package | `/` | `pyproject.toml` · `backend/kshield_backend/cli.py` | | curl installer | `/` | `install.sh` | | Homebrew formula | `homebrew/` | `kshield.rb` | | Tauri wrapper | `src-tauri/` | `src/main.rs` · `tauri.conf.json` | -| CI pipeline | `.github/workflows/` | `kshield-ci.yml` · `release.yml` | +| CI pipeline | `.github/workflows/` | `ci.yml` (build/test + PR scan) · `release.yml` · `deploy-pages.yml` | | Licensing | `/`, `vscode-extension/` | `LICENSE` (MIT, root project + bundled into the extension `.vsix`) | -| Docs | `docs/` | `architecture.md` · `setup.md` | +| Docs | `docs/`, `/` | `architecture.md` (current) · `architecture-v1.md` (history) · `setup.md` · `features.md` (verified capability reference) · `SKILLS.md` (AI-agent extension guide) | --- @@ -236,3 +365,4 @@ Developer pushes git tag v1.0.0 - GitHub Actions PR scanner receives only the **changed line diff**, not full file content - No API keys or credentials required for local operation - CORS restricted to explicit origin list (not `*`) +- `ksword.py` makes zero network calls and calls no LLM/external model — every remediation decision comes from the target file's own AST or text, verified by re-running the local check that raised the finding diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..0dd822d --- /dev/null +++ b/docs/features.md @@ -0,0 +1,109 @@ +# KShield — Feature Reference + +A complete, accurate account of what KShield actually does today. Unlike the README (which sells it), this document is meant to be checked against — every claim here has been verified live against a running backend, not just read from source. Where something is a known gap rather than a feature, it's listed as one; see [Known Limitations](#known-limitations). + +For the system diagrams behind these features, see [architecture.md](architecture.md). For the actual math behind each detector — formulas, thresholds, complexity — see [algorithms.md](algorithms.md). + +--- + +## Two scanning pipelines + +KShield has two genuinely different entry points, not one feature with two names: + +| | Single-file scan | Repo-wide audit | +|---|---|---| +| Triggered by | `kshield hook` (every `git commit`), `kshield scan ` | `kshield agent ` | +| Backend endpoint | `POST /api/v1/scan` | `POST /api/v1/audit` | +| Scope | One file (or the staged diff) | Every git-tracked file, in one pass | +| Access-control engine | `ast_rules.py` — simple, no cross-file context | `access_control.py` — graph-aware, reuses guards proven elsewhere in the repo | +| Typosquat / undeclared-dependency checks | Not available | Available (`dependency_audit.py`) | +| False-positive memory | Not applied | Applied (`quiet_office.py` + `pattern_archive.py`) | + +Both pipelines feed the same remediation agent (`ksword.py`) and the same finding schema, so CLI/dashboard/VS Code rendering is identical either way. + +--- + +## Detection capabilities + +### Hardcoded Secrets — CRITICAL +30+ named token signatures (GitHub, AWS, Google, Stripe, SendGrid, Twilio, Slack, Discord, npm, PyPI, HuggingFace, OpenAI, Anthropic, private key blocks, and a generic `api_key = "..."`-shaped catch-all), plus a Shannon-entropy fallback (score > 4.8, length ≥ 24) for anything that doesn't match a named pattern. UUIDs, MD5/SHA-1/SHA-256/SHA-512 hex, semver strings, and short base64 are allowlisted. **Language-agnostic** — this works on any text file, not just Python. + +### Broken Access Control — MEDIUM / HIGH / CRITICAL +**Python/FastAPI only.** Flags route handlers with no `Depends()`/`Security()` guard, in either the function signature or the decorator's `dependencies=[...]`. Severity: GET → MEDIUM, mutating methods → HIGH, escalated one step further if the path touches something sensitive (`/admin`, `/payment`, `/billing`, etc.). Public paths (`/health`, `/docs`, `/metrics`, ...) are exempt. The graph-aware version (repo-wide audit only) also recognizes inline signature/HMAC verification in a handler's body as a valid guard shape, for webhook-style routes that can never carry a logged-in-user dependency. + +**Verified against a real non-Python codebase (a production Fastify payments API) that this does not extend to JS/TS at all today** — see [Known Limitations](#known-limitations). + +### AI Structural Hallucination — LOW / MEDIUM / HIGH +Regex-pattern matching (not a trained model, despite `tensorflow` having once been listed as a dependency — it was never imported and has since been removed) across five categories: placeholder markers, credential stubs, hallucinated/mock imports, AI-generation artifacts ("as an AI, I cannot..."), and dead-code stubs (bare `raise NotImplementedError`, trailing `...`). Test files are exempt. + +### Dependency Hallucination — CRITICAL +Verifies every import against the real package registry: PyPI, npm, the Go module proxy, RubyGems. Standard-library modules are skipped. Registry timeouts fail open (never blocks a commit on a network hiccup). Known name-mismatches (`yaml`→`PyYAML`, `cv2`→`opencv-python`, etc.) are handled so real packages published under a different import name aren't misflagged. + +### Possible Typosquat — HIGH / CRITICAL *(repo-wide audit only)* +Levenshtein-distance check against a curated list of well-known Python/npm packages. An import within edit-distance 2 of a popular name, and not independently verified as its own real package, gets flagged — distance 1 is CRITICAL, distance 2 is HIGH. + +### Undeclared Dependency — LOW *(repo-wide audit only)* +An import that isn't in `requirements.txt`/`pyproject.toml`/`package.json` and isn't stdlib, first-party, or a known-popular name. No remediation offered — only the developer can say whether it should be added to a manifest. + +### Syntax Violation — MEDIUM +Files that fail to parse are flagged directly — often the signature of AI-generated code truncated mid-edit. + +--- + +## Verified auto-remediation (ksword) + +Not a template engine and not an LLM. Every strategy runs a strict loop: **propose a fix → apply it in-memory → re-run the exact check that raised the finding against the patched result → only return the diff if that re-check confirms the finding actually cleared.** No network calls, no model inference — every decision comes from the target file's own AST or text. + +| Finding type | What it does | +|---|---| +| Hardcoded Secret / High Entropy Credential | Extracts the literal to `os.getenv("VAR_NAME")`, adding `import os` if missing | +| Broken Access Control | Reuses a `Depends()`/`Security()` guard **already proven to work elsewhere in the same file** — never invents a guard name. Declines if nothing real exists to reuse. | +| Possible Typosquat | Corrects the import to the well-known name already identified by the typosquat check | +| Dependency Hallucination | No patch — offers a "closest well-known name" suggestion instead, since a rule engine can't safely guess the real package | +| Syntax Violation / AI Structural Hallucination / Undeclared Dependency | Explanation only, by design — these require human judgment or the original intent, which a rule engine can't recover | + +Every returned `patch_diff` has been proven to `git apply` cleanly and to clear the finding on re-scan — this was not always true (see CHANGELOG for the three-bug chain that made `apply-patch` non-functional until fixed this cycle) but is now verified live end-to-end. + +--- + +## False-positive memory *(repo-wide audit only)* + +- **Quiet Office** (`quiet_office.py`): dismiss a finding once (`POST /api/v1/audit/dismiss-finding`) and its normalized signature (quoted identifiers and file paths stripped) is remembered — matching future findings of the same type are auto-suppressed, not just the one exact string. +- **Pattern Archive** (`pattern_archive.py`): teach a project-specific auth-guard name (`POST /api/v1/patterns/auth-keywords`, e.g. `verify_org_membership`) once, and every future audit recognizes it as a real guard — persists across runs via the `configurations` table. + +--- + +## Repo-wide and org-wide audit + +- `kshield agent ` — scans every git-tracked file in the current repo in one pass, regardless of which subdirectory it's invoked from. Builds a full repo graph (imports, symbols, every route + its guards, parse errors) before any check runs, so access-control checks understand what's genuinely reachable rather than judging one file in isolation. +- `org-audit.sh` — clones every non-archived repo in a GitHub org (via the `gh` CLI) and runs `kshield agent` against each one in parallel, reporting a ranked summary (by CRITICAL/HIGH count) read back from the local `audit_runs` table. +- Every audit run is persisted with file/finding/severity counts (`GET /api/v1/audit/runs`). + +--- + +## Interfaces + +- **Rust CLI** (`kshield`) — `init`, `setup`, `start`, `stop`, `status`, `hook`, `scan `, `agent `. Plain ANSI terminal output, no TUI framework. +- **React Dashboard** — scan/audit history, filter chips (severity), slide-over finding detail, one-click patch apply with toast notifications, rule suppression, light/dark theme. Genuinely wired to the live backend (`frontend/src/api/client.ts`), not mock data. A separate public Landing page (zero backend calls) is what actually deploys to GitHub Pages; the live Dashboard only lives behind `/kshield-dashboard/*`. +- **VS Code Extension** — inline diagnostics on save (debounced), hover explanations, Quick Fix actions (apply patch / suppress rule), status-bar backend health. +- **Tauri desktop wrapper** — packages the dashboard as a native window. Packaging itself is not finished (see Known Limitations). +- **GitHub Actions PR scanning** (`ci.yml`, `pr-scan` job) — posts inline PR review comments for changed files. Currently calls the single-file `/api/v1/scan` endpoint per file, not the graph-aware `/api/v1/audit` — see Known Limitations. + +--- + +## Install paths + +`curl | bash`, `brew install ytt-global/tap/kshield`, `npx @ytt-global/kshield init`, `pip install kshield` — all four resolve to the same Rust binary and the same experience. `SQLITE_FALLBACK=true` runs entirely on a local SQLite DB (`~/.kshield/kshield.db`) with no Docker needed; a `docker-compose.yml` is available for a shared PostgreSQL + pgvector deployment. + +--- + +## Known Limitations + +Listed here deliberately, not buried — these are the gaps that matter most for deciding whether to trust a result: + +- **No JS/TS (or any non-Python) access-control detection.** Verified directly against a real production Fastify API: genuine routes with real auth middleware produced zero findings, not because the code is safe, but because the engine can't see it. Secrets and dependency-hallucination checks are language-agnostic and do work on any language; broken-access-control detection does not. A clean `kshield agent` result on a non-Python codebase does not mean "no access-control issues" today. +- **No cross-repo/cross-service auth awareness.** Guard-reuse (both in detection and in `ksword`'s remediation) is scoped to what's visible in the single file or single repo being scanned — a shared auth dependency imported from a separate internal library won't be recognized unless its name happens to match the auth-keyword list. +- **Similarity search is not real yet.** The `vulnerabilities.embedding` pgvector column is populated with an MD5-seeded random vector, not a real embedding — any cosine-similarity feature built on it today would be comparing structured noise, not semantic meaning. +- **PR-scan CI doesn't use the stronger engine.** `ci.yml`'s `pr-scan` job calls per-file `/api/v1/scan`, missing the cross-file guard-reuse and sensitive-path escalation that `/api/v1/audit` (and therefore `kshield agent`) has. +- **No Windows support.** No Rust unit tests either — CLI correctness currently relies entirely on manual/live verification, not an automated regression suite for `cli/`. +- **Tauri desktop packaging is unfinished.** diff --git a/docs/setup.md b/docs/setup.md index 42f8307..9f0f12d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -47,9 +47,9 @@ brew install ytt-global/tap/kshield ### npx (Node.js) ```bash -npx kshield init +npx @ytt-global/kshield init ``` -Downloads the platform binary on first run via the `kshield` npm package. +Downloads the platform binary on first run via the `@ytt-global/kshield` npm package. ### pip (Python) ```bash @@ -115,11 +115,47 @@ kshield stop # Stop the background backend kshield status # Check backend health and hook status kshield scan # Manually scan a single file kshield hook # Run pre-commit scan (called by git hook) +kshield agent # Repo-wide audit — every tracked file, in one pass kshield --version # Print CLI version ``` --- +## Repo-Wide Audit (`kshield agent`) + +`hook`/`scan` only see one file or your staged diff. `agent` reads every git-tracked file in the current repo and sends them all to `POST /api/v1/audit` in one request, so access-control checks can see the whole codebase — which routes exist, which guards are genuinely used elsewhere — instead of judging one file in isolation. + +```bash +cd your-repo +kshield agent your-repo-name +``` + +Exits `1` if any active CRITICAL or HIGH finding remains, same as `hook`. Each run is persisted (file/finding/severity counts) and can be listed later: + +```bash +curl http://127.0.0.1:8000/api/v1/audit/runs +``` + +Dismiss a finding as a false positive and similar future findings (matched by normalized description, not exact text) are auto-suppressed on later runs: + +```bash +curl -X POST http://127.0.0.1:8000/api/v1/audit/dismiss-finding \ + -H "Content-Type: application/json" \ + -d '{"description": "...", "anomaly_type": "Broken Access Control", "justification": "internal admin tool, not internet-facing"}' +``` + +### Auditing a whole GitHub org + +`org-audit.sh` at the repo root clones every non-archived repo in an org and runs `kshield agent ` against each one: + +```bash +./org-audit.sh your-github-org +``` + +Requires the `gh` CLI to be authenticated. Per-repo logs land in `~/audits//results/`; the final summary is read back from the local `audit_runs` table, ranked by CRITICAL/HIGH count. + +--- + ## Backend Management ### Start diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 621710d..37bba5f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { ScanResult, GlobalTelemetry } from '../types/scan'; +import type { ScanResult, GlobalTelemetry, SuppressedRule } from '../types/scan'; const BASE = 'http://localhost:8000/api/v1'; @@ -18,9 +18,17 @@ async function post(path: string, body: unknown): Promise { return res.json() as Promise; } +async function del(path: string): Promise { + const res = await fetch(`${BASE}${path}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(`KShield API ${path} → ${res.status}`); + return res.json() as Promise; +} + export const api = { - scans: (): Promise => get('/scans'), - telemetry: (): Promise => get('/telemetry'), - suppress: (rule_type: string) => post('/suppress', { rule_type }), - applyPatch: (vulnerability_id: string) => post('/apply-patch', { vulnerability_id }), + scans: (): Promise => get('/scans'), + telemetry: (): Promise => get('/telemetry'), + suppress: (rule_type: string) => post('/suppress', { rule_type }), + suppressedRules: (): Promise => get('/suppress'), + unsuppress: (rule_type: string) => del(`/suppress/${encodeURIComponent(rule_type)}`), + applyPatch: (vulnerability_id: string) => post('/apply-patch', { vulnerability_id }), }; diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index a98a909..362387c 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -1,15 +1,22 @@ -import React, { useState } from 'react'; -import { ShieldCog, Plus, Server, ListChecks, Ban } from 'lucide-react'; - -interface RuleConfig { -// ... (interface and SEVERITY remain unchanged) - - id: string; - label: string; - description: string; - enabled: boolean; - severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'; -} +import React, { useEffect, useState } from 'react'; +import { ShieldCog, Server, ListChecks, Ban } from 'lucide-react'; +import { api } from '../api/client'; +import type { SuppressedRule } from '../types/scan'; + +// The actual detection rules the engine supports — matches anomaly_type +// exactly as returned by the backend. There is no dynamic/custom rule +// system; the previous "Add New Rule" form implied a capability that never +// existed on the backend, so it's gone rather than kept as decoration. +const RULE_TYPES: { type: string; description: string; severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' }[] = [ + { type: 'Hardcoded Secret', description: 'Named token signatures — GitHub, AWS, Stripe, and 25+ more.', severity: 'CRITICAL' }, + { type: 'High Entropy Credential', description: 'Shannon-entropy fallback for secrets with no named pattern.', severity: 'HIGH' }, + { type: 'Broken Access Control', description: 'FastAPI routes with no authentication guard.', severity: 'HIGH' }, + { type: 'AI Structural Hallucination', description: 'Placeholder stubs, hallucinated imports, AI generation artifacts.', severity: 'MEDIUM' }, + { type: 'Dependency Hallucination', description: "Imports that don't exist on PyPI, npm, the Go proxy, or RubyGems.", severity: 'CRITICAL' }, + { type: 'Possible Typosquat', description: 'Undeclared imports very close to a well-known package name.', severity: 'HIGH' }, + { type: 'Undeclared Dependency', description: 'Imports missing from requirements.txt / package.json.', severity: 'LOW' }, + { type: 'Syntax Violation', description: 'Files that fail to parse — often truncated AI-generated code.', severity: 'MEDIUM' }, +]; const SEVERITY: Record = { CRITICAL: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-400 dark:border-red-900', @@ -18,37 +25,46 @@ const SEVERITY: Record = { LOW: 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-400 dark:border-blue-900', }; -const INITIAL_RULES: RuleConfig[] = []; - export const Settings: React.FC = () => { const [apiUrl, setApiUrl] = useState(''); const [ignorePatterns, setIgnorePatterns] = useState(''); - const [rules, setRules] = useState(INITIAL_RULES); const [saved, setSaved] = useState(false); - // New rule form state - const [newRuleLabel, setNewRuleLabel] = useState(''); - const [newRuleDescription, setNewRuleDescription] = useState(''); - const [newRuleSeverity, setNewRuleSeverity] = useState<'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'>('MEDIUM'); - - const toggleRule = (id: string) => - setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r)); - - const handleAddRule = () => { - if (!newRuleLabel.trim()) return; - - const rule: RuleConfig = { - id: crypto.randomUUID(), - label: newRuleLabel.trim(), - description: newRuleDescription.trim() || 'User-defined detection rule.', - enabled: true, - severity: newRuleSeverity, - }; - - setRules(prev => [...prev, rule]); - setNewRuleLabel(''); - setNewRuleDescription(''); - setNewRuleSeverity('MEDIUM'); + const [suppressedRules, setSuppressedRules] = useState([]); + const [suppressedLoading, setSuppressedLoading] = useState(true); + const [suppressedError, setSuppressedError] = useState(false); + const [togglingType, setTogglingType] = useState(null); + + const loadSuppressedRules = () => { + setSuppressedLoading(true); + setSuppressedError(false); + api.suppressedRules() + .then(setSuppressedRules) + .catch(() => setSuppressedError(true)) + .finally(() => setSuppressedLoading(false)); + }; + + useEffect(() => { + loadSuppressedRules(); + }, []); + + const isRuleEnabled = (type: string) => !suppressedRules.some(r => r.rule_type === type); + + const handleToggleRule = async (type: string) => { + setTogglingType(type); + try { + if (isRuleEnabled(type)) { + await api.suppress(type); + setSuppressedRules(prev => [...prev, { rule_type: type, justification: 'Suppressed via dashboard', created_at: new Date().toISOString() }]); + } else { + await api.unsuppress(type); + setSuppressedRules(prev => prev.filter(r => r.rule_type !== type)); + } + } catch { + setSuppressedError(true); + } finally { + setTogglingType(null); + } }; const handleSave = () => { @@ -56,7 +72,7 @@ export const Settings: React.FC = () => { setTimeout(() => setSaved(false), 2500); }; - const activeCount = rules.filter(r => r.enabled).length; + const activeCount = RULE_TYPES.length - suppressedRules.length; return (
@@ -102,96 +118,70 @@ export const Settings: React.FC = () => {
- {/* Detection rules */} + {/* Detection rules — real, API-backed. A rule toggled off here calls the + same global suppress mechanism as the dashboard's "Suppress Rule" + button (POST/DELETE /api/v1/suppress), so state matches everywhere. */}

Detection Rules

-

Toggle which vulnerability types the engine flags on each scan.

+

Toggle which vulnerability types the engine flags — applies globally, on every future scan.

- - {activeCount}/{rules.length} active - + {!suppressedLoading && !suppressedError && ( + + {activeCount}/{RULE_TYPES.length} active + + )}
- {/* Add New Rule Form */} -
-
-
- - setNewRuleLabel(e.target.value)} - placeholder="e.g. Custom Secret" - className="w-full rounded-lg px-3 py-2 text-sm bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-red-500/20" - /> -
-
- - setNewRuleDescription(e.target.value)} - placeholder="What this rule detects..." - className="w-full rounded-lg px-3 py-2 text-sm bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 focus:outline-none focus:ring-2 focus:ring-red-500/20" - /> -
-
-
- - -
- -
+ {suppressedLoading ? ( +

Loading…

+ ) : suppressedError ? ( +
+

Could not reach the backend to load rule state.

+
-
- -
- {rules.map(rule => ( -
- -
-

- {rule.label} -

-

{rule.description}

-
- - {/* Show abbreviated severity on mobile */} - {rule.severity[0]} - {rule.severity} - -
- ))} -
+ ) : ( +
+ {RULE_TYPES.map(rule => { + const enabled = isRuleEnabled(rule.type); + return ( +
+ +
+

+ {rule.type} +

+

{rule.description}

+
+ + {rule.severity[0]} + {rule.severity} + +
+ ); + })} +
+ )}
{/* Ignore patterns */} diff --git a/frontend/src/types/scan.ts b/frontend/src/types/scan.ts index b2397aa..c94582a 100644 --- a/frontend/src/types/scan.ts +++ b/frontend/src/types/scan.ts @@ -20,6 +20,12 @@ export interface ScanResult { anomalies: Anomaly[]; } +export interface SuppressedRule { + rule_type: string; + justification: string; + created_at: string; +} + export interface GlobalTelemetry { totalScans: number; cleanFiles: number; diff --git a/install.sh b/install.sh index 6d6f049..1a0d513 100755 --- a/install.sh +++ b/install.sh @@ -30,6 +30,9 @@ ARCH="$(uname -m)" case "$OS" in Darwin) OS_LABEL="apple-darwin" ;; Linux) OS_LABEL="unknown-linux-gnu" ;; + MINGW*|MSYS*|CYGWIN*) + error "Windows detected via $OS. This script targets macOS/Linux tar.gz archives — use one of these instead:\n\n npx @ytt-global/kshield init\n\n or download kshield-x86_64-pc-windows-msvc.zip directly:\n https://github.com/$REPO/releases" + ;; *) error "Unsupported OS: $OS. Build from source: https://github.com/$REPO" ;; esac diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 0000000..73c15d2 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,78 @@ +# kshield MCP server + +Exposes the local kshield backend as MCP tools, so any MCP-aware agent +(Claude Code, Claude Desktop) can run kshield's checks directly — scan a +file, run a repo-wide audit, apply a remediation patch — without shelling +out to the CLI. + +This is a thin client, same as the Rust CLI: it talks to the same local +FastAPI backend over HTTP (`KSHIELD_BACKEND`, default +`http://127.0.0.1:8000`). It does no scanning of its own. **Start the +backend first** — `kshield start`, or `uvicorn app.main:app` from +`backend/`. + +## Tools + +| Tool | What it does | +|---|---| +| `kshield_health` | Check the backend is reachable — call this first | +| `kshield_scan_file(path)` | Scan one file on disk | +| `kshield_audit_repo(repo_path)` | Repo-wide audit — every git-tracked file, graph-aware cross-file checks | +| `kshield_list_scan_history(limit)` | Recent single-file scans | +| `kshield_list_audit_runs` | Past repo-wide audits, most-critical-first | +| `kshield_dismiss_finding(description, anomaly_type, justification)` | Mark a finding as a false positive | +| `kshield_apply_patch(vulnerability_id)` | **Writes to disk** — applies the auto-generated patch via `git apply` | +| `kshield_list_suppressed_rules` | List globally suppressed rule types | +| `kshield_suppress_rule(rule_type, justification)` | Globally suppress a rule type | +| `kshield_unsuppress_rule(rule_type)` | Remove a suppression | + +Not yet exposed: the `/patterns/auth-keywords` pattern-archive endpoints — +natural next addition, left out of this first pass to keep the tool surface +focused. + +## Running it standalone (smoke test) + +`server.py` declares its own deps via PEP 723 inline metadata, so `uv run +server.py` *should* just work — but if you're running it from inside the +kshield repo, uv's project auto-detection picks up kshield's own +`pyproject.toml` instead of the script's inline block and the import fails +with `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`. Verified +fix: pass `--no-project` and spell the deps out explicitly: + +```bash +uv run --no-project --with "mcp[cli]>=1.2.0" --with "httpx>=0.27" \ + python3 mcp-server/server.py +``` + +Plain `pip`/`venv` works too if you'd rather not deal with the uv quirk +above: + +```bash +cd mcp-server +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +python3 server.py +``` + +Either way it just sits there waiting on stdio — that's correct, it's meant +to be launched by an MCP client, not run interactively. + +## Registering with Claude Code + +```bash +claude mcp add kshield -- uv run --no-project \ + --with "mcp[cli]>=1.2.0" --with "httpx>=0.27" \ + python3 /Users/YTTGlobalServices/100-percent-code/kshield/mcp-server/server.py +``` + +(Swap the `uv run ...` command for the venv's `python3 server.py` if you set +up via pip instead.) + +## Verified + +`kshield_health` and `kshield_scan_file` were tested end-to-end against a +live local backend (2026-08-04) — correctly detected a hardcoded AWS key in +a test file and returned a working auto-generated remediation patch. The +remaining tools are thin, direct passthroughs to already-tested backend +endpoints (see `backend/app/api/v1/`) but weren't each individually +exercised through this MCP layer yet. diff --git a/mcp-server/requirements.txt b/mcp-server/requirements.txt new file mode 100644 index 0000000..efed47c --- /dev/null +++ b/mcp-server/requirements.txt @@ -0,0 +1,2 @@ +mcp[cli]>=1.2.0 +httpx>=0.27 diff --git a/mcp-server/server.py b/mcp-server/server.py new file mode 100644 index 0000000..6567b7b --- /dev/null +++ b/mcp-server/server.py @@ -0,0 +1,211 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "mcp[cli]>=1.2.0", +# "httpx>=0.27", +# ] +# /// +"""kshield MCP server. + +Exposes the local kshield backend's scan/audit engine as MCP tools, so any +MCP-aware agent (Claude Code, Claude Desktop, etc.) can run kshield's checks +directly instead of shelling out to the CLI. + +Talks to the same local FastAPI backend the Rust CLI and desktop app use — +same KSHIELD_BACKEND env var, same default http://127.0.0.1:8000. This +server does no scanning itself; it's a thin MCP-shaped client, matching +kshield's existing "one local backend, many local clients" architecture. + +Requires the backend running first: `kshield start` (or, from backend/, +`uvicorn app.main:app`). +""" + +import os +import subprocess +from pathlib import Path + +import httpx +from mcp.server.fastmcp import FastMCP + +BACKEND_URL = os.environ.get("KSHIELD_BACKEND", "http://127.0.0.1:8000") + +mcp = FastMCP("kshield") + + +def _client() -> httpx.AsyncClient: + return httpx.AsyncClient(base_url=BACKEND_URL, timeout=60.0) + + +def _git_tracked_files(repo_path: str) -> list[str]: + """Mirrors the CLI's `Agent` command: every git-tracked file in the repo.""" + result = subprocess.run( + ["git", "-C", repo_path, "ls-files"], + capture_output=True, + text=True, + check=True, + ) + return [line for line in result.stdout.splitlines() if line] + + +@mcp.tool() +async def kshield_health() -> dict: + """Check whether the local kshield backend is reachable. Call this first — + if it reports unreachable, tell the user to run `kshield start` before + anything else.""" + async with _client() as client: + try: + resp = await client.get("/health", timeout=5.0) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError as e: + return {"status": "unreachable", "backend_url": BACKEND_URL, "error": str(e)} + + +@mcp.tool() +async def kshield_scan_file(path: str, commit_sha: str | None = None) -> dict: + """Scan a single file on disk for secrets, broken access control, and + AI-hallucinated dependencies/APIs. `path` is a filesystem path readable + by this server. Returns findings with severity and an auto-generated + remediation patch per finding, where one exists.""" + file_path = Path(path).expanduser().resolve() + if not file_path.is_file(): + return {"error": f"No such file: {file_path}"} + content = file_path.read_text(errors="replace") + async with _client() as client: + resp = await client.post( + "/api/v1/scan", + json={ + "filename": str(file_path), + "content": content, + "commit_sha": commit_sha, + }, + ) + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_audit_repo(repo_path: str, name: str | None = None) -> dict: + """Repo-wide audit: scans every git-tracked file in `repo_path` at once, + with graph-aware cross-file access-control checks (not just single-file + rules). This is kshield's deepest check — use it before a release or when + reviewing a large diff, not for a quick single-file check (use + kshield_scan_file for that). Can take a while on large repos.""" + repo = Path(repo_path).expanduser().resolve() + if not repo.is_dir(): + return {"error": f"No such directory: {repo}"} + try: + tracked = _git_tracked_files(str(repo)) + except subprocess.CalledProcessError as e: + return {"error": f"Not a git repository, or git ls-files failed: {e}"} + + files = [] + for rel_path in tracked: + full = repo / rel_path + if not full.is_file(): + continue + try: + content = full.read_text(errors="replace") + except (UnicodeDecodeError, OSError): + continue + files.append({"filename": rel_path, "content": content}) + + async with _client() as client: + resp = await client.post( + "/api/v1/audit", + json={"name": name or repo.name, "files": files}, + ) + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_list_audit_runs() -> dict: + """List every past repo-wide audit run, most-critical-first.""" + async with _client() as client: + resp = await client.get("/api/v1/audit/runs") + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_list_scan_history(limit: int = 50) -> dict: + """List recent single-file scans and their findings.""" + async with _client() as client: + resp = await client.get("/api/v1/scans", params={"limit": limit}) + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_dismiss_finding( + description: str, anomaly_type: str, justification: str = "" +) -> dict: + """Mark a specific finding as a false positive. Future findings with a + closely matching description and the same anomaly_type get auto-quieted + instead of re-flagged. Use this when a flagged pattern is confirmed safe + — not to silence something that hasn't actually been checked.""" + async with _client() as client: + resp = await client.post( + "/api/v1/audit/dismiss-finding", + json={ + "description": description, + "anomaly_type": anomaly_type, + "justification": justification, + }, + ) + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_apply_patch(vulnerability_id: str) -> dict: + """Apply the auto-generated remediation patch for a specific finding + directly to the file on disk via `git apply`. This WRITES to the + filesystem — only call it after showing the user the finding and patch, + or when they've explicitly asked to auto-fix. Returns patch_only if the + file can't be found or the patch can't be applied cleanly; surface the + raw diff in that case instead of failing silently.""" + async with _client() as client: + resp = await client.post( + "/api/v1/apply-patch", json={"vulnerability_id": vulnerability_id} + ) + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_list_suppressed_rules() -> dict: + """List every globally suppressed rule type, with justification.""" + async with _client() as client: + resp = await client.get("/api/v1/suppress") + resp.raise_for_status() + return {"suppressed": resp.json()} + + +@mcp.tool() +async def kshield_suppress_rule(rule_type: str, justification: str = "") -> dict: + """Globally suppress a rule type across all future scans on this backend + instance. Idempotent. Use sparingly — this silences the rule everywhere, + not just for one file.""" + async with _client() as client: + resp = await client.post( + "/api/v1/suppress", + json={"rule_type": rule_type, "justification": justification}, + ) + resp.raise_for_status() + return resp.json() + + +@mcp.tool() +async def kshield_unsuppress_rule(rule_type: str) -> dict: + """Remove a global rule suppression, re-enabling that check for future + scans.""" + async with _client() as client: + resp = await client.delete(f"/api/v1/suppress/{rule_type}") + resp.raise_for_status() + return resp.json() + + +if __name__ == "__main__": + mcp.run() diff --git a/npm/bin/kshield.js b/npm/bin/kshield.js index 36015d5..2357b21 100644 --- a/npm/bin/kshield.js +++ b/npm/bin/kshield.js @@ -2,15 +2,16 @@ "use strict"; const path = require("path"); +const os = require("os"); const { spawnSync } = require("child_process"); -const binary = path.join(__dirname, "kshield-bin"); +const binary = path.join(__dirname, os.platform() === "win32" ? "kshield-bin.exe" : "kshield-bin"); const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }); if (result.error) { if (result.error.code === "ENOENT") { - console.error("[kshield] Binary not found. Re-run: npm install -g kshield"); + console.error("[kshield] Binary not found. Re-run: npm install -g @ytt-global/kshield"); } else { console.error("[kshield] Error:", result.error.message); } diff --git a/npm/package.json b/npm/package.json index b77b5e4..34a2579 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,5 +1,8 @@ { - "name": "kshield", + "name": "@ytt-global/kshield", + "publishConfig": { + "access": "public" + }, "version": "1.1.0", "description": "Local-first AI code review firewall — catches secrets, broken access control, and AI hallucinations before they reach your main branch.", "keywords": ["security", "code-review", "pre-commit", "git-hooks", "secrets-detection"], diff --git a/npm/scripts/install.js b/npm/scripts/install.js index 0b75076..2e3a1ae 100644 --- a/npm/scripts/install.js +++ b/npm/scripts/install.js @@ -18,12 +18,17 @@ const BINARY_DIR = path.join(__dirname, "..", "bin"); function platform() { const arch = os.arch() === "arm64" ? "aarch64" : "x86_64"; switch (os.platform()) { - case "darwin": return `${arch}-apple-darwin`; - case "linux": return `${arch}-unknown-linux-gnu`; + case "darwin": return `${arch}-apple-darwin`; + case "linux": return `${arch}-unknown-linux-gnu`; + case "win32": return arch === "x86_64" ? "x86_64-pc-windows-msvc" : null; default: return null; } } +function isWindows() { + return os.platform() === "win32"; +} + function download(url, dest) { return new Promise((resolve, reject) => { const file = fs.createWriteStream(dest); @@ -51,26 +56,32 @@ async function main() { return; } - const tarball = `kshield-${plat}.tar.gz`; - const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${tarball}`; - const tmp = path.join(os.tmpdir(), tarball); - const binaryDest = path.join(BINARY_DIR, "kshield-bin"); + const archiveExt = isWindows() ? "zip" : "tar.gz"; + const archive = `kshield-${plat}.${archiveExt}`; + const url = `https://github.com/${REPO}/releases/download/v${VERSION}/${archive}`; + const tmp = path.join(os.tmpdir(), archive); + const binaryDest = path.join(BINARY_DIR, isWindows() ? "kshield-bin.exe" : "kshield-bin"); console.log(`[kshield] Downloading binary for ${plat}...`); try { await download(url, tmp); - execSync(`tar -xzf "${tmp}" -C "${BINARY_DIR}"`, { stdio: "ignore" }); - const extracted = path.join(BINARY_DIR, "kshield"); + if (isWindows()) { + // Windows ships tar.exe too (since Win10 1803+), which also unzips .zip archives. + execSync(`tar -xf "${tmp}" -C "${BINARY_DIR}"`, { stdio: "ignore" }); + } else { + execSync(`tar -xzf "${tmp}" -C "${BINARY_DIR}"`, { stdio: "ignore" }); + } + const extracted = path.join(BINARY_DIR, isWindows() ? "kshield.exe" : "kshield"); if (fs.existsSync(extracted)) { fs.renameSync(extracted, binaryDest); - fs.chmodSync(binaryDest, 0o755); + if (!isWindows()) fs.chmodSync(binaryDest, 0o755); } fs.unlinkSync(tmp); console.log("[kshield] Installed successfully."); } catch (err) { console.warn("[kshield] Could not download binary:", err.message); - console.warn("[kshield] Run: npx kshield --help (will try again)"); + console.warn("[kshield] Run: npx @ytt-global/kshield --help (will try again)"); } } diff --git a/org-audit.sh b/org-audit.sh new file mode 100755 index 0000000..54f1665 --- /dev/null +++ b/org-audit.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Org-wide KShield audit: clones every repo in an org and runs `kshield agent ` +# against each one. Per-run summaries land in the local audit_runs table, so the +# final report is read from there rather than parsed out of terminal output. +set -uo pipefail + +ORG="${1:?Usage: ./org-audit.sh }" +WORKDIR="$HOME/audits/$(date +%F)" +RESULTS="$WORKDIR/results" +mkdir -p "$RESULTS" +cd "$WORKDIR" + +kshield start >/dev/null 2>&1 || true + +# 1. Inventory non-archived repos +gh repo list "$ORG" --limit 1000 --json name,url,isArchived \ + | jq -r '.[] | select(.isArchived==false) | .url' > repo_urls.txt + +echo "Found $(wc -l < repo_urls.txt) repos to audit." + +# 2. Clone them in parallel (skip ones already cloned from a prior run) +# -a reads the file directly, but that's GNU-only — BSD xargs (macOS default) +# doesn't support it, so feed the list via stdin instead for portability. +xargs -P 8 -I{} sh -c ' + name=$(basename "{}" .git) + [ -d "$name" ] || git clone --depth 1 --quiet "{}" "$name" +' < repo_urls.txt + +# 3. Run kshield agent per repo +run_names=() +for repo in */; do + name="${repo%/}" + [ -d "$repo/.git" ] || continue + ( + cd "$repo" + kshield init >/dev/null 2>&1 || true + kshield agent "$name" > "$RESULTS/${name}.log" 2>&1 + ) + status=$? + run_names+=("$name") + if [ "$status" -gt 1 ]; then + echo "FAILED to audit: $name (exit $status) — see $RESULTS/${name}.log" + else + echo "audited: $name" + fi +done + +# 4. Pull the summary for exactly these run names from audit_runs +python3 - "${run_names[@]}" <<'PY' +import sqlite3, sys +from pathlib import Path + +names = sys.argv[1:] +db_path = Path.home() / ".kshield" / "kshield.db" +conn = sqlite3.connect(db_path) +placeholders = ",".join("?" * len(names)) +rows = conn.execute( + f""" + SELECT name, file_count, findings_count, critical_count, high_count + FROM audit_runs + WHERE name IN ({placeholders}) + GROUP BY name + HAVING created_at = MAX(created_at) + """, + names, +).fetchall() + +print() +print("── Audit summary ──────────────────────────────") +print(f"{'REPO':<30}{'FILES':<8}{'FINDINGS':<10}{'CRITICAL':<10}{'HIGH':<6}") +flagged = 0 +for name, files, findings, critical, high in sorted(rows, key=lambda r: -(r[3] + r[4])): + if critical or high: + flagged += 1 + print(f"{name:<30}{files:<8}{findings:<10}{critical:<10}{high:<6}") +print("────────────────────────────────────────────────") +print(f"{flagged} of {len(rows)} repo(s) have CRITICAL or HIGH findings.") +PY diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7a26fa8 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1043 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/16/71eefcf68267bbf06a9b6bff57d0b222e49432326e85d74348b67694b8d4/greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb", size = 294266, upload-time = "2026-07-22T11:37:56.142Z" }, + { url = "https://files.pythonhosted.org/packages/36/ea/a0b19adfc35d07e10acb626e9d22a3893b95f1309c42c4a20161dec16800/greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686", size = 613712, upload-time = "2026-07-22T12:26:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a121978b3337407d05a1ce5f79b4aa5998a43a9d8422f9726029b90b4471/greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7", size = 625582, upload-time = "2026-07-22T12:29:00.814Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4a/f301f1d85c69a86b90b5d581a73e8927bba4e79450037e6e2cbca05eb4fd/greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7", size = 633429, upload-time = "2026-07-22T12:43:42.073Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/080f16cf870e929e592f55767f01d6c98d2ee83bfdc36c3b892f2d0459ab/greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071", size = 624663, upload-time = "2026-07-22T11:51:08.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/26884072b0eb343a4d5fee903341bfe5171b32b7f14553886e2b6349135a/greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937", size = 428238, upload-time = "2026-07-22T12:39:49.973Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/8f3ca88370b817369008faeceeee85970adc16c92a70a3e5fe5fea495a57/greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72", size = 1585010, upload-time = "2026-07-22T12:25:02.539Z" }, + { url = "https://files.pythonhosted.org/packages/51/c2/45877154689709ebce9a0b83c2235e6ca0f31577889b02af308c8cc5f8fb/greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59", size = 1651283, upload-time = "2026-07-22T11:51:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/8711a75cb61d85246277c07ff6e1a6504621ba473d808c11ad225ffca43f/greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6", size = 246434, upload-time = "2026-07-22T11:43:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/00/62/e290b3bce433da8f0324ac02da0b128d683482229f1a8b789fa47818a4cd/greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da", size = 244990, upload-time = "2026-07-22T11:39:22.626Z" }, + { url = "https://files.pythonhosted.org/packages/f3/04/81bd731d6d1e3a469d9a4c36f5eb069bcf0cbb2d5d342c9fec22245b91fc/greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4", size = 295909, upload-time = "2026-07-22T11:38:09.261Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/f5f22903a6ae70f5ea328ed0beaec92ad903f0e3b7d2845133b354abc4b8/greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17", size = 612011, upload-time = "2026-07-22T12:26:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/8e/10/92a4a88d12b915d74ea5b6d288e4afefda4771647caa34442c156f7a454f/greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a", size = 624299, upload-time = "2026-07-22T12:29:02.089Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f9/03e26be3487c5238e81f2b84714959a86ea8515a869828cf41f4fc54b34e/greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf", size = 629603, upload-time = "2026-07-22T12:43:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/50/6d/0b14bb9db2989f32cd9fe7f76afedea01ee8bee3f87c07e69f24adfe7e63/greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f", size = 621541, upload-time = "2026-07-22T11:51:09.464Z" }, + { url = "https://files.pythonhosted.org/packages/57/6b/7c55ca72ef80d57c16c4a55210f82582622462dc4485799a30f4ec6f3372/greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f", size = 432554, upload-time = "2026-07-22T12:39:51.379Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/25e9a2d9eb6b2e8b7ca4e80a3a26cb887cce6c8e0a87c921164f11bc5574/greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d", size = 1581444, upload-time = "2026-07-22T12:25:03.818Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/4c9bf2e2c408dcc0556edce69efa9f802e82223573c53240136a086821f1/greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9", size = 1645842, upload-time = "2026-07-22T11:51:12.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/41/303ecb26a3a56122c0f4d4073ee078881847bd6b6f463ae0ec57ec20223b/greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3", size = 247169, upload-time = "2026-07-22T11:38:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e3/ef56864b4c35fcb3eb3b41b869f6cc46f4cd3f5e2c68e74acde8ac433951/greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0", size = 245565, upload-time = "2026-07-22T11:38:27.061Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/6ab1d4f9cd548d15ab90da29947f2076100130bb179b0bde59f795a459e3/greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22", size = 295410, upload-time = "2026-07-22T11:40:35.747Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7a/422f63b4715cbc0b24385305407adf38b48f6bb68b3e6b04090e994d0f5a/greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf", size = 661286, upload-time = "2026-07-22T12:26:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/5a1cac663bf5582190c5a714ef81364f03cde232227f39748f8ae4c11da5/greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9", size = 673517, upload-time = "2026-07-22T12:29:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/9c/bf/250c2921c7b585dde12f5239e313ca2dcbc464d161ecca36e4e6ef21762d/greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c", size = 677968, upload-time = "2026-07-22T12:43:46.788Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/2a82a1e3f8aaca020853ac8d12211280ca2b231aa08ea39f636f1060c319/greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8", size = 670917, upload-time = "2026-07-22T11:51:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/18/40/10bfcf6513558d82f7b95dd728001c63bd388259fe27d3e30ae01f103430/greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c", size = 480643, upload-time = "2026-07-22T12:39:54.149Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/e379a152b17bfdfa95795af4049e37c0fd1b4d81f020d426db104ed07c77/greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3", size = 1628478, upload-time = "2026-07-22T12:25:06.678Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/bffdfa64f7317f954c5c1230b5dd5922676ce198689a68c1ac1ed4b1b1a5/greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec", size = 1692021, upload-time = "2026-07-22T11:51:17.008Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/f799f9637e2c6e9b0b716015e339040598b058cf7654dfc0d67468b177ed/greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c", size = 248031, upload-time = "2026-07-22T11:40:11.007Z" }, + { url = "https://files.pythonhosted.org/packages/05/75/625bcdd74d5e6b2dca1ecba3c3ac77bcf8a026c21a649a46cef23e421f97/greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f", size = 246892, upload-time = "2026-07-22T11:40:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/ec/69/35c62ed49c320cb4d98e14698ccca5467d3bfe683984172be9cb564d9ce3/greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3", size = 305571, upload-time = "2026-07-22T11:40:31.659Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/64d60216b3640dcb0b62d913dd9e0d80030c09115bb2e4ba70c95d10ca45/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867", size = 672568, upload-time = "2026-07-22T12:26:45.298Z" }, + { url = "https://files.pythonhosted.org/packages/5c/de/ba3ab0a96292e53039530333b0d2ae18d9e508f3a325cd7bf15f8172944c/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c", size = 680076, upload-time = "2026-07-22T12:29:06.125Z" }, + { url = "https://files.pythonhosted.org/packages/ae/db/24a10af12bf8e639cec46c38b9ce1a282543ba42ff4fb0b31a970f1ab603/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8", size = 681690, upload-time = "2026-07-22T12:43:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/aeada79083c6f1c15f45d77a332f9c441af263ee298e3eb17522cd337d22/greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66", size = 676733, upload-time = "2026-07-22T11:51:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/f4/60/44a2eca7b9fd71ae0fae7ff184da1cd3169d176652b97aa1cffcbb0ef961/greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd", size = 510263, upload-time = "2026-07-22T12:39:55.678Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/2392fc3a98948652ef5fd1e7275c04f861dd13f74b78a2b4309f4ee4d090/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7", size = 1637327, upload-time = "2026-07-22T12:25:07.879Z" }, + { url = "https://files.pythonhosted.org/packages/55/c6/e7237a3dfa1f205ed0d9ea1e46d70bd2811b32d516266399fb59d28ab90a/greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e", size = 1697493, upload-time = "2026-07-22T11:51:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/e3/4ba8154ba2a3d43729e499f72471b4b5c993f3826d3e24da81d5f06d6572/greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132", size = 251637, upload-time = "2026-07-22T11:40:37.44Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/e3f96dfc100261a29545ddc8270cafe58f9195b6651466910e820910de77/greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809", size = 296076, upload-time = "2026-07-22T11:39:38.364Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3d/da52d208e5c977bce8667e784729e584e38b5785f4c1ec0f4c836e9a1c42/greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927", size = 666870, upload-time = "2026-07-22T12:26:46.691Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/7e6dee25cb8a8cf9b362c8e597cc269593378bd916f16c736c059a52e85a/greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5", size = 677678, upload-time = "2026-07-22T12:29:07.508Z" }, + { url = "https://files.pythonhosted.org/packages/51/a7/dafc7415d430b0a43a16396eb49ecb3b62fd720877fb259cc4dcfaf5f31e/greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3", size = 681428, upload-time = "2026-07-22T12:43:49.623Z" }, + { url = "https://files.pythonhosted.org/packages/6c/21/5a38699fa45de749e3857d93b8f07e4c20489e77c2d35d915a2e1c456606/greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0", size = 676067, upload-time = "2026-07-22T11:51:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d9/6298f3432de301d4718766cf934bd73c418c73f81fbb77247319364b0d96/greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb", size = 487446, upload-time = "2026-07-22T12:39:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ba/863116ab8ff1ca7a729e327800268939d182db47aa433db70e216e7d9194/greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88", size = 1633489, upload-time = "2026-07-22T12:25:09.605Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7466ced82818d6132462d7f26b3f83c66ea15d2b193a6c0088d558ed7d95/greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c", size = 1696584, upload-time = "2026-07-22T11:51:21.304Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4d/55b638489260065de9ffce606c8b5d04507bef705de4b212a0c3d6a1a0df/greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da", size = 248297, upload-time = "2026-07-22T11:42:04.055Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/9dd4ae635da93d41dc268bc34bd62a9d711ed8b8825c5d22ac910c7d6e6d/greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667", size = 247423, upload-time = "2026-07-22T11:44:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/19/66/7c87ed9cdbf1d49c2c6cd1c7b9dd4d16c33b24235ca03972293a1876b30c/greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c", size = 306487, upload-time = "2026-07-22T11:41:25.118Z" }, + { url = "https://files.pythonhosted.org/packages/5b/05/0a4201e7c0054866eefc05da234f236dd4c950d0fbf9ca0517141f01b269/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9", size = 676479, upload-time = "2026-07-22T12:26:48.129Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/4b6b8c5a3aec70f0649cd89662d120fdd6421e2bdc8e3b15c3ab5ec568d8/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25", size = 684321, upload-time = "2026-07-22T12:29:08.925Z" }, + { url = "https://files.pythonhosted.org/packages/88/15/0b167aeea95285b0e654ddce651922f666c089363c2ec528ca8b9a9ba74f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d", size = 685995, upload-time = "2026-07-22T12:43:50.993Z" }, + { url = "https://files.pythonhosted.org/packages/24/c9/b49c31c9a972eee91e260445770e922244a7efc542697f51a012ec046d0f/greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde", size = 681293, upload-time = "2026-07-22T11:51:20.43Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/c023ec337f32ff505be7db759c80d98f0532bb94d0c6fa13645efe9bee2e/greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05", size = 516928, upload-time = "2026-07-22T12:39:58.359Z" }, + { url = "https://files.pythonhosted.org/packages/95/6f/7f2d4653770500eee667866016d42d7a68e3d3462f80df6b8e3fcd48a0eb/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8", size = 1642474, upload-time = "2026-07-22T12:25:10.819Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d8/8cba31036a4caae448087ba5d150660ab03b4a0f54d9150f6495a3be7262/greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2", size = 1701012, upload-time = "2026-07-22T11:51:23.17Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cd/3f77a4cce3bae631b08eb52f53a82a976669600337e21dfdba811cb50267/greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2", size = 251977, upload-time = "2026-07-22T11:41:38.125Z" }, + { url = "https://files.pythonhosted.org/packages/93/e8/65e8707d00fe2a49bf12f609a9b2b39ba6dd23c2810eacad877c4fc94bfe/greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994", size = 250538, upload-time = "2026-07-22T11:40:17.985Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +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.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" }, + { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "kshield" +version = "1.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "httpx" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pgvector" }, + { name = "pydantic" }, + { name = "python-multipart" }, + { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", specifier = ">=0.20" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "fastapi", specifier = ">=0.111" }, + { name = "greenlet", specifier = ">=3.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "pgvector", specifier = ">=0.3" }, + { name = "pydantic", specifier = ">=2.7" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.29" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "pgvector" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +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/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[package.optional-dependencies] +asyncio = [ + { name = "greenlet" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + +[package.optional-dependencies] +standard = [ + { 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.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/54/b2bce5b754b91b727b852e78af6d7193d4fe985e420dc54e6c2abe161c1c/websockets-17.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf", size = 212573, upload-time = "2026-07-31T11:29:04.803Z" }, + { url = "https://files.pythonhosted.org/packages/78/1b/eaefeb695b217d8c735fc377ab53c6b00bc9ed64a01a3f6f797096ac0ee8/websockets-17.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a", size = 210262, upload-time = "2026-07-31T11:29:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f", size = 210535, upload-time = "2026-07-31T11:29:07.553Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ab/13a856b488dbac7fd3c5473e38098897cda0baa04e3ca3b34ec6c8a32b46/websockets-17.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536", size = 219602, upload-time = "2026-07-31T11:29:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac", size = 219873, upload-time = "2026-07-31T11:29:10.362Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/92e6146d3588d145136c0e0e16d106bbb855bb5047e13ec3fdee39cce770/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1", size = 221108, upload-time = "2026-07-31T11:29:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/357afa2ffd29686536109e7e3cb2a94f2d09e535df4cf3989393dc506a40/websockets-17.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d", size = 224401, upload-time = "2026-07-31T11:29:12.959Z" }, + { url = "https://files.pythonhosted.org/packages/01/27/9efba1e7a8df48e405d017e67513c6b2a8f0b59c8500b820c47cd5f3dea9/websockets-17.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301", size = 221670, upload-time = "2026-07-31T11:29:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/ab27d62103e8a150f3657e043e5fc711ad3e018c8cd8a715093e93d7640c/websockets-17.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5", size = 220442, upload-time = "2026-07-31T11:29:15.417Z" }, + { url = "https://files.pythonhosted.org/packages/6d/04/289e00b8001b622b0c397a6901fcc4aa8f34a6d2ed42be17f2704f2faae1/websockets-17.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0", size = 217760, upload-time = "2026-07-31T11:29:16.772Z" }, + { url = "https://files.pythonhosted.org/packages/2f/70/0fe58cdac988dfc0066786cc07b09dfd72b48b0c01e5de667721192b2e6e/websockets-17.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac", size = 220597, upload-time = "2026-07-31T11:29:18.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/76/d3eaf120710d1a791d1c7a4963f0b50a589df8ba675394fd2a97dfea3746/websockets-17.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56", size = 219187, upload-time = "2026-07-31T11:29:19.468Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/4e9ae886bdb1647537176ca33fca66d79dddb3773d213ac98ddc6bba9ab4/websockets-17.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed", size = 219955, upload-time = "2026-07-31T11:29:20.641Z" }, + { url = "https://files.pythonhosted.org/packages/7c/67/cd7cc6849a86cf8c9979c0c570b6b6ccee75d2872854238d8d54e77be6ec/websockets-17.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb", size = 221002, upload-time = "2026-07-31T11:29:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/5c/4d14eaf7b2f1448d1af24c1641f04eb74c1632a5802952aac4b7e068b8e7/websockets-17.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd", size = 218579, upload-time = "2026-07-31T11:29:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/ff8bc4b8a6ec235ed8985de12fecc59ad2cd68cc8fc79b97deaa42e412ac/websockets-17.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08", size = 219612, upload-time = "2026-07-31T11:29:24.052Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/103d81d655bab3ffd5c7d5d4b08f92c374499decd1d4be6035ce715b385b/websockets-17.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448", size = 219846, upload-time = "2026-07-31T11:29:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/3495161b1827941258545604fdf72e3e053d03f25bb61752228a784c26a1/websockets-17.0.1-cp311-cp311-win32.whl", hash = "sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946", size = 213046, upload-time = "2026-07-31T11:29:27.094Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae", size = 213344, upload-time = "2026-07-31T11:29:28.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/9e/abfdde9cbd57f0b5867a70e3426ac63341e1a21557b273c39bb6d2ccf8b9/websockets-17.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a", size = 213277, upload-time = "2026-07-31T11:29:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/51/77/63b4abd29f15107d856f010de6f35434faa7c49ef89151e051d2807a9c40/websockets-17.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2", size = 210194, upload-time = "2026-07-31T11:31:18.046Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ab/6160542ee644f72b865af13ddfff23740c95595b237e16312262cafdb641/websockets-17.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f", size = 210465, upload-time = "2026-07-31T11:31:19.333Z" }, + { url = "https://files.pythonhosted.org/packages/53/1a/d4437d3cb0691eeac2c6064e21c83a9eeda04f6ef0261abfc0dde708590d/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d", size = 211417, upload-time = "2026-07-31T11:31:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/88/28/2d671e23a20359a1cc142848384659813b49028d46cb0acdc28e81ac59b5/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1", size = 211309, upload-time = "2026-07-31T11:31:21.969Z" }, + { url = "https://files.pythonhosted.org/packages/02/52/b85a676b161991e5c0d884252376435f60510789e7740e3da73489d22a6e/websockets-17.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42", size = 212203, upload-time = "2026-07-31T11:31:23.38Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/91e297e41381d9131f3142a9c1a50389fd96b98125ce9b81526fa4e14b9f/websockets-17.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", size = 213434, upload-time = "2026-07-31T11:31:24.707Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +]