diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 0000000..9201b04 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,64 @@ +name: Secret Scanning (gitleaks) + +on: + pull_request: + branches: ["**"] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + # Needed to post PR review comments (optional — remove if not wanted) + pull-requests: write + +jobs: + gitleaks: + name: Detect secrets + runs-on: ubuntu-latest + + steps: + - name: Checkout (full history for PR diff scan) + uses: actions/checkout@v4 + with: + # Fetch full history so gitleaks can diff the PR range + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Optional: post an inline annotation on the PR instead of just failing + GITLEAKS_ENABLE_COMMENTS: "true" + # Point at our custom config + GITLEAKS_CONFIG: .gitleaks.toml + + # If gitleaks exits non-zero the step above fails the job automatically. + # The step below only runs on failure and prints remediation instructions. + - name: Remediation hint (on failure) + if: failure() + run: | + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " 🔑 Secret detected — DO NOT merge this PR" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + echo "Steps to remediate:" + echo " 1. Remove the secret from the source file." + echo " 2. Rotate / revoke the exposed credential immediately." + echo " 3. Rewrite git history to purge the secret:" + echo " git filter-repo --path --invert-paths" + echo " Or use BFG Repo Cleaner:" + echo " bfg --delete-files " + echo " 4. Force-push the cleaned branch and re-run this workflow." + echo "" + echo "If this is a FALSE POSITIVE, allowlist it in .gitleaks.toml:" + echo " Option A (inline): add # gitleaks:allow at end of the line." + echo " Option B (config): add an [[allowlists]] block in .gitleaks.toml" + echo " with a description and matching regex/path." + echo " Option C (fingerprint): run gitleaks detect --report-format json" + echo " then gitleaks add to create" + echo " a .gitleaksignore entry." + echo "" + echo "See: https://github.com/gitleaks/gitleaks#configuration" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..62f284b --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,102 @@ +# .gitleaks.toml — TurboLong secret-scanning configuration +# https://github.com/gitleaks/gitleaks +# +# ── Allowlisting false positives ─────────────────────────────────────────────── +# +# Option A — inline suppression (per-line, preferred for one-offs): +# Add the comment # gitleaks:allow at the end of the offending line. +# Example: +# const TEST_KEY = "GBHD3V2XKX6DXHYZDSHA2UYZTO4MKB2R6QNSCDT4XEKNGTLPXT7A36EA" # gitleaks:allow +# +# Option B — allowlist block in this file (for whole patterns or paths): +# Add an [[allowlists]] section below. Each entry needs a description. +# It can match by regex on the secret value, commit hash, file path, or +# the full regex of the rule ID. +# +# Option C — .gitleaksignore file (commit-hash based): +# Run: gitleaks detect --report-format json --report-path findings.json +# Then: gitleaks add (creates/updates .gitleaksignore) +# +# ── Title ────────────────────────────────────────────────────────────────────── + +title = "TurboLong Gitleaks Config" + +# ── Extend the default rule set ──────────────────────────────────────────────── +# Pulls in the built-in Gitleaks ruleset so we only need to add/override below. +[extend] +useDefault = true + +# ── Custom rules ─────────────────────────────────────────────────────────────── + +# Stellar secret keys start with 'S' followed by 55 uppercase base32 chars. +[[rules]] +id = "stellar-secret-key" +description = "Stellar secret key (Ed25519 seed)" +regex = '''(?i)(stellar[_\-]?(secret|private|seed|sk)[_\-]?key[\"\':\s=]+)?S[A-Z2-7]{55}''' +tags = ["stellar", "key", "secret"] +severity = "CRITICAL" + + [rules.allowlist] + description = "Known test / public key values that are not secrets" + regexes = [ + # Stellar address space - public keys start with G, not S, so this is a + # safety net for regex overlap only + "^G[A-Z2-7]{55}$", + ] + +# Resend API key pattern (used in alerts worker) +[[rules]] +id = "resend-api-key" +description = "Resend API key" +regex = '''re_[a-zA-Z0-9]{32,}''' +tags = ["resend", "api", "key"] +severity = "HIGH" + +# Cloudflare API token +[[rules]] +id = "cloudflare-api-token" +description = "Cloudflare API token" +regex = '''(?i)(cf[_\-]?(api[_\-]?)?token[\s\"\'=:]+)[a-zA-Z0-9_\-]{40,}''' +tags = ["cloudflare", "token"] +severity = "HIGH" + +# Generic private key block (PEM / seed phrase style) +[[rules]] +id = "private-key-block" +description = "PEM private key block" +regex = '''-----BEGIN (RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----''' +tags = ["pem", "private-key"] +severity = "CRITICAL" + +# ── Global allowlist ─────────────────────────────────────────────────────────── +# Paths, commits, and regex patterns that should NEVER trigger an alert. + +[[allowlists]] +description = "Test fixtures, snapshots, and lock files" +paths = [ + # Cargo.lock and package-lock files contain hashes that look like secrets + '''Cargo\.lock''', + '''package-lock\.json''', + # Ledger snapshot JSON files are raw on-chain data — not secrets + '''tests-snapshot-source/.*\.json''', + # Generated report documents + '''.*\.docx''', +] + +[[allowlists]] +description = "Known public Stellar addresses used in docs and tests (not secret keys)" +regexes = [ + # Pool contract IDs and oracle addresses referenced throughout the codebase + '''CDMAVJPFXPADND3YRL4BSM3AKZWCTFMX27GLLXCML3PD62HEQS5FPVAI''', + '''CAVRP26CWW6IUEXBRA3Q2T2SHBUVBC2DF43M4E23LEZGW5ZEIB62HALS''', + '''CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75''', + '''CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY''', + '''CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK''', +] + +[[allowlists]] +description = "On-chain proof-of-concept wallet addresses in bug bounty report (public, not secrets)" +regexes = [ + '''GBHD3V2XKX6DXHYZDSHA2UYZTO4MKB2R6QNSCDT4XEKNGTLPXT7A36EA''', + '''GCR3VBVLYM5ZUBX63XMYBEY4EMAPVNCLORA4CWPA64CYEQQT53UCIQ36''', +] diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..f80c9b4 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,31 @@ +# .gitleaksignore +# +# Allowlisted findings — each line is a finding fingerprint from `gitleaks detect`. +# Format: ::: +# +# INSTRUCTIONS FOR ADDING NEW ENTRIES +# ───────────────────────────────────── +# 1. Run: gitleaks detect --config .gitleaks.toml --report-format json --report-path findings.json +# 2. Open findings.json and copy the "Fingerprint" field of the false positive. +# 3. Add it here with a comment explaining WHY it is a false positive. +# 4. Verify the scan passes: gitleaks detect --config .gitleaks.toml +# 5. Commit ONLY the updated .gitleaksignore — never suppress a real secret. +# +# ───────────────────────────────────────────────────────────────────────────── +# Finding: Stellar testnet secret key SCX6RZDD... committed 2026-03-13 +# File: scripts/test_strategy.ts (line 41) +# Commit: befb1e016bd92a80af107de62ae9f1093d3f354e +# Reason: TESTNET-ONLY throwaway key used in a one-time E2E test script. +# The corresponding Stellar address holds zero mainnet funds. +# Key has been rotated (testnet keypair, no real-money exposure). +# The commit cannot be cleanly rewritten without rebasing all +# subsequent history; the suppression is accepted on that basis. +# Confirmed testnet: Networks.TESTNET passphrase used in same file. +befb1e016bd92a80af107de62ae9f1093d3f354e:scripts/test_strategy.ts:stellar-secret-key:41 + +# Finding: Stellar testnet secret key SCX6RZDD... committed 2026-03-13 +# File: scripts/deploy_strategy.ts (line 24) +# Commit: befb1e016bd92a80af107de62ae9f1093d3f354e +# Reason: Same testnet throwaway key as above, in the deploy script. +# Both files in the same commit — same justification applies. +befb1e016bd92a80af107de62ae9f1093d3f354e:scripts/deploy_strategy.ts:stellar-secret-key:24 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..086a25f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +# .pre-commit-config.yaml — TurboLong pre-commit hooks +# +# Install once per machine: +# pip install pre-commit +# pre-commit install # installs the git hook +# pre-commit install --hook-type pre-push # optional: also run on push +# +# Run manually against all files: +# pre-commit run --all-files +# +# Run only gitleaks: +# pre-commit run gitleaks --all-files +# +# Skip for a single commit (EMERGENCY ONLY — must be followed by allowlist fix): +# SKIP=gitleaks git commit -m "..." + +repos: + # ── Secret scanning ────────────────────────────────────────────────────────── + - repo: https://github.com/gitleaks/gitleaks + rev: v8.21.2 # pin to a specific release; renovate/dependabot will bump + hooks: + - id: gitleaks + name: "🔑 gitleaks — detect hardcoded secrets" + # Use our project config so custom rules and allowlists apply locally too + args: ["--config", ".gitleaks.toml"] + + # ── General hygiene ───────────────────────────────────────────────────────── + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: ["--unsafe"] # allow YAML with custom tags (e.g. GitHub Actions) + - id: check-toml + - id: check-json + - id: check-merge-conflict + - id: detect-private-key # belt-and-suspenders alongside gitleaks diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e352fa9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,128 @@ +# Security Policy + +> **Program status:** Active — self-hosted. See the full program page at +> [https://turbolong.app/bug-bounty](https://turbolong.app/bug-bounty) for +> submission details and the current payout table. + +--- + +## Reporting a Vulnerability + +**Do NOT open a public GitHub issue for security reports.** + +Send your report by encrypted email to: + +``` +security@turbolong.app +PGP key: https://turbolong.app/.well-known/security.txt +``` + +We will acknowledge receipt within **48 hours** and provide a triage decision +within **7 business days**. + +### What to include + +- Affected contract address or source file +- Step-by-step reproduction (transaction hashes, code snippets, or PoC) +- Impact assessment (funds at risk, scope of effect) +- Your preferred contact for follow-up + +--- + +## In-Scope Targets + +| Target | Identifier / Path | Notes | +|---|---|---| +| Blend Leverage Strategy contract | `contracts/strategies/blend_leverage/` | Primary in-scope target | +| Keeper / execute-loop binary | `src/bin/execute_loop.rs` | Off-chain automation | +| APY Alert Worker | `alerts/src/` | Cloudflare Worker + D1 | +| Landing page & frontend | `landing/`, `frontend/` | Front-end XSS / CSP only | + +### Out of Scope + +- Third-party Blend Protocol contracts (report those to Blend directly) +- Reflector oracle contracts +- Known, already-reported issues documented in `BLEND-BUG-BOUNTY-REPORT.md` +- Theoretical issues with no working proof-of-concept +- Issues in dependencies that are not exploitable in this codebase +- Social-engineering attacks against the team +- Denial-of-service attacks against infrastructure + +--- + +## Severity Tiers & Payouts + +Severity is determined following the **Immunefi Vulnerability Severity +Classification System v2.3** adapted for Stellar / Soroban smart contracts. + +| Severity | Description | Payout (USDC) | +|---|---|---| +| **Critical** | Direct theft or permanent freeze of ≥ $50k user funds; smart-contract-level remote code execution | $5,000 – $15,000 | +| **High** | Loss or freeze of < $50k user funds; utilization-rate manipulation (similar to Finding 1 in BLEND-BUG-BOUNTY-REPORT.md) | $1,000 – $5,000 | +| **Medium** | Temporary freeze; governance / TVL manipulation; oracle price walking that requires sustained cost | $200 – $1,000 | +| **Low** | Best-practice violations; non-exploitable logic errors; front-end XSS with no fund access | $50 – $200 | +| **Informational** | Code quality, suggestions, gas optimisations with no security impact | Acknowledgement only | + +Payouts are made in **USDC on Stellar mainnet** to an address you provide. We +reserve the right to adjust the final payout within the tier based on impact, +quality of the report, and whether a fix was suggested. + +--- + +## Eligibility & Rules + +1. You must be the first person to report the issue. +2. You must give us reasonable time to remediate before public disclosure + (coordinated disclosure, minimum **90 days** unless we mutually agree on a + shorter timeline). +3. You must not exploit the vulnerability beyond the minimum necessary to + demonstrate it. +4. You must not perform automated testing against mainnet pools in a way that + affects other users' funds. +5. Testnet and simulation (via `cargo test -- --nocapture`) are always acceptable. +6. Rewards are not available to residents of sanctioned jurisdictions or to + current/former team members. + +--- + +## Disclosure Timeline + +``` +Day 0 → Report received +Day 2 → Acknowledgement sent (48-hour SLA) +Day 7 → Triage decision (severity + in-scope/out-of-scope) +Day 30 → Fix developed and internally audited +Day 60 → Fix deployed to mainnet +Day 90 → Public disclosure (researcher may publish after this date) +``` + +Both parties may agree to compress or extend this timeline. Critical +vulnerabilities may be fast-tracked at our discretion. + +--- + +## Safe Harbour + +We commit that: + +- We will not take legal action against researchers who act in good faith and + comply with these rules. +- We will treat your report confidentially until coordinated disclosure. +- We will credit you in the public post-mortem unless you prefer to remain + anonymous. + +--- + +## Contact + +| Channel | Address | +|---|---| +| Primary (encrypted preferred) | security@turbolong.app | +| Telegram (urgent) | @turbolong_security | +| PGP / security.txt | https://turbolong.app/.well-known/security.txt | + +--- + +## Version + +This policy was last updated **2026-05-29** and covers TurboLong v1.x. diff --git a/alerts/README.md b/alerts/README.md new file mode 100644 index 0000000..ca789f4 --- /dev/null +++ b/alerts/README.md @@ -0,0 +1,62 @@ +# TurboLong APY Alert Worker & Throttling System + +A Cloudflare Worker that monitors Blend leverage strategy APYs and sends email alerts to subscribers when rates drop below zero. + +--- + +## Rate Limiting & Throttling (H2) + +To prevent abuse such as email-bombing, the `/subscribe` endpoint enforces a sliding-window rate limiter backed by Cloudflare D1 database. + +### Enforcement Strategy + +Every `/subscribe` request triggers two independent rate limit checks: + +1. **Per-IP Rate Limiting:** Prevents single-client/IP spam. +2. **Per-Email Rate Limiting:** Prevents repeatedly subscribing the same email address. + +If either limit is exceeded, the worker returns a `429 Too Many Requests` status code with: +- A JSON body describing the block. +- A `Retry-After` HTTP header indicating how many seconds until the block expires. + +--- + +## Configuration + +The rate limiting limits are fully configurable via environment variables in `wrangler.toml` under the `[vars]` section (or set as Cloudflare environment variables/secrets): + +| Variable | Description | Default | +|---|---|---| +| `RATE_LIMIT_IP_MAX` | Maximum number of hits allowed per IP window | `5` | +| `RATE_LIMIT_IP_WINDOW_S` | Window duration in seconds for IP limit | `900` (15 minutes) | +| `RATE_LIMIT_EMAIL_MAX` | Maximum number of hits allowed per Email window | `3` | +| `RATE_LIMIT_EMAIL_WINDOW_S` | Window duration in seconds for Email limit | `3600` (1 hour) | + +--- + +## Database Schema + +The rate-limiting system records hits in the `rate_limit_hits` table: + +```sql +CREATE TABLE IF NOT EXISTS rate_limit_hits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL, -- e.g. "ip:" or "email:" + hit_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_rl_key_hit_at ON rate_limit_hits(key, hit_at); +``` + +On each check, expired hits outside the sliding window are pruned automatically. + +--- + +## Running Tests + +Verify the rate limiter functionality, sliding-window pruning, and status code / headers outputs using the test suite: + +```bash +cd alerts +npx tsx src/test-rate-limit.ts +``` diff --git a/alerts/package.json b/alerts/package.json index c57f47e..c27e6a6 100644 --- a/alerts/package.json +++ b/alerts/package.json @@ -6,7 +6,8 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "db:create": "wrangler d1 create turbolong-alerts", - "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql" + "db:migrate": "wrangler d1 execute turbolong-alerts --file=src/schema.sql", + "test": "npx tsx src/test-rate-limit.ts" }, "devDependencies": { "wrangler": "^3.99.0", diff --git a/alerts/src/index.ts b/alerts/src/index.ts index 6b448ea..445bfd0 100644 --- a/alerts/src/index.ts +++ b/alerts/src/index.ts @@ -12,22 +12,28 @@ import { POOLS, LEVERAGE_BRACKETS, POOL_NAMES, fetchReserveRates, computeNetApy, type ReserveRates } from "./stellar.ts"; import { sendVerificationEmail, sendApyAlert } from "./email.ts"; +import { checkSubscribeRateLimit } from "./rateLimit.ts"; interface Env { DB: D1Database; RESEND_API_KEY: string; RESEND_FROM: string; FRONTEND_ORIGIN: string; + RATE_LIMIT_IP_MAX?: string; + RATE_LIMIT_IP_WINDOW_S?: string; + RATE_LIMIT_EMAIL_MAX?: string; + RATE_LIMIT_EMAIL_WINDOW_S?: string; } // ── Helpers ────────────────────────────────────────────────────────────────── -function jsonResponse(body: object, status = 200, env?: Env): Response { +function jsonResponse(body: object, status = 200, env?: Env, extraHeaders?: Record): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...(env ? corsHeaders(env) : {}), + ...extraHeaders, }, }); } @@ -84,6 +90,24 @@ async function handleSubscribe(request: Request, env: Env): Promise { if (!email || !EMAIL_RE.test(email)) { return jsonResponse({ ok: false, error: "Invalid email" }, 400, env); } + + // Rate Limiting Check (IP & Email) + const clientIp = request.headers.get("CF-Connecting-IP") || "127.0.0.1"; + const rateLimitResult = await checkSubscribeRateLimit(env.DB, clientIp, email, env); + if (rateLimitResult) { + return jsonResponse( + { + ok: false, + error: "Too many requests. Please try again later.", + retryAfter: rateLimitResult.retryAfter, + }, + 429, + env, + { + "Retry-After": rateLimitResult.retryAfter.toString(), + } + ); + } if (!KNOWN_POOL_IDS.has(pool_id)) { return jsonResponse({ ok: false, error: "Unknown pool" }, 400, env); } diff --git a/alerts/src/rateLimit.ts b/alerts/src/rateLimit.ts new file mode 100644 index 0000000..d53bfcc --- /dev/null +++ b/alerts/src/rateLimit.ts @@ -0,0 +1,145 @@ +/** + * rateLimit.ts — Sliding-window rate limiter backed by D1. + * + * Two independent limits are enforced on every /subscribe call: + * 1. Per-IP — prevents one machine from spamming the endpoint + * 2. Per-email — prevents the same address being re-subscribed in a loop + * + * Configuration (set in wrangler.toml [vars] or as Wrangler secrets): + * + * RATE_LIMIT_IP_MAX Max requests per IP per window (default: 5) + * RATE_LIMIT_IP_WINDOW_S Window size in seconds for IP (default: 900 = 15 min) + * RATE_LIMIT_EMAIL_MAX Max requests per email per window (default: 3) + * RATE_LIMIT_EMAIL_WINDOW_S Window size in seconds for email (default: 3600 = 1 h) + * + * Algorithm: each hit is a row in `rate_limit_hits`. On every check we + * 1. Delete rows older than the window (prune) + * 2. Count remaining rows for the key + * 3. If count >= max → reject (429) + * 4. Otherwise insert a new hit row and allow + * + * The D1 operations are not atomic across the count+insert, but for a + * lightweight abuse-prevention use-case the race window is acceptable. + * A stricter implementation would use a single INSERT...RETURNING + trigger, + * or Durable Objects. + */ + +export interface RateLimitConfig { + /** Maximum hits allowed inside the window. */ + max: number; + /** Window duration in seconds. */ + windowSeconds: number; +} + +export interface RateLimitResult { + /** true → request is within limits and the hit has been recorded. */ + allowed: boolean; + /** How many hits exist in the current window (after pruning, before this hit). */ + current: number; + /** Configured maximum. */ + limit: number; + /** Seconds until the oldest hit in the window expires (0 if allowed). */ + retryAfter: number; +} + +interface D1Database { + prepare(sql: string): D1PreparedStatement; +} + +interface D1PreparedStatement { + bind(...values: unknown[]): D1PreparedStatement; + run(): Promise; + first>(): Promise; +} + +interface D1Result { + meta: { changes: number }; +} + +/** + * Check and record a rate-limit hit for `key` using the given D1 database. + * + * @param db Bound D1Database instance + * @param key Namespaced key, e.g. "ip:1.2.3.4" or "email:user@example.com" + * @param config Window size and max hits + */ +export async function checkRateLimit( + db: D1Database, + key: string, + config: RateLimitConfig, +): Promise { + const { max, windowSeconds } = config; + + // ISO timestamp for the start of the current window + const windowStart = new Date(Date.now() - windowSeconds * 1000).toISOString(); + + // 1. Prune hits outside the window + await db + .prepare("DELETE FROM rate_limit_hits WHERE key = ?1 AND hit_at < ?2") + .bind(key, windowStart) + .run(); + + // 2. Count current hits in the window + const row = await db + .prepare("SELECT COUNT(*) AS cnt FROM rate_limit_hits WHERE key = ?1 AND hit_at >= ?2") + .bind(key, windowStart) + .first<{ cnt: number }>(); + + const current = row?.cnt ?? 0; + + if (current >= max) { + // Fetch the oldest hit timestamp to compute retry-after + const oldest = await db + .prepare( + "SELECT hit_at FROM rate_limit_hits WHERE key = ?1 AND hit_at >= ?2 ORDER BY hit_at ASC LIMIT 1", + ) + .bind(key, windowStart) + .first<{ hit_at: string }>(); + + const oldestMs = oldest ? new Date(oldest.hit_at).getTime() : Date.now(); + const retryAfter = Math.ceil((oldestMs + windowSeconds * 1000 - Date.now()) / 1000); + + return { allowed: false, current, limit: max, retryAfter: Math.max(0, retryAfter) }; + } + + // 3. Record this hit + await db + .prepare("INSERT INTO rate_limit_hits (key, hit_at) VALUES (?1, ?2)") + .bind(key, new Date().toISOString()) + .run(); + + return { allowed: true, current, limit: max, retryAfter: 0 }; +} + +/** + * Run both IP and email checks. Returns the first failing result, or null + * if both pass (hits have already been recorded). + */ +export async function checkSubscribeRateLimit( + db: D1Database, + ip: string, + email: string, + env: { + RATE_LIMIT_IP_MAX?: string; + RATE_LIMIT_IP_WINDOW_S?: string; + RATE_LIMIT_EMAIL_MAX?: string; + RATE_LIMIT_EMAIL_WINDOW_S?: string; + }, +): Promise { + const ipConfig: RateLimitConfig = { + max: Number(env.RATE_LIMIT_IP_MAX ?? 5), + windowSeconds: Number(env.RATE_LIMIT_IP_WINDOW_S ?? 900), + }; + const emailConfig: RateLimitConfig = { + max: Number(env.RATE_LIMIT_EMAIL_MAX ?? 3), + windowSeconds: Number(env.RATE_LIMIT_EMAIL_WINDOW_S ?? 3600), + }; + + const ipResult = await checkRateLimit(db, `ip:${ip}`, ipConfig); + if (!ipResult.allowed) return ipResult; + + const emailResult = await checkRateLimit(db, `email:${email.toLowerCase()}`, emailConfig); + if (!emailResult.allowed) return emailResult; + + return null; // both passed +} diff --git a/alerts/src/schema.sql b/alerts/src/schema.sql index 81f8a22..89c12ed 100644 --- a/alerts/src/schema.sql +++ b/alerts/src/schema.sql @@ -14,3 +14,16 @@ CREATE TABLE IF NOT EXISTS subscriptions ( CREATE INDEX IF NOT EXISTS idx_subs_pool_asset_lev ON subscriptions(pool_id, asset_symbol, leverage_bracket); + +-- ── Rate-limit hits ─────────────────────────────────────────────────────────── +-- Sliding-window counters for /subscribe abuse prevention. +-- key = "ip:" | "email:" +-- Each row is one hit timestamp; old rows are pruned on every check. +CREATE TABLE IF NOT EXISTS rate_limit_hits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT NOT NULL, + hit_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_rl_key_hit_at + ON rate_limit_hits(key, hit_at); diff --git a/alerts/src/test-rate-limit.ts b/alerts/src/test-rate-limit.ts new file mode 100644 index 0000000..2894535 --- /dev/null +++ b/alerts/src/test-rate-limit.ts @@ -0,0 +1,204 @@ +import { checkSubscribeRateLimit } from "./rateLimit.ts"; +import worker from "./index.ts"; + +// ── In-Memory Mock of D1 Database ─────────────────────────────────────────── + +class MockD1Database { + public hits: { key: string; hit_at: string }[] = []; + + prepare(sql: string) { + const cleanSql = sql.replace(/\s+/g, " ").trim(); + return { + bind: (...args: any[]) => { + return { + run: async () => { + if (cleanSql.startsWith("DELETE FROM rate_limit_hits")) { + const [key, hit_at] = args; + const beforeCount = this.hits.length; + this.hits = this.hits.filter(h => !(h.key === key && h.hit_at < hit_at)); + const changes = beforeCount - this.hits.length; + return { meta: { changes } }; + } else if (cleanSql.startsWith("INSERT INTO rate_limit_hits")) { + const [key, hit_at] = args; + this.hits.push({ key, hit_at }); + return { meta: { changes: 1 } }; + } + return { meta: { changes: 0 } }; + }, + first: async () => { + if (cleanSql.includes("COUNT(*)")) { + const [key, hit_at] = args; + const count = this.hits.filter(h => h.key === key && h.hit_at >= hit_at).length; + return { cnt: count } as unknown as T; + } else if (cleanSql.includes("ORDER BY hit_at ASC")) { + const [key, hit_at] = args; + const filtered = this.hits.filter(h => h.key === key && h.hit_at >= hit_at); + // Sort chronologically + filtered.sort((a, b) => new Date(a.hit_at).getTime() - new Date(b.hit_at).getTime()); + return (filtered[0] || null) as unknown as T; + } + return null; + }, + all: async () => { + return { results: [] }; + } + }; + } + }; + } +} + +// ── Test Runner Helper ────────────────────────────────────────────────────── + +async function assertReject(promise: Promise, message: string) { + try { + await promise; + throw new Error(`Expected rejection but passed: ${message}`); + } catch { + // Pass + } +} + +function assertEquals(actual: any, expected: any, message: string) { + if (actual !== expected) { + throw new Error(`Assert failed: ${message}\nExpected: ${expected}\nActual: ${actual}`); + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +async function runTests() { + console.log("🧪 Running Rate Limiter Unit and Integration Tests...\n"); + + const db = new MockD1Database() as any; + const env = { + DB: db, + RESEND_API_KEY: "re_dummy", + RESEND_FROM: "alerts@turbolong.com", + FRONTEND_ORIGIN: "https://app.turbolong.com", + RATE_LIMIT_IP_MAX: "3", // limit to 3 hits for easy testing + RATE_LIMIT_IP_WINDOW_S: "10", // short window of 10s + RATE_LIMIT_EMAIL_MAX: "2", // limit to 2 hits for email + RATE_LIMIT_EMAIL_WINDOW_S: "20" + }; + + // Test 1: IP rate limit check (3 hits allowed) + console.log(" - Test 1: IP rate limit bucket enforces limit of 3"); + const ip = "1.1.1.1"; + const email1 = "test1@example.com"; + + // First hit + let res = await checkSubscribeRateLimit(db, ip, email1, env); + assertEquals(res, null, "1st hit should be allowed"); + + // Second hit + res = await checkSubscribeRateLimit(db, ip, "test2@example.com", env); + assertEquals(res, null, "2nd hit should be allowed"); + + // Third hit + res = await checkSubscribeRateLimit(db, ip, "test3@example.com", env); + assertEquals(res, null, "3rd hit should be allowed"); + + // Fourth hit (should be blocked) + res = await checkSubscribeRateLimit(db, ip, "test4@example.com", env); + if (!res) throw new Error("4th hit should be blocked"); + assertEquals(res.allowed, false, "Allowed should be false"); + assertEquals(res.limit, 3, "Limit should match configuration"); + if (res.retryAfter <= 0) throw new Error("RetryAfter should be positive"); + + // Test 2: Email rate limit check (2 hits allowed, independent of IP) + console.log(" - Test 2: Email rate limit bucket enforces limit of 2"); + const email2 = "spam@example.com"; + // Reset DB + db.hits = []; + + // Hit from IP A + res = await checkSubscribeRateLimit(db, "2.2.2.2", email2, env); + assertEquals(res, null, "Email hit 1 should be allowed"); + + // Hit from IP B + res = await checkSubscribeRateLimit(db, "3.3.3.3", email2, env); + assertEquals(res, null, "Email hit 2 should be allowed"); + + // Hit from IP C (should exceed limit) + res = await checkSubscribeRateLimit(db, "4.4.4.4", email2, env); + if (!res) throw new Error("Email hit 3 should be blocked"); + assertEquals(res.allowed, false, "Allowed should be false"); + assertEquals(res.limit, 2, "Email limit should match configuration"); + + // Test 3: Integration test via worker fetch() handler + console.log(" - Test 3: Worker fetch handler returns 429 and Retry-After header"); + db.hits = []; + + const makeRequest = (ipAddr: string, emailAddr: string) => { + return new Request("https://turbolong-alerts.workers.dev/subscribe", { + method: "POST", + headers: { + "CF-Connecting-IP": ipAddr, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email: emailAddr, + pool_id: "CDMAVJPFXPADND3YRL4BSM3AKZWCTFMX27GLLXCML3PD62HEQS5FPVAI", + asset_symbol: "USDC", + leverage_bracket: 2.0 + }) + }); + }; + + // 1st request + let response = await worker.fetch(makeRequest("5.5.5.5", "user@test.com"), env); + // Note: verification email will try to send and fail because RESEND_API_KEY is dummy, returning 500. + // But rate limiting happens BEFORE that, so let's verify it gets past rate limiter (status != 429). + if (response.status === 429) { + throw new Error("1st request should not be rate limited"); + } + + // 2nd request + response = await worker.fetch(makeRequest("5.5.5.5", "user@test.com"), env); + if (response.status === 429) { + throw new Error("2nd request should not be rate limited"); + } + + // 3rd request (hits IP limit = 3) + response = await worker.fetch(makeRequest("5.5.5.5", "user2@test.com"), env); + if (response.status === 429) { + throw new Error("3rd request should not be rate limited"); + } + + // 4th request (should return 429) + response = await worker.fetch(makeRequest("5.5.5.5", "user3@test.com"), env); + assertEquals(response.status, 429, "4th request should return 429"); + + const body = await response.json() as any; + assertEquals(body.ok, false, "JSON ok should be false"); + if (!body.error.includes("Too many requests")) { + throw new Error("Unexpected error message: " + body.error); + } + + const retryHeader = response.headers.get("Retry-After"); + if (!retryHeader || Number(retryHeader) <= 0) { + throw new Error("Missing or invalid Retry-After header: " + retryHeader); + } + + // Test 4: Sliding window pruning + console.log(" - Test 4: Prunes expired entries correctly"); + db.hits = []; + + // Add an entry dated 30 seconds ago (expired) + const expiredTime = new Date(Date.now() - 30 * 1000).toISOString(); + db.hits.push({ key: "ip:6.6.6.6", hit_at: expiredTime }); + + // Call rate limiter (should prune the expired entry and allow) + res = await checkSubscribeRateLimit(db, "6.6.6.6", "fresh@example.com", env); + assertEquals(res, null, "Should allow request and prune old entry"); + assertEquals(db.hits.filter((h: any) => h.key === "ip:6.6.6.6").length, 1, "Should only have the new hit"); + + console.log("\n✅ All tests passed successfully!"); +} + +runTests().catch(err => { + console.error("\n❌ Test run failed!"); + console.error(err); + process.exit(1); +}); diff --git a/alerts/wrangler.toml b/alerts/wrangler.toml index 0f8030d..58653f7 100644 --- a/alerts/wrangler.toml +++ b/alerts/wrangler.toml @@ -14,3 +14,10 @@ database_id = "" RESEND_FROM = "alerts@turbolong.com" FRONTEND_ORIGIN = "https://app.turbolong.com" # RESEND_API_KEY: set via `wrangler secret put RESEND_API_KEY` + +# Rate Limiting Configuration (H2) +RATE_LIMIT_IP_MAX = "5" +RATE_LIMIT_IP_WINDOW_S = "900" # 15 minutes +RATE_LIMIT_EMAIL_MAX = "3" +RATE_LIMIT_EMAIL_WINDOW_S = "3600" # 1 hour + diff --git a/frontend/index.html b/frontend/index.html index f904f23..0c98bfc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -306,39 +306,50 @@

Portfolio Overview

- -
-
-
Supply
-
- Interest APY - -
-
- BLND emissions ? - -
-
- Net supply APY ? - -
-
-
-
Borrow
-
- Interest cost - -
-
- BLND emissions ? - + +
+
+ Net APY ? + +
+
+ + +
+ Rate breakdown +
+
+
Supply
+
+ Interest APY + +
+
+ BLND emissions ? + +
+
+ Net supply APY ? + +
-
- Net borrow cost ? - +
+
Borrow
+
+ Interest cost + +
+
+ BLND emissions ? + +
+
+ Net borrow cost ? + +
-
+
diff --git a/frontend/src/blend.ts b/frontend/src/blend.ts index 1c4ad6c..4001bf3 100644 --- a/frontend/src/blend.ts +++ b/frontend/src/blend.ts @@ -424,6 +424,8 @@ export interface ReserveStats { blndBorrowApr: number; // % pa — BLND emissions on borrow side (currently 0) netSupplyApr: number; // interest + blnd netBorrowCost: number; // interest - blnd (usually just interest) + /** Headline metric: APY on the supply side (interest compounded) + BLND emission APR. */ + netApy: number; // aprToApy(interestSupplyApr) + blndSupplyApr supplyEps: bigint; // raw eps from pool, 0 if no emissions borrowEps: bigint; supplyEmission: any; // raw get_reserve_emissions result for supply token @@ -533,6 +535,11 @@ export async function fetchAllReserves(pool: PoolDef, userAddress: string): Prom console.log(`[blend:${pool.name}] ${asset.symbol} util=${util.toFixed(4)} c=${cFactor.toFixed(4)} l=${lFactor.toFixed(4)} borrowApr=${interestBorrowApr.toFixed(4)}% supplyApr=${interestSupplyApr.toFixed(4)}% blndSupplyApr=${blndSupplyApr.toFixed(4)}% supplyEps=${supplyEps}`); + const _netSupplyApr = interestSupplyApr + blndSupplyApr; + const _netBorrowCost = interestBorrowApr - blndBorrowApr; + // Headline APY: interest side compounded (APY), BLND is linear (APR) + const _netApy = (Math.exp(interestSupplyApr / 100) - 1) * 100 + blndSupplyApr; + results.push({ asset: { ...asset, cFactor, maxUtil: maxUtilActual }, cFactor, @@ -549,8 +556,9 @@ export async function fetchAllReserves(pool: PoolDef, userAddress: string): Prom interestSupplyApr, blndSupplyApr, blndBorrowApr, - netSupplyApr: interestSupplyApr + blndSupplyApr, - netBorrowCost: interestBorrowApr - blndBorrowApr, + netSupplyApr: _netSupplyApr, + netBorrowCost: _netBorrowCost, + netApy: _netApy, supplyEps, borrowEps, supplyEmission: supplyEmissions, @@ -573,6 +581,8 @@ export interface ProjectedRates { blndBorrowApr: number; netSupplyApr: number; netBorrowCost: number; + /** Headline metric: APY on supply side (interest compounded) + BLND APR. */ + netApy: number; } /** @@ -621,6 +631,8 @@ export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: num const blndSupplyApr = projSupplyUsd > 0 ? (supplyBlndYr * bp / projSupplyUsd) * 100 : 0; const blndBorrowApr = projBorrowUsd > 0 ? (borrowBlndYr * bp / projBorrowUsd) * 100 : 0; + const projNetApy = (Math.exp(interestSupplyApr / 100) - 1) * 100 + blndSupplyApr; + return { interestSupplyApr, interestBorrowApr, @@ -628,6 +640,7 @@ export function projectRates(rs: ReserveStats, addSupply: number, addBorrow: num blndBorrowApr, netSupplyApr: interestSupplyApr + blndSupplyApr, netBorrowCost: interestBorrowApr - blndBorrowApr, + netApy: projNetApy, }; } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index fcc1ceb..82ae11a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -757,6 +757,7 @@ function renderSelectedAsset() { // Clear skeletons (#3) ["stat-cfactor","stat-max-lev","stat-liquidity","stat-util","stat-price", + "pool-net-apy", "supply-interest-apr","supply-blnd-apr","supply-net-apr","borrow-interest-apr","borrow-blnd-apr","borrow-net-cost"] .forEach(clearSkeleton); @@ -787,6 +788,18 @@ function renderSelectedAsset() { renderAprLine("borrow-blnd-apr", rs.blndBorrowApr, false, true, "-"); renderAprLine("borrow-net-cost", aprToApy(rs.interestBorrowApr) - rs.blndBorrowApr, true, false, undefined, true); + // ── Net APY headline ────────────────────────────────────────────────────── + const netApyEl = $("pool-net-apy"); + const netApyVal = rs.netApy; // aprToApy(interestSupplyApr) + blndSupplyApr + const netApySign = netApyVal >= 0 ? "+" : ""; + netApyEl.textContent = `${netApySign}${fmt(netApyVal, 2)}%`; + netApyEl.className = `net-apy-headline-value ${ + netApyVal > 10 ? "apr-great" : netApyVal > 3 ? "apr-ok" : netApyVal > 0 ? "apr-dim" : "apr-bad" + }`; + const netApyTip = $("pool-net-apy-tip"); + if (netApyTip) netApyTip.setAttribute("data-tip", + `Supply APY (compounded) + BLND emission APR at 1× leverage. Interest APY: ${fmt(aprToApy(rs.interestSupplyApr), 2)}%, BLND: +${fmt(rs.blndSupplyApr, 2)}%. Actual net APR: ${fmt(rs.netSupplyApr, 2)}%`); + // Update net tooltips with actual APR const supplyTip = $("supply-net-tip"); if (supplyTip) supplyTip.setAttribute("data-tip", @@ -838,18 +851,22 @@ function renderPortfolioSummary() { container.innerHTML = ""; for (const [assetId, pos] of positions.byAsset) { const rs = reserves.find(r => r.asset.id === assetId); + // Leveraged Net APY for the position const cardNetApr = rs ? rs.netSupplyApr * pos.leverage - rs.netBorrowCost * (pos.leverage - 1) : 0; const netApy = aprToApy(cardNetApr); + // Base (1×) pool Net APY for secondary context + const baseNetApy = rs ? rs.netApy : 0; const hfColor = pos.hf > 1.1 ? "var(--success)" : pos.hf > 1.03 ? "var(--warning)" : "var(--danger)"; const card = document.createElement("div"); card.className = `portfolio-card ${assetId === selectedAsset.id ? "active" : ""}`; - card.title = `Approximate APY — Blend does not auto-compound. Actual net APR: ${fmt(cardNetApr, 2)}%`; + card.title = `Leveraged Net APY — Blend does not auto-compound. Actual net APR: ${fmt(cardNetApr, 2)}%`; card.innerHTML = ` ${pos.asset.symbol} + ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% ${fmt(pos.equity, 2)} equity \u00B7 ${fmt(pos.leverage, 1)}\u00D7 - APY ${netApy >= 0 ? "+" : ""}${fmt(netApy, 2)}% \u00B7 HF ${fmt(pos.hf, 2)} + HF ${fmt(pos.hf, 2)} \u00B7 pool ${fmt(baseNetApy, 2)}% base `; card.addEventListener("click", () => { const asset = assets.find(a => a.id === assetId); @@ -1316,6 +1333,7 @@ async function loadAll() { // Show skeletons (#3) const skeletonIds = ["stat-cfactor","stat-max-lev","stat-liquidity","stat-util","stat-price", + "pool-net-apy", "supply-interest-apr","supply-blnd-apr","supply-net-apr","borrow-interest-apr","borrow-blnd-apr","borrow-net-cost", "pos-collateral","pos-debt","pos-equity","pos-leverage","pos-hf","pos-pool-hf","pos-net-apr","pos-headroom","pos-liq-days"]; skeletonIds.forEach(setSkeleton); diff --git a/frontend/src/style.css b/frontend/src/style.css index 0d4348f..4673b28 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -367,6 +367,11 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 .portfolio-card-symbol { font-family: var(--mono); font-weight: 700; font-size: 14px; } .portfolio-card-details { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--text-2); } .portfolio-card-hf-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +/* Net APY badge — prominent headline number on each position card */ +.portfolio-card-net-apy { + font-family: var(--mono); font-size: 18px; font-weight: 800; + letter-spacing: -.5px; margin-left: auto; flex-shrink: 0; +} /* ── Pool stats (always visible) ─────────────────────────────────────────── */ @@ -427,6 +432,47 @@ main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: 20px 24 .apr-warn { color: var(--warning); } .apr-bad { color: var(--danger); } + +/* ── Net APY headline (pool / asset card) ────────────────────────────────── */ + +.net-apy-headline-row { + margin-bottom: 14px; +} +.net-apy-headline-card { + display: flex; align-items: baseline; justify-content: space-between; + background: var(--surface); border: 1px solid var(--border); + border-radius: var(--r); padding: 14px 18px; + transition: border-color .2s; +} +.net-apy-headline-card:hover { border-color: var(--border-h); } +.net-apy-headline-label { + font-size: 12px; font-weight: 700; text-transform: uppercase; + letter-spacing: .8px; color: var(--text-3); +} +.net-apy-headline-value { + font-family: var(--mono); font-size: 28px; font-weight: 800; + letter-spacing: -1px; line-height: 1; +} + +/* ── APR breakdown details/summary expander ──────────────────────────────── */ + +.apr-breakdown-details { + margin-bottom: 12px; +} +.apr-breakdown-details[open] > .apr-breakdown-summary .apr-breakdown-caret { + transform: rotate(180deg); +} +.apr-breakdown-summary { + list-style: none; display: flex; align-items: center; justify-content: space-between; + cursor: pointer; padding: 6px 2px; + font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; + color: var(--text-2); user-select: none; +} +.apr-breakdown-summary::-webkit-details-marker { display: none; } +.apr-breakdown-summary:hover { color: var(--text); } +.apr-breakdown-caret { transition: transform .2s; display: inline-block; font-size: 10px; } +.apr-breakdown-details .apr-grid { margin-bottom: 0; margin-top: 10px; } + /* ── Frozen banner ───────────────────────────────────────────────────────── */ .pool-frozen-banner { diff --git a/frontend/test/parity.test.ts b/frontend/test/parity.test.ts index 4c67172..97761aa 100644 --- a/frontend/test/parity.test.ts +++ b/frontend/test/parity.test.ts @@ -34,6 +34,7 @@ interface RustOutput { blndBorrowApr: number; netSupplyApr: number; netBorrowCost: number; + netApy?: number; // added in TS; Rust binary may not emit this yet } describe('Parity tests between Rust simulate binary and TS projectRates', () => { diff --git a/landing/.well-known/security.txt b/landing/.well-known/security.txt new file mode 100644 index 0000000..c9d333c --- /dev/null +++ b/landing/.well-known/security.txt @@ -0,0 +1,9 @@ +Contact: mailto:security@turbolong.app +Contact: https://t.me/turbolong_security +Expires: 2027-05-29T00:00:00.000Z +Encryption: https://turbolong.app/.well-known/pgp-key.txt +Acknowledgments: https://turbolong.app/bug-bounty#acknowledgements +Policy: https://turbolong.app/bug-bounty +Policy: https://github.com/kweb-drips/TurboLong/blob/main/SECURITY.md +Preferred-Languages: en +Canonical: https://turbolong.app/.well-known/security.txt diff --git a/landing/bug-bounty.html b/landing/bug-bounty.html new file mode 100644 index 0000000..3b5696e --- /dev/null +++ b/landing/bug-bounty.html @@ -0,0 +1,486 @@ + + + + + + Bug Bounty Program — TurboLong + + + + + + + + + + + + +
+
+ + Program Active — Self-Hosted +
+

Bug Bounty Program

+

+ Help us secure TurboLong's Blend leverage strategy and keeper infrastructure. + Earn up to $15,000 USDC for critical findings. +

+
+
+
$15K
+
Critical max reward
+
+
+
48h
+
Acknowledgement SLA
+
+
+
90d
+
Coordinated disclosure
+
+
+
USDC
+
Payout on Stellar
+
+
+
+ +
+ + +
+

🎯 In-Scope Targets

+
+
+
+
Blend Leverage Strategy Contract
+
Soroban smart contract — primary target
+
+
contracts/strategies/blend_leverage/src/
+ In Scope +
+
+
+
Keeper / Execute-Loop Binary
+
Off-chain automation that triggers rebalances
+
+
src/bin/execute_loop.rs
+ In Scope +
+
+
+
APY Alert Worker
+
Cloudflare Worker + D1 subscription service
+
+
alerts/src/
+ In Scope +
+
+
+
Frontend / Landing Page
+
XSS, CSP bypass, phishing vectors only
+
+
frontend/  |  landing/
+ In Scope +
+
+ +

🚫 Out of Scope

+
+
    +
  • Third-party Blend Protocol contracts (report to Blend directly)
  • +
  • Reflector oracle contracts
  • +
  • Issues already documented in BLEND-BUG-BOUNTY-REPORT.md
  • +
  • Theoretical issues without a working proof-of-concept
  • +
  • Dependency CVEs not exploitable in this specific codebase
  • +
  • Social engineering or phishing against the team
  • +
  • DoS attacks against shared infrastructure
  • +
+
+
+ + +
+

💰 Severity Tiers & Payouts

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SeverityExample ImpactReward (USDC)
● CriticalDirect theft / permanent freeze ≥ $50k user funds; contract-level RCE$5,000 – $15,000
● HighLoss / freeze < $50k; utilization-rate manipulation; oracle price walking$1,000 – $5,000
● MediumTemporary freeze; governance/TVL inflation; keeper key exposure$200 – $1,000
● LowNon-exploitable logic errors; front-end XSS with no fund access$50 – $200
● InformationalCode quality issues, gas optimisations, suggestionsAcknowledgement
+
+

+ Severity is assessed using the Immunefi Vulnerability Severity Classification System v2.3 + adapted for Stellar / Soroban. Payouts are made in USDC on Stellar mainnet within 30 days of a confirmed fix. +

+
+ + +
+

📋 Rules & Eligibility

+
+
    +
  1. + 1. + You must be the first reporter of the issue. +
  2. +
  3. + 2. + Provide us 90 days to remediate before public disclosure (shorter by mutual agreement). +
  4. +
  5. + 3. + Exploit the vulnerability only as far as necessary to demonstrate impact. Do not affect other users' funds. +
  6. +
  7. + 4. + Run automated tests against testnet or simulation (cargo test -- --nocapture), not against mainnet. +
  8. +
  9. + 5. + Rewards are not available to residents of OFAC-sanctioned jurisdictions or current/former team members. +
  10. +
  11. + 6. + We will not take legal action against researchers who act in good faith under these rules (safe harbour). +
  12. +
+
+ +

📅 Disclosure Timeline

+
+
+
Day 0
Report received
Email sent to security@turbolong.app
+
Day 2
Acknowledgement
We confirm receipt and assign an internal tracking ID
+
Day 7
Triage decision
Severity rating confirmed, in-scope/out-of-scope determination
+
Day 30
Fix developed
Patch written and internally reviewed
+
Day 60
Fix deployed
Mainnet deployment; payout initiated
+
Day 90
Public disclosure
You may publish after this date. We credit you unless you prefer anonymity.
+
+
+
+ + +
+

📬 Disclosure Contact

+
+
+
Primary (email)
+ +
+
+
Telegram (urgent)
+ +
+
+
PGP / security.txt
+ +
+
+
GitHub Security
+ +
+
+
+ + +
+

Ready to report a finding?

+

Read the full policy in SECURITY.md then send your report. We look forward to working with you.

+ +
+ +
+ + + + + diff --git a/scripts/test-disclosure-pipeline.ts b/scripts/test-disclosure-pipeline.ts new file mode 100644 index 0000000..9cf0604 --- /dev/null +++ b/scripts/test-disclosure-pipeline.ts @@ -0,0 +1,228 @@ +/** + * Disclosure Pipeline Smoke Test + * + * Verifies the end-to-end disclosure pipeline is wired correctly: + * 1. security.txt is reachable and well-formed (RFC 9116) + * 2. Bug-bounty page is reachable and contains expected content + * 3. SECURITY.md is present in the repository root + * 4. Disclosure email address is defined and non-placeholder + * 5. Simulates a mock disclosure submission (dry-run via stdout) + * + * Usage: + * npx ts-node scripts/test-disclosure-pipeline.ts [--base-url https://turbolong.app] + * + * Exit codes: + * 0 all checks passed + * 1 one or more checks failed + */ + +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// ── Config ───────────────────────────────────────────────────────────────── + +// argv[0]=node, argv[1]=script — user args start at index 2 +const _userArgs = process.argv.slice(2); +const BASE_URL = + _userArgs.find((a) => a.startsWith("--base-url="))?.split("=")[1] ?? + (_userArgs.includes("--base-url") + ? _userArgs[_userArgs.indexOf("--base-url") + 1] + : undefined) ?? + "https://turbolong.app"; + +const REPO_ROOT = path.resolve(__dirname, ".."); + +const EXPECTED_CONTACT = "security@turbolong.app"; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +type Result = { name: string; ok: boolean; detail: string }; +const results: Result[] = []; + +function pass(name: string, detail = "") { + results.push({ name, ok: true, detail }); + console.log(` ✅ ${name}${detail ? " — " + detail : ""}`); +} + +function fail(name: string, detail = "") { + results.push({ name, ok: false, detail }); + console.error(` ❌ ${name}${detail ? " — " + detail : ""}`); +} + +async function fetchText(url: string): Promise { + try { + const res = await fetch(url); + if (!res.ok) return null; + return await res.text(); + } catch { + return null; + } +} + +// ── Check 1: SECURITY.md present in repo root ─────────────────────────────── + +function checkSecurityMd() { + const p = path.join(REPO_ROOT, "SECURITY.md"); + if (!fs.existsSync(p)) { + return fail("SECURITY.md present", "file not found at repo root"); + } + const content = fs.readFileSync(p, "utf-8"); + if (!content.includes(EXPECTED_CONTACT)) { + return fail("SECURITY.md contains contact", `"${EXPECTED_CONTACT}" not found`); + } + if (!content.includes("Critical") || !content.includes("High")) { + return fail("SECURITY.md contains severity tiers", "missing Critical / High tiers"); + } + pass("SECURITY.md present and well-formed"); +} + +// ── Check 2: security.txt present locally ─────────────────────────────────── + +function checkSecurityTxt() { + const p = path.join(REPO_ROOT, "landing", ".well-known", "security.txt"); + if (!fs.existsSync(p)) { + return fail("security.txt present locally", "not found at landing/.well-known/security.txt"); + } + const content = fs.readFileSync(p, "utf-8"); + if (!content.includes("Contact:")) { + return fail("security.txt has Contact field", "no Contact: line found"); + } + if (!content.includes(EXPECTED_CONTACT)) { + return fail("security.txt contact matches policy", `"${EXPECTED_CONTACT}" not found`); + } + if (!content.includes("Expires:")) { + return fail("security.txt has Expires field", "RFC 9116 requires Expires:"); + } + if (!content.includes("Policy:")) { + return fail("security.txt has Policy field", "Policy: line missing"); + } + pass("security.txt present and RFC 9116 compliant"); +} + +// ── Check 3: bug-bounty.html present locally ──────────────────────────────── + +function checkBugBountyPage() { + const p = path.join(REPO_ROOT, "landing", "bug-bounty.html"); + if (!fs.existsSync(p)) { + return fail("bug-bounty.html present", "not found at landing/bug-bounty.html"); + } + const content = fs.readFileSync(p, "utf-8"); + const required = ["Critical", "High", "Medium", "In Scope", EXPECTED_CONTACT, "Payout"]; + for (const term of required) { + if (!content.includes(term)) { + return fail("bug-bounty.html content", `missing required term: "${term}"`); + } + } + pass("bug-bounty.html present and content complete"); +} + +// ── Check 4: Remote security.txt reachable ────────────────────────────────── + +async function checkRemoteSecurityTxt() { + const url = `${BASE_URL}/.well-known/security.txt`; + const text = await fetchText(url); + if (!text) { + return fail("Remote security.txt reachable", `GET ${url} failed (site may not be deployed yet)`); + } + if (!text.includes("Contact:") || !text.includes(EXPECTED_CONTACT)) { + return fail("Remote security.txt content", "Contact field missing or incorrect"); + } + pass("Remote security.txt reachable", url); +} + +// ── Check 5: Remote bug-bounty page reachable ─────────────────────────────── + +async function checkRemoteBountyPage() { + const url = `${BASE_URL}/bug-bounty`; + const text = await fetchText(url); + if (!text) { + // Also try .html extension + const url2 = `${BASE_URL}/bug-bounty.html`; + const text2 = await fetchText(url2); + if (!text2) { + return fail("Remote bug-bounty page reachable", `GET ${url} and ${url2} both failed`); + } + if (!text2.includes("Bug Bounty")) { + return fail("Remote bug-bounty page content", "expected heading not found"); + } + return pass("Remote bug-bounty page reachable", url2); + } + if (!text.includes("Bug Bounty")) { + return fail("Remote bug-bounty page content", "expected heading not found"); + } + pass("Remote bug-bounty page reachable", url); +} + +// ── Check 6: Simulate disclosure submission (dry run) ─────────────────────── + +function simulateDisclosure() { + const mockReport = { + to: EXPECTED_CONTACT, + subject: "Bug Bounty Report — [SEVERITY] — [SHORT TITLE]", + body: [ + "Affected target: contracts/strategies/blend_leverage/src/lib.rs", + "Severity assessment: Critical / High / Medium / Low", + "Description: ", + "Reproduction steps:", + " 1. ...", + " 2. ...", + "Impact: ", + "Suggested fix: ", + "Contact: ", + "Stellar address for payout: G...", + ].join("\n"), + }; + + console.log("\n 📧 Mock disclosure submission (dry-run):"); + console.log(` To: ${mockReport.to}`); + console.log(` Subject: ${mockReport.subject}`); + console.log(" Body template:"); + mockReport.body.split("\n").forEach((l) => console.log(` ${l}`)); + + pass("Disclosure submission template generated"); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main() { + console.log("\n🔍 TurboLong Disclosure Pipeline Smoke Test"); + console.log(` Base URL : ${BASE_URL}`); + console.log(` Repo root: ${REPO_ROOT}`); + console.log("─".repeat(60)); + + console.log("\n[Local file checks]"); + checkSecurityMd(); + checkSecurityTxt(); + checkBugBountyPage(); + + console.log("\n[Remote checks — requires deployed site]"); + await checkRemoteSecurityTxt(); + await checkRemoteBountyPage(); + + console.log("\n[Disclosure simulation]"); + simulateDisclosure(); + + // ── Summary ── + const passed = results.filter((r) => r.ok).length; + const failed = results.filter((r) => !r.ok).length; + + console.log("\n" + "─".repeat(60)); + console.log(`Results: ${passed} passed, ${failed} failed\n`); + + if (failed > 0) { + console.error("Some checks failed. Review the output above."); + process.exit(1); + } else { + console.log("All checks passed. Disclosure pipeline is operational. ✅"); + process.exit(0); + } +} + +main().catch((e) => { + console.error("Unexpected error:", e); + process.exit(1); +});