diff --git a/README.md b/README.md index 585afc0..300694b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Testudo + Testudo

Testudo

@@ -30,7 +30,8 @@ Testudo acts as an antivirus for every Ethereum transaction. It intercepts signa | Capability | Method | |-----------|--------| -| EIP-7702 delegation analysis | Bytecode capability extraction (14 detectors) | +| EIP-7702 delegation analysis | Bytecode capability extraction (23 detectors) | +| EIP-7702 cross-chain replay (`chainId=0`) | Typed "I ACCEPT THE RISK" confirmation gate | | Token approvals (`approve`, `increaseAllowance`) | Calldata decoding + address check | | NFT `setApprovalForAll` | Calldata decoding + marketplace allowlist | | Permit / Permit2 signatures | Typed data primaryType detection | @@ -38,18 +39,20 @@ Testudo acts as an antivirus for every Ethereum transaction. It intercepts signa | `eth_sign` hard block | Always CRITICAL, typed confirmation required | | Typed data address scanning | Recursive address extraction + batch check | | Malicious transaction recipients | Address-only check pipeline | +| Phishing domain blocking | DNR rules + bloom filter + CDN sync | | Deployer reputation | Nonce/age heuristic via Blockscout + RPC | | Human-readable intent | Token metadata resolution + intent builder | +| Proxy resolution | EIP-1967 storage + EIP-1167 bytecode + cycle guard | ## Architecture **3-layer defense** — every unknown address passes through: -1. **Safe Filter** (local, instant) — known-good addresses skip API -2. **Threat Intelligence API** (800ms timeout) — 15K+ malicious addresses + GoPlus real-time fallback -3. **Local Bytecode Analysis** (parallel) — 14 deterministic detectors, no ML +1. **Safe Filter** (local, instant) — known-good addresses skip API. Ed25519-signed CDN manifest with rollback protection +2. **Threat Intelligence API** (800ms timeout, per-chain routes) — aggregated malicious address registry + GoPlus real-time fallback +3. **Local Bytecode Analysis** (parallel) — 23 deterministic detectors, no ML -All results are **explainable**: "This contract HAS capability X because of opcode Y at offset Z." +Decision matrix is **fail-closed on strong local signals**: API "clean" cannot downgrade a CRITICAL local bytecode match (zero-day protection, ADR-013). All results are **explainable**: "This contract HAS capability X because of opcode Y at offset Z." ## Installation @@ -82,9 +85,10 @@ yarn test ``` packages/ - core/ # @testudo/core — Detection engine (190 tests) - extension/ # @testudo/extension — Chrome extension (Preact + Signals) - e2e/ # End-to-end tests (Playwright, 43 tests) + core/ # @testudo/core — Detection engine (395 tests) + extension/ # @testudo/extension — Chrome extension (Preact + Signals, 375 tests) + e2e/ # Playwright E2E tests + e2e-docker/ # Synpress + Anvil attack-pattern E2E apps/ mock-dapp/ # Demo playground for testing diff --git a/packages/extension/.env.local.example b/packages/extension/.env.local.example new file mode 100644 index 0000000..833f224 --- /dev/null +++ b/packages/extension/.env.local.example @@ -0,0 +1,9 @@ +# Copy to `.env.local` (gitignored) and fill in values. +# The build reads this automatically; explicit env vars still win. + +# API key for testudo-api. Get from Railway env on the testudo-api service. +TESTUDO_API_KEY= + +# Override the API base URL for local/staging testing. +# Default: https://testudo-api-production.up.railway.app +# TESTUDO_API_URL=http://localhost:3000 diff --git a/packages/extension/manifest.json b/packages/extension/manifest.json index 52d4bec..3a0f197 100644 --- a/packages/extension/manifest.json +++ b/packages/extension/manifest.json @@ -2,8 +2,8 @@ "manifest_version": 3, "name": "Testudo", "short_name": "Testudo", - "version": "0.2.0", - "description": "Protect your wallet from malicious EIP-7702 delegations. Real-time bytecode analysis warns you before signing dangerous contracts.", + "version": "0.3.0", + "description": "Antivirus for your Ethereum wallet. Analyzes contracts, approvals, and signatures before you sign — EIP-7702, permits, phishing, and more.", "homepage_url": "https://github.com/Lykhoyda/Testudo", "minimum_chrome_version": "137", diff --git a/packages/extension/popup.html b/packages/extension/popup.html index 052beac..0974590 100644 --- a/packages/extension/popup.html +++ b/packages/extension/popup.html @@ -284,14 +284,12 @@ .activity-item { display: flex; - align-items: center; - gap: 12px; + flex-direction: column; background: var(--surface); - padding: 12px; border-radius: var(--r-md); border: 1px solid var(--border); cursor: pointer; - transition: all 0.15s; + transition: background 0.15s, border-color 0.15s; } .activity-item:hover { @@ -299,6 +297,164 @@ border-color: var(--border-hover); } + .activity-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; + } + + .activity-item.expanded { + background: var(--surface-raised); + border-color: var(--border-hover); + } + + .activity-main { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: transparent; + border: 0; + width: 100%; + text-align: left; + color: inherit; + font: inherit; + cursor: pointer; + border-radius: var(--r-md); + } + + .activity-main:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + } + + .activity-item.expanded .activity-main { + border-radius: var(--r-md) var(--r-md) 0 0; + } + + .activity-chevron { + font-size: 18px !important; + color: var(--text-dim); + flex-shrink: 0; + transition: transform 0.15s; + } + + .activity-chevron.open { + transform: rotate(180deg); + color: var(--text-muted); + } + + .activity-details { + position: relative; + border-top: 1px solid var(--border); + padding: 14px 14px 16px; + font-family: var(--font-ui); + font-size: 13px; + line-height: 1.45; + } + + .activity-details::before { + content: ''; + position: absolute; + top: -1px; + left: 12px; + right: 12px; + height: 2px; + border-radius: 0 0 2px 2px; + background: var(--border-strong); + opacity: 0.8; + } + + .activity-details[data-risk="critical"]::before { background: var(--danger); } + .activity-details[data-risk="high"]::before, + .activity-details[data-risk="medium"]::before { background: var(--warn); } + .activity-details[data-risk="low"]::before { background: var(--safe); } + + .activity-details-grid { + display: grid; + grid-template-columns: 68px 1fr; + row-gap: 8px; + column-gap: 12px; + margin: 0; + } + + .activity-details-grid dt { + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 10px; + font-weight: 600; + align-self: start; + padding-top: 2px; + } + + .activity-details-grid dd { + margin: 0; + color: var(--text); + word-break: break-all; + font-size: 13px; + } + + .activity-details-mono { + font-family: var(--font-mono); + font-size: 12px !important; + color: var(--text-muted); + line-height: 1.4; + } + + .activity-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px 3px 8px; + border-radius: 999px; + font-weight: 600; + font-size: 12px; + letter-spacing: 0.01em; + border: 1px solid transparent; + } + + .activity-status.blocked { + color: var(--danger); + background: var(--danger-bg); + border-color: var(--danger-border); + } + + .activity-status.allowed { + color: var(--safe); + background: var(--safe-bg); + border-color: var(--safe-border); + } + + .activity-status-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: currentColor; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent); + flex-shrink: 0; + } + + .activity-threats { + display: flex; + flex-wrap: wrap; + gap: 4px 5px; + } + + .activity-threat-chip { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 4px; + background: var(--warn-bg); + border: 1px solid var(--warn-border); + color: var(--warn); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1.5; + } + .activity-icon-wrapper { display: flex; align-items: center; diff --git a/packages/extension/rolldown.config.ts b/packages/extension/rolldown.config.ts index c492eb8..85da5b5 100644 --- a/packages/extension/rolldown.config.ts +++ b/packages/extension/rolldown.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from 'rolldown'; -import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'fs'; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; @@ -7,6 +7,33 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const distDir = join(__dirname, 'dist'); const fontsDir = join(distDir, 'fonts'); +// Load `.env.local` (gitignored) into process.env so developers don't have to +// re-export TESTUDO_API_KEY on every build. Explicit env vars win over the file. +function loadEnvFile(path: string): void { + if (!existsSync(path)) return; + const content = readFileSync(path, 'utf-8'); + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq === -1) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (process.env[key] === undefined) { + process.env[key] = value; + } + } + console.log(`[Testudo Build] Loaded env from ${path.replace(__dirname, '.')}`); +} + +loadEnvFile(join(__dirname, '.env.local')); + if (!existsSync(distDir)) { mkdirSync(distDir); } diff --git a/packages/extension/src/components/popup/RecentActivity.tsx b/packages/extension/src/components/popup/RecentActivity.tsx index 522a811..eaaa1e6 100644 --- a/packages/extension/src/components/popup/RecentActivity.tsx +++ b/packages/extension/src/components/popup/RecentActivity.tsx @@ -1,5 +1,6 @@ -import type { ReadonlySignal } from '@preact/signals'; +import { type ReadonlySignal, signal } from '@preact/signals'; import { + formatDate, formatRelativeTime, getRiskIcon, getRiskLabel, @@ -13,6 +14,26 @@ interface Props { onViewAll: () => void; } +// Module-level state keeps the expanded scan sticky across popup re-renders. +const expandedKey = signal(null); + +function scanKey(scan: RecentScan): string { + return `${scan.address}-${scan.timestamp}`; +} + +function hostnameFromUrl(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function toggleExpanded(key: string): void { + expandedKey.value = expandedKey.value === key ? null : key; +} + export function RecentActivity({ scans, onViewAll }: Props) { return (
@@ -26,22 +47,81 @@ export function RecentActivity({ scans, onViewAll }: Props) { {scans.value.length === 0 ? (
No recent scans
) : ( - scans.value.map((scan) => ( -
-
- -
-
-
- - {getRiskLabel(scan.risk)} - - {formatRelativeTime(scan.timestamp)} -
- {truncateAddress(scan.address, 8, 6)} + scans.value.map((scan) => { + const key = scanKey(scan); + const isExpanded = expandedKey.value === key; + const hostname = hostnameFromUrl(scan.url); + const threats = scan.threats?.filter(Boolean) ?? []; + return ( +
+ + + {isExpanded && ( +
+
+
Address
+
{scan.address}
+ +
Seen
+
{formatDate(scan.timestamp)}
+ + {hostname && ( + <> +
Site
+
{hostname}
+ + )} + +
Status
+
+ + +
+ + {threats.length > 0 && ( + <> +
Threats
+
+
+ {threats.map((t) => ( + + {t.toUpperCase()} + + ))} +
+
+ + )} +
+
+ )}
-
- )) + ); + }) )}
diff --git a/packages/extension/src/services/messaging.ts b/packages/extension/src/services/messaging.ts index 5d0acb2..b48d6e0 100644 --- a/packages/extension/src/services/messaging.ts +++ b/packages/extension/src/services/messaging.ts @@ -12,18 +12,19 @@ function sendTestudoRequest( ): Promise { return new Promise((resolve, reject) => { const requestId = crypto.randomUUID(); + let timer: ReturnType | undefined; const unsubscribe = channel.onResponse((msg) => { if (msg.type === responseType && msg.requestId === requestId) { unsubscribe(); - clearTimeout(timer); + if (timer !== undefined) clearTimeout(timer); resolve(msg.result as T); } }); channel.sendRequest({ type: requestType, requestId, ...payload }); - const timer = setTimeout(() => { + timer = setTimeout(() => { unsubscribe(); reject(new Error(`${requestType} timeout`)); }, timeoutMs); diff --git a/packages/extension/src/utils/types.ts b/packages/extension/src/utils/types.ts index c406868..41487bc 100644 --- a/packages/extension/src/utils/types.ts +++ b/packages/extension/src/utils/types.ts @@ -88,6 +88,9 @@ export interface RecentScan { address: string; risk: string; timestamp: number; + threats?: string[]; + url?: string; + blocked?: boolean; } export type WarningContext = diff --git a/packages/extension/store-assets/testudo-extension.zip b/packages/extension/store-assets/testudo-extension.zip index 8f6b213..f9a0571 100644 Binary files a/packages/extension/store-assets/testudo-extension.zip and b/packages/extension/store-assets/testudo-extension.zip differ