Skill: shadoprizm/cyberlens (version 1.2.1)

--- name: cyberlens description: Scan websites, GitHub repositories, and Claw Hub skills for practical security issues using a local quick website scan and CyberLens cloud analysis when connected. metadata: {"openclaw": {"requires": {"bins": ["python3"]}, "primaryEnv": "CYBERLENS_API_KEY", "emoji": "\ud83d\udd12", "homepage": "https://cyberlensai.com"}} --- # CyberLens Security Scanner Scan websites, GitHub repositories, and Claw Hub skills for practical security issues before you ship, install, or trust them. CyberLens can audit live web targets for missing headers, HTTPS weaknesses, exposed technologies, and insecure forms. It can scan GitHub repositories for dependency vulnerabilities, secret leaks, and malicious code. And it can scan Claw Hub skills directly from their Claw Hub URL — just paste a link like `https://clawhub.ai/author/skill-name` and CyberLens will analyse the skill package before you install it. Results include a 0-100 security score, letter grade (A-F), AI-powered analysis, and plain-English remediation advice. Reports can be shared as formatted markdown in chat or exported as a PDF. ## Prerequisites - **Python 3.9+**: Required for script execution - **Python Packages**: ```bash pip install -r requirements.txt ``` ## First-Time Setup Website quick scans and Claw Hub skill package scans work without an account. Connect a CyberLens account when the user wants the full cloud website scan or any repository scan. Run the `connect_account` tool. This opens a browser to cyberlensai.com where they sign in or create an account. A short-lived connect code is delivered through the callback, the skill exchanges that code for the real account key over HTTPS on official CyberLens hosts, and the key is stored at `~/.openclaw/skills/cyberlens/config.yaml`. Browser authentication uses `https://cyberlensai.com/connect`. The hosted scan API runs at `https://api.cyberlensai.com/functions/v1/public-api-scan`. If the user needs to override the scan API endpoint explicitly, set `CYBERLENS_API_BASE_URL`. If the user doesn't have a CyberLens account, direct them to https://cyberlensai.com to sign up. Free tier includes 5 scans/month (3 website + 2 repository). Repository scanning requires a connected account. If the user exhausts quota, the skill opens the pricing page and returns an upgrade URL in the tool result. ## Tools ### connect_account Connect or reconnect a CyberLens account. Opens the browser to cyberlensai.com/connect, waits for authentication, validates the returned exchange host against official CyberLens infrastructure, and stores the API key locally. ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import connect_account result = asyncio.run(connect_account()) print(result) " ``` Use this when: - The user wants to connect their CyberLens account - The user gets an authentication error during scanning - The user wants to switch to a different account If OpenClaw is running on another machine, set `CYBERLENS_CONNECT_CALLBACK_URL` to a browser-reachable callback URL before running `connect_account`. Use `CYBERLENS_CONNECT_BIND_HOST` and `CYBERLENS_CONNECT_BIND_PORT` when a reverse proxy or different local bind address is involved. If no callback path is available, the user can still set `CYBERLENS_API_KEY` manually. If the user prefers not to persist the key on disk, they can keep `CYBERLENS_API_KEY` only in the process environment. ### scan_target Scan a live website, GitHub repository, or Claw Hub skill URL. CyberLens auto-detects the target type. Website scans use the local quick engine without an account and the full cloud path when connected. Repository scans require the CyberLens cloud API. Parameters: - `target` (required): Website URL, GitHub repository URL, or Claw Hub skill URL - `scan_depth`: requested depth: "quick", "standard" (default), or "deep". Local website mode always uses the quick scan and warns when a deeper mode was requested. - `timeout`: Request timeout in seconds (default: 30) - `use_cloud`: Force cloud (true) or local (false) scanning. Repository and skill scans require cloud. ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import scan_target result = asyncio.run(scan_target('https://clawhub.ai/anthropic/tavily-search')) print(result) " ``` ### scan_skill Scan a Claw Hub skill before installing it. Accepts a Claw Hub URL (e.g. `https://clawhub.ai/author/skill-name`) or a direct skill download URL. The skill package is downloaded and analysed locally for security vulnerabilities, malicious code, dependency issues, secret leaks, and trust posture problems. Use this when: - A user wants to check a Claw Hub skill before running `clawhub install` - A user pastes a Claw Hub link and asks whether the skill is safe - You want to verify a skill's security posture before recommending installation Parameters: - `skill_url` (required): Claw Hub skill URL or direct skill download URL - `timeout`: Request timeout in seconds (default: 60) ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import scan_skill result = asyncio.run(scan_skill('https://clawhub.ai/anthropic/tavily-search')) print(result) " ``` Returns: security_score, grade (A-F), local assessment, and detailed findings with file paths and recommendations. ### scan_website Scan a website URL for security vulnerabilities. Uses the local quick scan without an account and the CyberLens cloud API when connected (70+ checks). For repository or Claw Hub URLs, use `scan_target`, `scan_skill`, or `scan_repository`. Parameters: - `url` (required): The website URL to scan (must include https:// or http://) - `scan_depth`: requested depth: "quick", "standard" (default), or "deep". Local website mode always uses the quick scan and warns when a deeper mode was requested. - `timeout`: Request timeout in seconds (default: 30) - `use_cloud`: Force cloud (true) or local (false) scanning. Auto-detects by default. ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import scan_website result = asyncio.run(scan_website('https://example.com')) print(result) " ``` Returns: score (0-100), grade (A-F), findings with severity/description/remediation, and scan source (cloud or local). ### scan_repository Scan a GitHub repository URL, including OpenClaw skills before installation. Repository scans use the CyberLens cloud API and return repository findings, dependency alerts, trust posture findings, and security/trust scores. Parameters: - `repository_url` (required): GitHub repository URL such as `https://github.com/owner/repo` - `timeout`: Request timeout in seconds (default: 60) - `use_cloud`: Force cloud behaviour. Repository scans require cloud access. ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import scan_repository result = asyncio.run(scan_repository('https://github.com/shadoprizm/cyberlens-skill')) print(result) " ``` ### get_security_score Quick security score check. Faster than a full scan when you only need the grade. Works with website URLs, GitHub repositories, and Claw Hub skill URLs. Parameters: - `url` (required): The URL to check (website, GitHub repo, or Claw Hub skill) - `timeout`: Request timeout in seconds (default: 30) ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import get_security_score result = asyncio.run(get_security_score('https://clawhub.ai/anthropic/tavily-search')) print(result) " ``` ### generate_report Generate a formatted markdown report from any scan result. Takes the output of `scan_target`, `scan_skill`, `scan_website`, or `scan_repository` and produces a clean markdown document suitable for sharing via messaging (Telegram, Discord, Signal), the web UI, or any channel that renders markdown. Use this when: - The user asks to see a report or summary of the scan - The user wants to share results in a chat or messaging platform - You want to present findings in a readable format Parameters: - `scan_result` (required): The dictionary returned by any CyberLens scan tool ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import scan_skill, generate_report result = asyncio.run(scan_skill('https://clawhub.ai/anthropic/tavily-search')) report = generate_report(result) print(report['report']) " ``` Returns: formatted markdown string in the `report` field, plus metadata (url, score, grade). ### export_report_pdf Export scan results as a professionally formatted PDF file. Takes the output of any CyberLens scan tool and writes a PDF with colour-coded severity indicators, score cards, AI analysis, and detailed findings. Requires the `reportlab` package. Use this when: - The user asks for a PDF, downloadable report, or file export - The user wants to save or archive the scan results - The user wants a report they can attach to an email or share as a file Parameters: - `scan_result` (required): The dictionary returned by any CyberLens scan tool - `output_path` (optional): Where to save the PDF. Defaults to `~/cyberlens-report-<timestamp>.pdf` ```bash python3 -c " import asyncio from cyberlens_skill.src.tools import scan_skill, export_report_pdf result = asyncio.run(scan_skill('https://clawhub.ai/anthropic/tavily-search')) pdf = export_report_pdf(result) print(pdf['path']) " ``` Returns: `path` (absolute path to the generated PDF), plus metadata. After scanning, ask the user how they would like to receive the results: 1. **In chat** — use `generate_report` to render a markdown report directly in the conversation 2. **As a PDF** — use `export_report_pdf` to save a PDF file they can download, email, or archive ### explain_finding Get a plain-English explanation of a security finding type. Parameters: - `finding_type` (required): e.g., "missing-csp", "no-https", "missing-hsts" - `context` (optional): Where the finding was detected Known finding types: missing-csp, missing-hsts, missing-x-frame-options, missing-x-content-type-options, missing-referrer-policy, missing-permissions-policy, no-https, information-disclosure, server-version-exposed, insecure-form-action, missing-csrf-protection ### list_scan_rules List all available scan rules organized by category (headers, https, disclosure, forms, repository). ## Account Tiers | Tier | Scans/Month | |------|-------------| | Free | 5 (3 website + 2 repo) | | Starter | 10 | | Advanced | 40 | | Premium | 100 | | Agency | Custom | ## Notes - Cloud scanning is more thorough than local (70+ checks vs ~15 local checks) - The local website engine is always a quick scan, even if the caller requested "standard" or "deep" - Repository scanning requires a connected CyberLens account - Claw Hub URLs (clawhub.ai, claw-hub.net, openclaw-hub.org) are automatically detected - If the API key is invalid or expired, suggest running `connect_account` again - Scan results from the cloud match exactly what cyberlensai.com shows - Local fallback scanning works for website targets without an account and after website quota exhaustion, but it has fewer checks - After any scan completes, ask the user whether they want results in chat (markdown) or as a PDF file - PDF export requires the `reportlab` package — install with `pip install reportlab`

path: CONTRIBUTING.md
content: # Contributing

## Development Setup

```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
```

## Running Checks

```bash
python3 -m pytest -q
```

## Guidelines

- Keep changes focused and document user-visible behavior changes in `README.md` when needed.
- Add or update tests when changing scanner logic, auth flow behavior, or tool output shape.
- Prefer dependency bumps in `requirements.txt` only when they are justified by compatibility or security.
- Update `requirements.lock` when direct dependency pins change.

## Security

Follow [SECURITY.md](SECURITY.md) for vulnerability reporting. Do not file sensitive issues publicly.


path: README.md
content: ---
name: cyberlens-security-scanner
description: Scan websites, GitHub repositories, and Claw Hub skills for security vulnerabilities before you ship, install, or trust them. Supports live web targets (headers, HTTPS, exposed tech, insecure forms), GitHub repos (dependencies, secrets, malicious code), and Claw Hub skill packages (trust posture, behavioural analysis). Returns a 0-100 security score, letter grade (A-F), AI-powered analysis, and plain-English remediation. Export results as formatted markdown or PDF. Use when the user wants to audit a URL, check a skill before installing, scan a repo for vulnerabilities, or generate a security report.
---

# CyberLens Security Scanner

[![Open CLAW](https://img.shields.io/badge/Open%20CLAW-Native%20Skill-blue)](https://openclaw.ai)
[![Claw Hub](https://img.shields.io/badge/Claw%20Hub-Available-brightgreen)](https://clawhub.ai)
[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
[![Python](https://img.shields.io/badge/Python-3.9%2B-3776AB)](https://www.python.org/downloads/)
[![CyberLens](https://img.shields.io/badge/CyberLens-Powered-orange)](https://cyberlensai.com)

Stop guessing whether that website, repo, or skill is safe. CyberLens gives you a security score, a letter grade, and clear remediation steps in seconds.

## What You Get

**Website scanning** has two honest modes: a **local quick scan** without an account and a **full cloud scan** when connected. The local quick scan covers ~15 core checks; the cloud scan runs 70+ checks with richer analysis.

**Repository scanning** analyses GitHub repos for dependency vulnerabilities, leaked secrets, malicious code patterns, and trust posture issues.

**Claw Hub skill scanning** downloads the skill package, extracts it, and analyses the actual source code for dangerous patterns, hardcoded secrets, permission issues, and dependency risks. Paste a Claw Hub URL or a direct download link and CyberLens handles the rest. No account required for skill scanning.

Every scan returns a **0-100 security score**, an **A-F letter grade**, and **plain-English remediation advice**. Results can be viewed as formatted markdown in chat or exported as a professional PDF.

## Quick Start

```
clawhub install cyberlens-security-scanner
```

### 1. Scan something

Just tell your agent what to scan:

- "Check this skill before I install it: https://clawhub.ai/author/skill-name"
- "Scan https://example.com for security issues"
- "Audit https://github.com/owner/repo for vulnerabilities"
- "Give me a quick security score for https://example.com"

CyberLens auto-detects the target type and selects the right scan method. Website quick scans and Claw Hub skill scans work immediately with no account. Repository scans require a connected account.

### 2. Connect your account (for cloud scans)

Without an account, website scans use the local quick scanner and repository scans stay unavailable. Connect your account to upgrade website scans to the full cloud path and unlock repository scanning. The `connect_account` tool opens a browser, completes authentication, and stores your API key locally. Free accounts get 5 cloud scans/month (3 website + 2 repository). No credit card required. If you exhaust website cloud quota, the skill opens the CyberLens pricing page and falls back to the local quick scan automatically.

Don't have an account? Sign up at [cyberlensai.com](https://cyberlensai.com).

### 3. Get your report

After any scan, choose how you want results delivered:

- **In chat** via `generate_report` (formatted markdown)
- **As a PDF** via `export_report_pdf` (colour-coded, professional layout)

---

## Tools

### scan_skill

Scan a Claw Hub skill before installing it. This is the primary tool for vetting OpenClaw skills from the Claw Hub marketplace.

The skill package is downloaded, extracted, and analysed locally -- no CyberLens account required. Accepts Claw Hub URLs (the download link is resolved automatically) or direct download URLs.

**Parameters:**
- `skill_url` (required) -- Claw Hub skill URL (e.g. `https://clawhub.ai/skills/skill-name`) or direct download URL
- `timeout` -- Request timeout in seconds (default: 60)

**Example prompts:**
- "Scan https://clawhub.ai/skills/ontology before I install it"
- "Is this Claw Hub skill safe? https://clawhub.ai/author/skill-name"
- "Check this skill for malicious code before I install it"

**Returns:** Security score, grade (A-F), assessment, and detailed findings with file locations (dangerous code patterns, hardcoded secrets, manifest issues, dependency problems, external URLs).

### scan_target

Scan a live website, GitHub repository, or Claw Hub skill URL. CyberLens auto-detects the target type.

**Parameters:**
- `target` (required) -- Website URL, GitHub repository URL, or Claw Hub skill URL
- `scan_depth` -- Requested depth: `"quick"`, `"standard"` (default), or `"deep"`. Local website mode always uses the quick scan and warns when a deeper mode was requested.
- `timeout` -- Request timeout in seconds (default: 30)
- `use_cloud` -- Force cloud (`true`) or local (`false`). Repository scans require cloud.

**Example prompts:**
- "Scan https://clawhub.ai/author/skill-name for security issues"
- "Scan https://example.com for security issues"
- "Scan https://github.com/shadoprizm/cyberlens-skill before I install it"

### connect_account

Connect or reconnect your CyberLens account for cloud-powered scanning.

**Example prompt:** "Connect my CyberLens account"

### scan_website

Scan a website for security vulnerabilities. Uses the local quick engine without an account and the full cloud API when connected.

**Parameters:**
- `url` (required) -- The URL to scan (must include `https://` or `http://`)
- `scan_depth` -- Requested depth: `"quick"`, `"standard"` (default), or `"deep"`. Local website mode always uses the quick scan and warns when a deeper mode was requested.
- `timeout` -- Request timeout in seconds (default: 30)
- `use_cloud` -- Force cloud (`true`) or local (`false`). Auto-detects by default.

**Example prompts:**
- "Scan https://example.com for security issues"
- "Run the full cloud website scan for https://mysite.com"
- "Check if https://example.com is secure"

**Returns:** Score (0-100), grade (A-F), findings with severity/description/remediation, scan source (cloud or local), and explicit mode metadata showing whether this was a local quick scan or the full cloud scan.

### scan_repository

Scan a GitHub repository for security issues. Requires a connected CyberLens account.

**Parameters:**
- `repository_url` (required) -- GitHub repository URL (e.g. `https://github.com/owner/repo`)
- `timeout` -- Request timeout in seconds (default: 60)
- `use_cloud` -- Force cloud (`true`) or local (`false`). Repository scans require cloud access.

**Example prompts:**
- "Scan https://github.com/shadoprizm/cyberlens-skill for repo vulnerabilities"
- "Audit this OpenClaw skill before I install it"
- "Check my GitHub repo for security issues"

**Returns:** Repository security score, trust score, aggregated findings, and the underlying repository assessment sections from CyberLens cloud analysis.

### generate_report

Generate a formatted markdown report from any scan result. Suitable for sharing in Telegram, Discord, Signal, the web UI, or any channel that renders markdown.

**Parameters:**
- `scan_result` (required) -- The result returned by any CyberLens scan tool

**Example prompts:**
- "Show me the report in chat"
- "Give me a summary of the scan results"
- "Share the findings here"

**Returns:** A clean markdown report with score cards, AI analysis, summary, and severity-sorted findings.

### export_report_pdf

Export scan results as a professionally formatted PDF file with colour-coded severity indicators and full findings detail.

**Parameters:**
- `scan_result` (required) -- The result returned by any CyberLens scan tool
- `output_path` (optional) -- Where to save the PDF. Defaults to `~/cyberlens-report-<timestamp>.pdf`

**Example prompts:**
- "Export that scan as a PDF"
- "Give me a downloadable report"
- "Save the results as a PDF file"

**Returns:** Absolute path to the generated PDF file.

### get_security_score

Quick score check -- faster when you only need the grade. Supports websites, GitHub repositories, and Claw Hub skill URLs.

**Parameters:**
- `url` (required) -- The URL to check
- `timeout` -- Request timeout in seconds (default: 30)

**Example prompt:** "What's the security grade for https://clawhub.ai/author/skill-name?"

### explain_finding

Get a plain-English explanation of a security finding.

**Parameters:**
- `finding_type` (required) -- e.g., `"missing-csp"`, `"no-https"`, `"missing-hsts"`
- `context` (optional) -- Additional context about where the finding was detected

**Known finding types:** `missing-csp`, `missing-hsts`, `missing-x-frame-options`, `missing-x-content-type-options`, `missing-referrer-policy`, `missing-permissions-policy`, `no-https`, `information-disclosure`, `server-version-exposed`, `insecure-form-action`, `missing-csrf-protection`

### list_scan_rules

List all available detection rules organized by category (headers, HTTPS, disclosure, forms, repository).

---

## Cloud vs Local Scanning

| | Local | Cloud |
|---|---|---|
| Checks | ~15 core rules | 70+ rules |
| Account required | No | Yes |
| Results match website | No | Yes |
| Scan history | No | Yes |
| Claw Hub skill scanning | Yes (local analysis) | Yes (cloud analysis) |
| Repository scanning | No | Yes |
| AI-powered analysis | No | Yes |
| PDF report export | Yes (from any result) | Yes (from any result) |

When connected, cloud scanning is used by default for websites and repositories. If website cloud quota is exhausted, the skill opens the CyberLens pricing page and still falls back to a local quick website scan automatically. Claw Hub skill scanning downloads and analyses the skill package locally and does not require a cloud account.

## Account Tiers

| Tier | Website Scans | Repo/Skill Scans | Combined | Price |
|------|:---:|:---:|:---:|-------|
| **Free** | **3** | **2** | **5/month** | **Free** |
| Starter | 20 | 10 | 30/month | $12/mo |
| Advanced | 65 | 35 | 100/month | $25/mo |
| Premium | 175 | 75 | 250/month | $49/mo |
| Agency | 500 | 250 | 750/month | $149/mo |

**Get started free** -- 5 scans/month with no credit card required. Sign up at [cyberlensai.com](https://cyberlensai.com).

---

## Connecting Your Account

The first time you want the full cloud scan, tell your agent to "connect my CyberLens account" (or it will prompt you for cloud-only features). This runs the `connect_account` tool, which:

1. Opens your browser to [cyberlensai.com/connect](https://cyberlensai.com/connect)
2. You sign in or create a free account
3. A local callback server receives a short-lived connect code
4. The skill exchanges that code for your account key over HTTPS
5. The key is stored at `~/.openclaw/skills/cyberlens/config.yaml`

No copy-pasting required. The raw account key never appears in the browser callback URL. The skill only accepts HTTPS exchange URLs on official CyberLens hosts.

If you run out of cloud scans later, CyberLens opens the pricing page automatically and the tool response includes a direct upgrade URL.

You can also set the key via environment variable: `CYBERLENS_API_KEY`.

### Remote or Server Installs

If OpenClaw is running on a different machine than your browser, set a browser-reachable callback URL before running `connect_account`:

```bash
# Direct LAN or VPN callback
export CYBERLENS_CONNECT_CALLBACK_URL="http://10.0.0.5:54321/callback"

# Hosted HTTPS callback behind a reverse proxy
export CYBERLENS_CONNECT_CALLBACK_URL="https://openclaw.example.com/cyberlens/callback"
export CYBERLENS_CONNECT_BIND_HOST="127.0.0.1"
export CYBERLENS_CONNECT_BIND_PORT="54321"
```

If you do not want to expose a callback at all, set `CYBERLENS_API_KEY` manually instead.

---

## Installation

### Via Claw Hub

```
clawhub install cyberlens-security-scanner
```

### Manual

Copy or symlink into your OpenClaw skills folder:

```bash
# Symlink (recommended for development)
ln -s /path/to/cyberlens-skill ~/.openclaw/skills/cyberlens

# Or copy
cp -r /path/to/cyberlens-skill ~/.openclaw/skills/cyberlens
```

Install Python dependencies:

```bash
pip install -r requirements.txt
```

For reproducible installs with pinned versions:

```bash
pip install -r requirements.lock
```

OpenClaw auto-discovers the skill from the `SKILL.md` file on the next session.

### Prerequisites

- Python 3.9+
- PDF export requires `reportlab` (included in requirements.txt)

---

## Project Structure

```
cyberlens-skill/
  SKILL.md              # OpenClaw skill manifest (YAML frontmatter + instructions)
  skill.yaml            # Skill metadata and config schema
  requirements.txt      # Python dependencies
  requirements.lock     # Pinned direct dependencies for reproducible installs
  SECURITY.md           # Vulnerability reporting policy
  CONTRIBUTING.md       # Contribution workflow and expectations
  LICENSE               # Apache 2.0 license
  src/
    tools.py            # Tool implementations
    skill_scanner.py    # Local skill analyser (download, extract, scan, cleanup)
    scanner.py          # Local SecurityScanner (async, httpx + BeautifulSoup)
    api_client.py       # CyberLens cloud API client (async, exponential backoff)
    auth.py             # Browser-based connect flow with secure code exchange
    models.py           # Pydantic data models
```

## Dependencies

- `httpx` -- Async HTTP client
- `beautifulsoup4` -- HTML parsing (for local scanning)
- `pydantic` -- Data validation
- `pyyaml` -- Config file handling
- `reportlab` -- PDF report generation

## Security

Please review [SECURITY.md](SECURITY.md) before reporting vulnerabilities. Sensitive reports should not be filed as public issues.

## Related

| Repository | Description |
|------------|-------------|
| [cyberlens-mcp-server](https://github.com/shadoprizm/cyberlens-mcp-server) | MCP server for Claude Desktop, VS Code, and other AI assistants |
| [cyberlens-extension](https://github.com/shadoprizm/cyberlens-extension) | Chrome extension |
| [CyberLens](https://cyberlensai.com) | Full platform with browser-based scanning and dashboards |

## License

Apache 2.0 -- see [LICENSE](LICENSE).

---

Part of the [CyberLens](https://cyberlensai.com) open-source security platform.


path: SECURITY.md
content: # Security Policy

## Supported Versions

Security updates are applied to:

- The latest commit on `master`
- The most recent published release of this skill

Older snapshots may not receive fixes.

## Reporting a Vulnerability

Do not open public GitHub issues for suspected vulnerabilities.

Preferred reporting path:

1. Use GitHub's private vulnerability reporting for this repository if it is available.
2. If private reporting is not enabled, contact the maintainer privately through the repository owner profile before disclosing details publicly.

Include:

- Affected version or commit
- Clear reproduction steps
- Expected impact
- Any proof-of-concept or logs needed to validate the issue

## Disclosure Expectations

Please allow time for triage and remediation before public disclosure. Reports will be reviewed, validated, and prioritized based on severity and exploitability.


path: examples/basic_scan.py
content: """Basic scan example - Single URL security scan."""

import asyncio
from src.scanner import SecurityScanner


async def main():
    """Run a basic security scan."""
    url = "https://example.com"
    
    print(f"🔍 Scanning {url}...\n")
    
    async with SecurityScanner() as scanner:
        result = await scanner.scan(url)
        
        if result.error:
            print(f"❌ Scan failed: {result.error}")
            return
        
        # Print summary
        print(f"✅ Scan complete in {result.scan_time_ms}ms")
        print(f"📊 Security Score: {result.score}/100 (Grade: {result.grade})")
        
        # Print technologies
        if result.technologies:
            print(f"🔧 Detected: {', '.join(result.technologies)}")
        
        # Print findings
        if result.findings:
            print(f"\n⚠️  Found {len(result.findings)} issue(s):\n")
            
            # Group by severity
            by_severity = {}
            for finding in result.findings:
                sev = finding.severity
                by_severity.setdefault(sev, []).append(finding)
            
            for severity in ["critical", "high", "medium", "low", "info"]:
                if severity in by_severity:
                    print(f"  {severity.upper()}:")
                    for finding in by_severity[severity]:
                        print(f"    • {finding.description}")
                        print(f"      Fix: {finding.remediation}")
        else:
            print("\n✨ No security issues found!")


if __name__ == "__main__":
    asyncio.run(main())


path: examples/batch_scan.py
content: """Batch scan example - Scan multiple URLs and generate a report."""

import asyncio
from typing import List
from dataclasses import dataclass
from src.scanner import SecurityScanner, ScanResult


@dataclass
class SiteResult:
    """Result for a single site in batch scan."""
    url: str
    score: int
    grade: str
    issues: int
    error: str = None


async def scan_single(url: str, scanner: SecurityScanner) -> SiteResult:
    """Scan a single URL and return simplified result."""
    result = await scanner.scan(url)
    
    if result.error:
        return SiteResult(url=url, score=0, grade="F", issues=0, error=result.error)
    
    return SiteResult(
        url=result.url,
        score=result.score,
        grade=result.grade,
        issues=len(result.findings),
    )


async def batch_scan(urls: List[str], max_concurrent: int = 3) -> List[SiteResult]:
    """Scan multiple URLs with concurrency limit."""
    semaphore = asyncio.Semaphore(max_concurrent)
    
    async def scan_with_limit(url: str, scanner: SecurityScanner) -> SiteResult:
        async with semaphore:
            return await scan_single(url, scanner)
    
    async with SecurityScanner() as scanner:
        tasks = [scan_with_limit(url, scanner) for url in urls]
        results = await asyncio.gather(*tasks)
    
    return results


def print_report(results: List[SiteResult]):
    """Print a formatted report of batch scan results."""
    print("\n" + "=" * 70)
    print("📊 BATCH SCAN REPORT")
    print("=" * 70)
    
    # Summary stats
    total = len(results)
    successful = sum(1 for r in results if r.error is None)
    failed = total - successful
    avg_score = sum(r.score for r in results if r.error is None) / successful if successful > 0 else 0
    
    print(f"\n📈 Summary:")
    print(f"   Total sites: {total}")
    print(f"   Successful:  {successful}")
    print(f"   Failed:      {failed}")
    print(f"   Avg score:   {avg_score:.1f}/100")
    
    # Grade distribution
    grades = {}
    for r in results:
        if r.error is None:
            grades[r.grade] = grades.get(r.grade, 0) + 1
    
    print(f"\n📋 Grade Distribution:")
    for grade in ["A", "B", "C", "D", "F"]:
        count = grades.get(grade, 0)
        bar = "█" * count + "░" * (successful - count if successful > 0 else 0)
        print(f"   {grade}: {bar} ({count})")
    
    # Detailed results
    print(f"\n📑 Results:")
    print("-" * 70)
    
    # Sort by score (best first)
    sorted_results = sorted(results, key=lambda r: (-r.score, r.url))
    
    for result in sorted_results:
        status = "✅" if result.error is None else "❌"
        if result.error:
            print(f"{status} {result.url[:50]:<50} ERROR: {result.error[:20]}")
        else:
            grade_emoji = {"A": "🟢", "B": "🟢", "C": "🟡", "D": "🟠", "F": "🔴"}[result.grade]
            print(f"{status} {result.url[:50]:<50} {grade_emoji} {result.grade} ({result.score}) {result.issues} issues")
    
    print("=" * 70)


async def main():
    """Run batch scan on multiple URLs."""
    urls = [
        "https://google.com",
        "https://github.com",
        "https://stackoverflow.com",
        "https://example.com",
        "https://httpbin.org/get",
    ]
    
    print(f"🚀 Starting batch scan of {len(urls)} URLs...")
    print(f"   (Max concurrent: 3)\n")
    
    results = await batch_scan(urls, max_concurrent=3)
    print_report(results)
    
    # Save to JSON
    import json
    report_data = [
        {
            "url": r.url,
            "score": r.score,
            "grade": r.grade,
            "issues": r.issues,
            "error": r.error,
        }
        for r in results
    ]
    
    with open("batch_scan_report.json", "w") as f:
        json.dump(report_data, f, indent=2)
    
    print("\n💾 Report saved to batch_scan_report.json")


if __name__ == "__main__":
    asyncio.run(main())


path: examples/custom_rules.py
content: """Custom rules example - Adding your own scan rules."""

import asyncio
from typing import List, Dict
from src.scanner import SecurityScanner, Finding


class CustomSecurityScanner(SecurityScanner):
    """Extended scanner with custom rules."""
    
    def check_custom_header(self, headers: Dict[str, str]) -> List[Finding]:
        """Check for custom application header."""
        findings = []
        headers_lower = {k.lower(): v for k, v in headers.items()}
        
        if "x-custom-app" in headers_lower:
            findings.append(Finding(
                type="x-custom-header-present",
                severity="info",
                description=f"Custom header detected: {headers_lower['x-custom-app']}",
                remediation="Custom headers are fine for debugging but may leak info in production",
            ))
        
        return findings
    
    def check_deprecated_server(self, headers: Dict[str, str]) -> List[Finding]:
        """Check for deprecated server versions."""
        findings = []
        headers_lower = {k.lower(): v for k, v in headers.items()}
        
        if "server" not in headers_lower:
            return findings
        
        server = headers_lower["server"].lower()
        
        # Check for known deprecated versions
        deprecated_patterns = [
            ("apache/2.2", "Apache 2.2 reached EOL in 2018"),
            ("nginx/1.8", "Nginx 1.8 is outdated, upgrade recommended"),
            ("nginx/1.6", "Nginx 1.6 is outdated, upgrade recommended"),
            ("iis/6", "IIS 6.0 is extremely outdated and insecure"),
            ("iis/7.0", "IIS 7.0 is outdated, consider upgrading"),
        ]
        
        for pattern, message in deprecated_patterns:
            if pattern in server:
                findings.append(Finding(
                    type="deprecated-server-version",
                    severity="high",
                    description=f"Deprecated server detected: {message}",
                    remediation="Upgrade to the latest stable version of your web server",
                    references=["https://endoflife.software/"],
                ))
                break
        
        return findings
    
    async def scan(self, url: str):
        """Run scan with custom rules."""
        result = await super().scan(url)
        
        if result.error:
            return result
        
        # Add custom findings
        custom_findings = []
        custom_findings.extend(self.check_custom_header(result.headers))
        custom_findings.extend(self.check_deprecated_server(result.headers))
        
        # Add custom findings to result
        result.findings.extend(custom_findings)
        
        # Recalculate score
        result.score, result.grade = self._calculate_score(result.findings)
        
        return result


def print_custom_findings(findings: List[Finding]):
    """Print only custom rule findings."""
    custom_types = [
        "x-custom-header-present",
        "deprecated-server-version",
    ]
    
    custom = [f for f in findings if f.type in custom_types]
    
    if custom:
        print("\n🔧 Custom Rule Findings:")
        for finding in custom:
            print(f"  [{finding.severity.upper()}] {finding.type}")
            print(f"    {finding.description}")
            print(f"    → {finding.remediation}")
    else:
        print("\n✅ No custom rule findings")


async def main():
    """Demonstrate custom rules."""
    url = "https://httpbin.org/get"
    
    print(f"🔍 Scanning with custom rules: {url}\n")
    
    async with CustomSecurityScanner() as scanner:
        result = await scanner.scan(url)
        
        if result.error:
            print(f"❌ Error: {result.error}")
            return
        
        print(f"Score: {result.score}/100 (Grade: {result.grade})")
        print(f"Total findings: {len(result.findings)}")
        
        # Show custom findings
        print_custom_findings(result.findings)
        
        # Show how to add your own rule
        print("\n💡 To add your own rule:")
        print("""
1. Create a subclass of SecurityScanner
2. Add a method that returns List[Finding]
3. Override scan() to call your method
4. Update the score after adding findings

Example:
    def check_my_rule(self, headers, content):
        findings = []
        if "bad-thing" in content:
            findings.append(Finding(
                type="my-custom-rule",
                severity="medium",
                description="Found bad thing",
                remediation="Remove bad thing",
            ))
        return findings
""")


if __name__ == "__main__":
    asyncio.run(main())


path: proposed readme.md
content: ---
name: cyberlens-security-scanner
description: Scan websites, GitHub repositories, and Claw Hub skills for security vulnerabilities before you ship, install, or trust them. Supports live web targets (headers, HTTPS, exposed tech, insecure forms), GitHub repos (dependencies, secrets, malicious code), and Claw Hub skill packages (trust posture, behavioural analysis). Returns a 0-100 security score, letter grade (A-F), AI-powered analysis, and plain-English remediation. Export results as formatted markdown or PDF. Use when the user wants to audit a URL, check a skill before installing, scan a repo for vulnerabilities, or generate a security report.
---

# CyberLens Security Scanner

Stop guessing whether that website, repo, or skill is safe. CyberLens gives you a security score, a letter grade, and clear remediation steps in seconds.

## What You Get

**Website scanning** checks live targets for missing security headers, HTTPS weaknesses, exposed server versions, insecure forms, and more. Cloud scans run 70+ checks; local fallback covers ~15 core checks without an account.

**Repository scanning** analyses GitHub repos for dependency vulnerabilities, leaked secrets, malicious code patterns, and trust posture issues.

**Claw Hub skill scanning** lets you vet any skill before you run `clawhub install`. Paste a Claw Hub URL and CyberLens will analyse the full skill package for security issues, suspicious behaviour, and dependency risks.

Every scan returns a **0-100 security score**, an **A-F letter grade**, **AI-powered analysis**, and **plain-English remediation advice**. Results can be viewed as formatted markdown in chat or exported as a professional PDF.

## Quick Start

```
clawhub install cyberlens-security-scanner
```

### 1. Connect your account

Run the `connect_account` tool to authenticate via cyberlensai.com. This opens a browser, completes OAuth, and stores your API key locally. Free accounts get 5 scans/month (2 website + 3 repository). No account is needed for local-only website scans.

Don't have an account? Sign up at [cyberlensai.com](https://cyberlensai.com).

### 2. Scan something

Just tell your agent what to scan:

- "Scan https://example.com for security issues"
- "Check this skill before I install it: https://clawhub.ai/author/skill-name"
- "Audit https://github.com/owner/repo for vulnerabilities"
- "Give me a quick security score for https://example.com"

CyberLens auto-detects the target type and selects the right scan method.

### 3. Get your report

After any scan, choose how you want results delivered:

- **In chat** via `generate_report` (formatted markdown)
- **As a PDF** via `export_report_pdf` (colour-coded, professional layout)

## Account Tiers

| Tier | Scans/Month |
|------|-------------|
| Free | 5 (2 website + 3 repo) |
| Starter | 10 |
| Advanced | 40 |
| Premium | 100 |
| Agency | Custom |

## Prerequisites

- Python 3.9+
- Python packages: `pip install -r requirements.txt`
- PDF export requires `reportlab` (`pip install reportlab`)

---

## Tools Reference

### connect_account

Connect or reconnect a CyberLens account. Opens the browser to cyberlensai.com/connect, waits for authentication, validates the returned exchange host against official CyberLens infrastructure, and stores the API key locally.

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import connect_account
result = asyncio.run(connect_account())
print(result)
"
```

Use this when:
- The user wants to connect their CyberLens account
- The user gets an authentication error during scanning
- The user wants to switch to a different account

Browser authentication uses https://cyberlensai.com/connect. The hosted scan API runs at https://api.cyberlensai.com/functions/v1/public-api-scan.

**Environment overrides** (optional):
- `CYBERLENS_CONNECT_CALLBACK_URL` — browser-reachable callback URL when OpenClaw runs on another machine
- `CYBERLENS_CONNECT_BIND_HOST` / `CYBERLENS_CONNECT_BIND_PORT` — for reverse proxy or different local bind address
- `CYBERLENS_API_KEY` — set manually if no callback path is available
- `CYBERLENS_API_BASE_URL` — override the scan API endpoint

### scan_target

Universal scanner. Accepts a website URL, GitHub repository URL, or Claw Hub skill URL. CyberLens auto-detects the target type. Website scans can use the local fallback engine. Repository and Claw Hub skill scans require the CyberLens cloud API.

**Parameters:**
- `target` (required): Website URL, GitHub repository URL, or Claw Hub skill URL
- `scan_depth`: "quick", "standard" (default), or "deep"
- `timeout`: Request timeout in seconds (default: 30)
- `use_cloud`: Force cloud (`true`) or local (`false`) scanning. Repository and skill scans require cloud.

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import scan_target
result = asyncio.run(scan_target('https://clawhub.ai/anthropic/tavily-search'))
print(result)
"
```

### scan_skill

Scan a Claw Hub skill before installing it. Accepts a Claw Hub URL (e.g. https://clawhub.ai/author/skill-name) or a GitHub repository URL for an OpenClaw skill. The CyberLens cloud API analyses the skill package for security vulnerabilities, malicious code, dependency issues, secret leaks, and trust posture problems.

Use this when:
- A user wants to check a Claw Hub skill before running `clawhub install`
- A user pastes a Claw Hub link and asks whether the skill is safe
- You want to verify a skill's security posture before recommending installation

**Parameters:**
- `skill_url` (required): Claw Hub skill URL or GitHub repository URL for an OpenClaw skill
- `timeout`: Request timeout in seconds (default: 60)

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import scan_skill
result = asyncio.run(scan_skill('https://clawhub.ai/anthropic/tavily-search'))
print(result)
"
```

Returns: security_score, trust_score, grade (A-F), AI analysis, and detailed findings by category (security, dependencies, trust posture, secrets, malicious code, behavioural, artifacts).

### scan_website

Scan a website URL for security vulnerabilities. Uses the CyberLens cloud API when connected (70+ checks), falls back to local scanning if not. For repository or Claw Hub URLs, use `scan_target`, `scan_skill`, or `scan_repository`.

**Parameters:**
- `url` (required): The website URL to scan (must include `https://` or `http://`)
- `scan_depth`: "quick", "standard" (default), or "deep"
- `timeout`: Request timeout in seconds (default: 30)
- `use_cloud`: Force cloud (`true`) or local (`false`) scanning. Auto-detects by default.

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import scan_website
result = asyncio.run(scan_website('https://example.com'))
print(result)
"
```

Returns: score (0-100), grade (A-F), findings with severity/description/remediation, and scan source (cloud or local).

### scan_repository

Scan a GitHub repository URL, including OpenClaw skills before installation. Repository scans use the CyberLens cloud API and return repository findings, dependency alerts, trust posture findings, and security/trust scores.

**Parameters:**
- `repository_url` (required): GitHub repository URL such as https://github.com/owner/repo
- `timeout`: Request timeout in seconds (default: 60)
- `use_cloud`: Force cloud behaviour. Repository scans require cloud access.

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import scan_repository
result = asyncio.run(scan_repository('https://github.com/shadoprizm/cyberlens-skill'))
print(result)
"
```

### get_security_score

Quick security score check. Faster than a full scan when you only need the grade. Works with website URLs, GitHub repositories, and Claw Hub skill URLs.

**Parameters:**
- `url` (required): The URL to check (website, GitHub repo, or Claw Hub skill)
- `timeout`: Request timeout in seconds (default: 30)

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import get_security_score
result = asyncio.run(get_security_score('https://clawhub.ai/anthropic/tavily-search'))
print(result)
"
```

### generate_report

Generate a formatted markdown report from any scan result. Takes the output of any CyberLens scan tool and produces a clean markdown document suitable for sharing via messaging (Telegram, Discord, Signal), the web UI, or any channel that renders markdown.

Use this when:
- The user asks to see a report or summary of the scan
- The user wants to share results in a chat or messaging platform
- You want to present findings in a readable format

**Parameters:**
- `scan_result` (required): The dictionary returned by any CyberLens scan tool

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import scan_skill, generate_report
result = asyncio.run(scan_skill('https://clawhub.ai/anthropic/tavily-search'))
report = generate_report(result)
print(report['report'])
"
```

Returns: formatted markdown string in the `report` field, plus metadata (url, score, grade).

### export_report_pdf

Export scan results as a professionally formatted PDF file. Takes the output of any CyberLens scan tool and writes a PDF with colour-coded severity indicators, score cards, AI analysis, and detailed findings. Requires the `reportlab` package.

Use this when:
- The user asks for a PDF, downloadable report, or file export
- The user wants to save or archive the scan results
- The user wants a report they can attach to an email or share as a file

**Parameters:**
- `scan_result` (required): The dictionary returned by any CyberLens scan tool
- `output_path` (optional): Where to save the PDF. Defaults to `~/cyberlens-report-<timestamp>.pdf`

```python
python3 -c "
import asyncio
from cyberlens_skill.src.tools import scan_skill, export_report_pdf
result = asyncio.run(scan_skill('https://clawhub.ai/anthropic/tavily-search'))
pdf = export_report_pdf(result)
print(pdf['path'])
"
```

Returns: path (absolute path to the generated PDF), plus metadata.

**After scanning, ask the user how they would like to receive the results:**
- In chat — use `generate_report` to render a markdown report directly in the conversation
- As a PDF — use `export_report_pdf` to save a PDF file they can download, email, or archive

### explain_finding

Get a plain-English explanation of a security finding type.

**Parameters:**
- `finding_type` (required): e.g., "missing-csp", "no-https", "missing-hsts"
- `context` (optional): Where the finding was detected

Known finding types: missing-csp, missing-hsts, missing-x-frame-options, missing-x-content-type-options, missing-referrer-policy, missing-permissions-policy, no-https, information-disclosure, server-version-exposed, insecure-form-action, missing-csrf-protection

### list_scan_rules

List all available scan rules organized by category (headers, https, disclosure, forms, repository).

## Notes

- Cloud scanning is more thorough than local (70+ checks vs ~15 local checks)
- Repository, Claw Hub skill, and OpenClaw skill scanning require a connected CyberLens account
- Claw Hub URLs (clawhub.ai, claw-hub.net, openclaw-hub.org) are automatically detected
- If the API key is invalid or expired, suggest running `connect_account` again
- Scan results from the cloud match exactly what cyberlensai.com shows
- Local fallback scanning works for website targets without an account but has fewer checks


path: requirements-dev.txt
content: # Development requirements
pytest>=7.0.0
pytest-asyncio>=0.21.0
pytest-cov>=4.0.0
black>=23.0.0
ruff>=0.1.0
mypy>=1.0.0


path: requirements.txt
content: # CyberLens Skill Requirements
# Install with: pip install -r requirements.txt

# HTTP client for async requests
httpx>=0.25.0

# HTML parsing for content analysis
beautifulsoup4>=4.12.0

# Data validation and serialization
# CVE-2024-3772 is fixed in Pydantic 2.4.0+ for the 2.x line.
pydantic>=2.4.0

# Config file parsing
pyyaml>=6.0

# PDF report generation
reportlab>=4.0.0

# Development dependencies (install with: pip install -r requirements-dev.txt)
# pytest>=7.0.0
# pytest-asyncio>=0.21.0
# black>=23.0.0
# mypy>=1.0.0


path: skill.yaml
content: name: cyberlens
display_name: "CyberLens Security Scanner"
version: "1.2.1"
description: "Scan websites, GitHub repositories, and Claw Hub skills for practical security issues before you ship, install, or trust them."
author: shadoprizm
license: Apache-2.0
tags: [security, github-security, repo-security, website-security, appsec, supply-chain, clawhub, skill-scanning]
capabilities:
  - web_scanning
  - security_analysis
  - skill_scanning
  - report_generation
tools:
  - connect_account
  - scan_target
  - scan_skill
  - scan_website
  - scan_repository
  - get_security_score
  - generate_report
  - export_report_pdf
  - explain_finding
  - list_scan_rules

config:
  api_key:
    type: string
    default: ""
    description: "CyberLens API key for cloud scanning (auto-set by connect_account)"
  api_base_url:
    type: string
    default: "https://api.cyberlensai.com/functions/v1/public-api-scan"
    description: "Official CyberLens cloud scan API endpoint. Browser auth uses cyberlensai.com; scan traffic goes to api.cyberlensai.com."
  timeout_seconds:
    type: integer
    default: 30
    description: "Request timeout for scan operations"
  max_redirects:
    type: integer
    default: 5
    description: "Maximum redirects to follow"
  user_agent:
    type: string
    default: "CyberLens-Skill/1.1.1"
    description: "User agent string for requests"


path: src/__init__.py
content: """CyberLens OpenClaw Skill - Security Scanner for websites and repositories."""

__version__ = "1.1.1"
__author__ = "shadoprizm"

from .scanner import SecurityScanner
from .tools import (
    connect_account,
    scan_target,
    scan_website,
    scan_repository,
    get_security_score,
    explain_finding,
    list_scan_rules,
)
from .api_client import CyberLensAPIClient
from .models import (
    ScanResult,
    SecurityScore,
    Finding,
    FindingExplanation,
    ScanRule,
)

__all__ = [
    "SecurityScanner",
    "CyberLensAPIClient",
    "connect_account",
    "scan_target",
    "scan_website",
    "scan_repository",
    "get_security_score",
    "explain_finding",
    "list_scan_rules",
    "ScanResult",
    "SecurityScore",
    "Finding",
    "FindingExplanation",
    "ScanRule",
]


path: src/api_client.py
content: """CyberLens API client for cloud-powered scanning."""

import asyncio
import os
from typing import Any, Dict, Optional
from urllib.parse import urlparse

import httpx


DEFAULT_API_BASE = "https://api.cyberlensai.com/functions/v1/public-api-scan"


class CyberLensQuotaExceededError(RuntimeError):
    """Raised when the CyberLens public API reports exhausted scan quota."""

    def __init__(
        self,
        message: str,
        *,
        upgrade_url: Optional[str] = None,
        quota_type: Optional[str] = None,
        used: Optional[int] = None,
        limit: Optional[int] = None,
    ) -> None:
        super().__init__(message)
        self.upgrade_url = upgrade_url
        self.quota_type = quota_type
        self.used = used
        self.limit = limit


def _resolve_api_base(api_base: Optional[str] = None) -> str:
    """Resolve and validate the CyberLens API base URL."""
    candidate = (api_base or os.environ.get("CYBERLENS_API_BASE_URL") or DEFAULT_API_BASE).strip()
    parsed = urlparse(candidate)
    if parsed.scheme != "https" or not parsed.netloc:
        raise ValueError("CyberLens API base URL must be a valid https:// URL.")
    return candidate.rstrip("/")


class CyberLensAPIClient:
    """Async client for the CyberLens public scan API."""

    def __init__(self, api_key: str, timeout: float = 120.0, api_base: Optional[str] = None):
        self.[REDACTED_SECRET]
        self.timeout = timeout
        self.api_base = _resolve_api_base(api_base)
        self._client: Optional[httpx.AsyncClient] = None

    async def __aenter__(self):
        self._client = httpx.AsyncClient(
            timeout=self.timeout,
            headers={
                "X-API-Key": self.api_key,
                "Content-Type": "application/json",
            },
        )
        return self

    async def __aexit__(self, *args):
        if self._client:
            await self._client.aclose()

    @staticmethod
    def _parse_error_payload(response: httpx.Response) -> Dict[str, Any]:
        try:
            payload = response.json()
        except ValueError:
            payload = {}
        return payload if isinstance(payload, dict) else {}

    def _read_response_data(self, response: httpx.Response) -> Dict[str, Any]:
        payload = self._parse_error_payload(response)

        if response.status_code >= 400:
            error = payload.get("error")
            if isinstance(error, dict):
                message = error.get("message") or f"CyberLens API error ({response.status_code})"
                if response.status_code == 402 and error.get("code") == "QUOTA_EXCEEDED":
                    raise CyberLensQuotaExceededError(
                        message,
                        upgrade_url=error.get("upgrade_url"),
                        quota_type=error.get("quota_type"),
                        used=error.get("used"),
                        limit=error.get("limit"),
                    )
                raise RuntimeError(message)

            if isinstance(error, str):
                raise RuntimeError(error)

            response.raise_for_status()

        data = payload.get("data")
        if not isinstance(data, dict):
            raise RuntimeError("CyberLens API response did not include a data object.")
        return data

    async def start_scan(self, url: str) -> str:
        """Start a scan and return the scan ID."""
        response = await self._client.post(
            f"{self.api_base}/scan",
            json={"url": url},
        )
        data = self._read_response_data(response)
        return data["scan_id"]

    async def poll_scan(self, scan_id: str) -> Dict[str, Any]:
        """Poll for scan results with exponential backoff."""
        delay = 1.0
        max_delay = 30.0
        elapsed = 0.0

        while elapsed < self.timeout:
            await asyncio.sleep(delay)
            elapsed += delay

            response = await self._client.get(f"{self.api_base}/scan/{scan_id}")
            data = self._read_response_data(response)

            if data["status"] == "completed":
                return data
            if data["status"] == "failed":
                raise RuntimeError("Scan failed on the server.")

            delay = min(delay * 2, max_delay)

        raise TimeoutError("Scan timed out waiting for results.")

    async def scan(self, url: str) -> Dict[str, Any]:
        """Start a scan and wait for results."""
        scan_id = await self.start_scan(url)
        return await self.poll_scan(scan_id)

    async def get_quota(self) -> Dict[str, Any]:
        """Get current usage quota."""
        response = await self._client.get(f"{self.api_base}/quota")
        return self._read_response_data(response)


path: src/auth.py
content: """Browser-based authentication flow for CyberLens account connection."""

import os
import secrets
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlencode, urlparse

import httpx
import yaml


CONNECT_BASE_URL = "https://cyberlensai.com/connect"
PRICING_BASE_URL = "https://www.cyberlensai.com/pricing"
TRUSTED_EXCHANGE_HOSTS = {
    "cyberlensai.com",
    "www.cyberlensai.com",
    "api.cyberlensai.com",
}
CONFIG_DIR = Path.home() / ".openclaw" / "skills" / "cyberlens"
CONFIG_FILE = CONFIG_DIR / "config.yaml"


class _CallbackHandler(BaseHTTPRequestHandler):
    """Handles the connect callback from cyberlensai.com/connect."""

    expected_state: Optional[str] = None
    connect_code: Optional[str] = None
    exchange_url: Optional[str] = None
    callback_error: Optional[str] = None
    received = threading.Event()

    def do_GET(self, *args, **kwargs):
        parsed = urlparse(self.path)
        params = parse_qs(parsed.query)

        state = params.get("state", [None])[0]
        code = params.get("code", [None])[0]
        exchange_url = params.get("exchange", [None])[0]
        error = params.get("error", [None])[0]

        if state != self.expected_state:
            self.send_response(400)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Invalid callback. State mismatch.")
        elif error:
            _CallbackHandler.callback_error = error
            _CallbackHandler.received.set()
            self.send_response(400)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(f"Connection failed: {error}".encode("utf-8"))
        elif code and exchange_url:
            _CallbackHandler.connect_code = code
            _CallbackHandler.exchange_url = exchange_url
            _CallbackHandler.received.set()
            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self.end_headers()
            self.wfile.write(
                b"<html><body style='font-family:system-ui;background:#1a1a2e;color:white;"
                b"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
                b"<div style='text-align:center'>"
                b"<h1>&#x2705; Authorization Received</h1>"
                b"<p>You can close this tab while OpenClaw finishes the secure exchange.</p>"
                b"</div></body></html>"
            )
        else:
            _CallbackHandler.callback_error = "Missing exchange code."
            _CallbackHandler.received.set()
            self.send_response(400)
            self.send_header("Content-Type", "text/plain")
            self.end_headers()
            self.wfile.write(b"Invalid callback. Missing exchange code.")

    def log_message(self, format, *args):
        pass  # Suppress HTTP server logs


def _find_open_port() -> int:
    """Find an available port in the 54321-54399 range."""
    import socket

    for port in range(54321, 54400):
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.bind(("127.0.0.1", port))
                return port
        except OSError:
            continue
    raise RuntimeError("No available ports in range 54321-54399")


def _is_loopback_host(hostname: Optional[str]) -> bool:
    """Return True when the callback host resolves to the local machine."""
    return hostname in {"localhost", "127.0.0.1", "::1"}


def _is_trusted_exchange_host(hostname: Optional[str]) -> bool:
    """Return True when the exchange URL host is an official CyberLens host."""
    if not hostname:
        return False
    return hostname in TRUSTED_EXCHANGE_HOSTS or hostname.endswith(".cyberlensai.com")


def _load_local_config() -> Dict[str, Any]:
    """Load the local skill config file if it exists."""
    if not CONFIG_FILE.exists():
        return {}

    with open(CONFIG_FILE) as f:
        return yaml.safe_load(f) or {}


def _write_local_config(config: Dict[str, Any]) -> Path:
    """Write the local skill config with restrictive permissions when supported."""
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    try:
        CONFIG_DIR.chmod(0o700)
    except OSError:
        pass

    with open(CONFIG_FILE, "w") as f:
        yaml.safe_dump(config, f, default_flow_style=False)

    try:
        CONFIG_FILE.chmod(0o600)
    except OSError:
        pass

    return CONFIG_FILE


def _resolve_callback_config() -> tuple[str, int, str]:
    """Resolve the bind address and callback URL for the connect flow."""
    configured_callback = os.environ.get("CYBERLENS_CONNECT_CALLBACK_URL", "").strip()

    if not configured_callback:
        port = _find_open_port()
        return "127.0.0.1", port, f"http://localhost:{port}/callback"

    parsed = urlparse(configured_callback)
    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        raise ValueError(
            "CYBERLENS_CONNECT_CALLBACK_URL must be a valid http:// or https:// URL."
        )

    callback_path = parsed.path or "/callback"
    callback_url = parsed._replace(path=callback_path).geturl()

    bind_port_value = os.environ.get("CYBERLENS_CONNECT_BIND_PORT", "").strip()
    if bind_port_value:
        bind_port = int(bind_port_value)
    elif parsed.port is not None:
        bind_port = parsed.port
    else:
        raise ValueError(
            "Configured callback URLs must include a port or set "
            "CYBERLENS_CONNECT_BIND_PORT explicitly."
        )

    bind_host = os.environ.get("CYBERLENS_CONNECT_BIND_HOST", "").strip()
    if not bind_host:
        bind_host = "127.0.0.1" if _is_loopback_host(parsed.hostname) else "0.0.0.0"

    return bind_host, bind_port, callback_url


async def _exchange_connect_code(code: str, exchange_url: str) -> str:
    """Redeem a one-time connect code for the real CyberLens account key."""
    parsed = urlparse(exchange_url)
    if parsed.scheme != "https" or not parsed.hostname:
        raise ValueError("Invalid exchange URL returned by CyberLens.")
    if not _is_trusted_exchange_host(parsed.hostname):
        raise ValueError("CyberLens returned an untrusted exchange host.")

    try:
        async with httpx.AsyncClient(timeout=15.0) as client:
            response = await client.post(exchange_url, json={"code": code})
    except httpx.HTTPError as exc:
        raise RuntimeError(f"Failed to exchange connect code: {exc}") from exc

    try:
        payload = response.json()
    except ValueError:
        payload = {}

    if response.status_code == 404:
        raise RuntimeError("CyberLens connect code was not found. Please try again.")
    if response.status_code == 409:
        raise RuntimeError("CyberLens connect code was already used. Please reconnect.")
    if response.status_code == 410:
        raise RuntimeError("CyberLens connect code expired. Please reconnect.")
    if response.status_code >= 400:
        message = payload.get("error") or f"Exchange failed with status {response.status_code}."
        raise RuntimeError(message)

    full_key = payload.get("fullKey")
    if not full_key:
        raise RuntimeError("CyberLens exchange response did not include an API key.")

    return full_key


def save_api_key(key: str) -> Path:
    """Save the API key to the skill config file."""
    config = _load_local_config()
    config["api_key"] = key
    return _write_local_config(config)


def build_upgrade_url(quota_type: str = "combined") -> str:
    """Build the public pricing URL for quota upgrade prompts."""
    query = urlencode({
        "source": "openclaw-skill-quota-exceeded",
        "quota_type": quota_type,
    })
    return f"{PRICING_BASE_URL}?{query}#plans"


def open_upgrade_page(upgrade_url: str) -> None:
    """Open the CyberLens pricing page in the user's browser."""
    print(f"Open CyberLens pricing to upgrade: {upgrade_url}")
    webbrowser.open(upgrade_url)


def load_api_key() -> Optional[str]:
    """Load the API key from env var or config file."""
    # Check env var first
    env_key = os.environ.get("CYBERLENS_API_KEY")
    if env_key:
        return env_key

    # Check config file
    return _load_local_config().get("api_key")


def load_api_base_url() -> Optional[str]:
    """Load a user-configured CyberLens API base URL override."""
    env_base = os.environ.get("CYBERLENS_API_BASE_URL", "").strip()
    if env_base:
        return env_base

    configured_base = _load_local_config().get("api_base_url")
    if isinstance(configured_base, str) and configured_base.strip():
        return configured_base.strip()

    return None


async def run_connect_flow() -> str:
    """
    Run the browser-based connect flow.

    Opens cyberlensai.com/connect in the user's browser, starts a callback
    server, and waits for a one-time connect code to be delivered.

    Returns the API key string.
    """
    state = secrets.token_urlsafe(32)
    bind_host, bind_port, callback_url = _resolve_callback_config()

    # Reset handler state
    _CallbackHandler.expected_state = state
    _CallbackHandler.connect_code = None
    _CallbackHandler.exchange_url = None
    _CallbackHandler.callback_error = None
    _CallbackHandler.received = threading.Event()

    # Start callback server in a thread
    server = HTTPServer((bind_host, bind_port), _CallbackHandler)
    server_thread = threading.Thread(target=server.serve_forever, daemon=True)
    server_thread.start()

    query = urlencode({
        "client": "openclaw_skill",
        "callback": callback_url,
        "state": state,
    })
    connect_url = f"{CONNECT_BASE_URL}?{query}"
    print(f"Complete CyberLens connection in your browser: {connect_url}")
    webbrowser.open(connect_url)

    # Wait for callback (timeout 5 minutes)
    received = _CallbackHandler.received.wait(timeout=300)
    server.shutdown()
    server.server_close()

    if _CallbackHandler.callback_error:
        raise RuntimeError(f"CyberLens connection failed: {_CallbackHandler.callback_error}")

    if not received or not _CallbackHandler.connect_code or not _CallbackHandler.exchange_url:
        raise TimeoutError(
            "Did not receive a CyberLens connect code within 5 minutes. "
            "Please try again or set CYBERLENS_API_KEY manually."
        )

    key = await _exchange_connect_code(
        _CallbackHandler.connect_code,
        _CallbackHandler.exchange_url,
    )

    # Save to config
    save_api_key(key)
    return key


path: src/models.py
content: """Pydantic models for CyberLens skill inputs and outputs."""

from typing import List, Dict, Optional, Any
from pydantic import BaseModel, Field, HttpUrl


class Finding(BaseModel):
    """A security finding from a scan."""
    type: str = Field(..., description="Type of finding")
    severity: str = Field(..., description="Severity level: critical, high, medium, low, info")
    description: str = Field(..., description="Human-readable description")
    remediation: str = Field(..., description="How to fix the issue")
    url: Optional[str] = Field(None, description="URL where finding was detected")
    evidence: Optional[str] = Field(None, description="Evidence supporting the finding")
    references: List[str] = Field(default_factory=list, description="Reference URLs")


class ScanResult(BaseModel):
    """Complete result from a security scan."""
    url: str = Field(..., description="Scanned URL")
    score: int = Field(..., ge=0, le=100, description="Security score 0-100")
    grade: str = Field(..., pattern="^[A-F]$", description="Letter grade A-F")
    findings: List[Finding] = Field(default_factory=list, description="List of findings")
    headers: Dict[str, str] = Field(default_factory=dict, description="Response headers")
    technologies: List[str] = Field(default_factory=list, description="Detected technologies")
    scan_time_ms: int = Field(default=0, description="Scan duration in milliseconds")
    error: Optional[str] = Field(None, description="Error message if scan failed")


class SecurityScore(BaseModel):
    """Quick security score result."""
    url: str = Field(..., description="Scanned URL")
    score: int = Field(..., ge=0, le=100, description="Security score 0-100")
    grade: str = Field(..., pattern="^[A-F]$", description="Letter grade A-F")
    assessment: str = Field(..., description="Human-readable assessment")


class FindingExplanation(BaseModel):
    """Explanation of a security finding."""
    finding_type: str = Field(..., description="Type of finding")
    explanation: str = Field(..., description="Plain-English explanation")
    severity: str = Field(..., description="Severity level")
    remediation: str = Field(..., description="How to fix")
    references: List[str] = Field(default_factory=list, description="Reference URLs")
    context: Optional[str] = Field(None, description="Optional context")


class ScanRule(BaseModel):
    """Definition of a scan rule."""
    name: str = Field(..., description="Rule name")
    category: str = Field(..., description="Rule category")
    severity: str = Field(..., description="Default severity")
    description: str = Field(..., description="What the rule checks for")


class ScanWebsiteInput(BaseModel):
    """Input for scan_website tool."""
    url: str = Field(..., description="The website URL to scan (must include https://)")
    scan_depth: str = Field(default="standard", pattern="^(quick|standard|deep)$")
    timeout: float = Field(default=30.0, gt=0, description="Request timeout in seconds")


class ScanWebsiteOutput(BaseModel):
    """Output from scan_website tool."""
    success: bool
    url: str
    score: Optional[int] = None
    grade: Optional[str] = None
    scan_time_ms: Optional[int] = None
    technologies: Optional[List[str]] = None
    findings_count: Optional[int] = None
    findings: Optional[List[Dict[str, Any]]] = None
    error: Optional[str] = None


class GetSecurityScoreInput(BaseModel):
    """Input for get_security_score tool."""
    url: str = Field(..., description="The website URL to check")
    timeout: float = Field(default=30.0, gt=0)


class GetSecurityScoreOutput(BaseModel):
    """Output from get_security_score tool."""
    success: bool
    url: str
    score: Optional[int] = None
    grade: Optional[str] = None
    assessment: Optional[str] = None
    error: Optional[str] = None


class ExplainFindingInput(BaseModel):
    """Input for explain_finding tool."""
    finding_type: str = Field(..., description="Type of finding to explain")
    context: Optional[str] = Field(None, description="Optional context")


class ExplainFindingOutput(BaseModel):
    """Output from explain_finding tool."""
    success: bool
    finding_type: Optional[str] = None
    explanation: Optional[str] = None
    severity: Optional[str] = None
    remediation: Optional[str] = None
    references: Optional[List[str]] = None
    context: Optional[str] = None
    error: Optional[str] = None
    known_types: Optional[List[str]] = None


class ListScanRulesOutput(BaseModel):
    """Output from list_scan_rules tool."""
    success: bool
    total_rules: int
    categories: Dict[str, Any]


path: src/scanner.py
content: """Core security scanner implementation for CyberLens skill."""

import asyncio
import ssl
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from urllib.parse import urlparse
import time

try:
    import httpx
except ImportError:
    raise ImportError("httpx is required. Install with: pip install httpx")

try:
    from bs4 import BeautifulSoup
except ImportError:
    raise ImportError("beautifulsoup4 is required. Install with: pip install beautifulsoup4")


@dataclass
class Finding:
    """A security finding from a scan."""
    type: str
    severity: str  # critical, high, medium, low, info
    description: str
    remediation: str
    url: Optional[str] = None
    evidence: Optional[str] = None
    references: List[str] = field(default_factory=list)


@dataclass
class ScanResult:
    """Complete result from a security scan."""
    url: str
    score: int  # 0-100
    grade: str  # A-F
    findings: List[Finding]
    headers: Dict[str, str] = field(default_factory=dict)
    technologies: List[str] = field(default_factory=list)
    scan_time_ms: int = 0
    error: Optional[str] = None


class SecurityScanner:
    """Async security scanner for websites."""
    
    # Security headers that should be present
    RECOMMENDED_HEADERS = {
        "content-security-policy": {
            "severity": "medium",
            "description": "Content-Security-Policy header is missing",
            "remediation": "Add a Content-Security-Policy header to prevent XSS and data injection attacks. Example: default-src 'self'",
        },
        "strict-transport-security": {
            "severity": "high",
            "description": "HTTP Strict Transport Security (HSTS) is not enabled",
            "remediation": "Add Strict-Transport-Security: max-age=31536000; includeSubDomains to force HTTPS connections",
        },
        "x-frame-options": {
            "severity": "medium",
            "description": "X-Frame-Options header is missing",
            "remediation": "Add X-Frame-Options: DENY or SAMEORIGIN to prevent clickjacking attacks",
        },
        "x-content-type-options": {
            "severity": "low",
            "description": "X-Content-Type-Options header is missing",
            "remediation": "Add X-Content-Type-Options: nosniff to prevent MIME sniffing",
        },
        "referrer-policy": {
            "severity": "low",
            "description": "Referrer-Policy header is missing",
            "remediation": "Add Referrer-Policy: strict-origin-when-cross-origin to control referrer information",
        },
        "permissions-policy": {
            "severity": "low",
            "description": "Permissions-Policy header is missing",
            "remediation": "Add Permissions-Policy to control browser features like camera, microphone, geolocation",
        },
    }
    
    # Technology signatures
    TECH_SIGNATURES = {
        "server": ["nginx", "apache", "cloudflare", "fastly", "akamai"],
        "x-powered-by": ["php", "asp.net", "express", "django", "rails"],
        "x-generator": ["wordpress", "drupal", "joomla", "next.js", "nuxt"],
    }
    
    def __init__(
        self,
        timeout: float = 30.0,
        max_redirects: int = 5,
        user_agent: str = "CyberLens-Skill/1.1.1",
    ):
        self.timeout = timeout
        self.max_redirects = max_redirects
        self.user_agent = user_agent
        self._client: Optional[httpx.AsyncClient] = None
    
    async def __aenter__(self):
        self._client = httpx.AsyncClient(
            timeout=httpx.Timeout(self.timeout),
            follow_redirects=True,
            max_redirects=self.max_redirects,
            headers={"User-Agent": self.user_agent},
        )
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        if self._client:
            await self._client.aclose()
    
    def _calculate_score(self, findings: List[Finding]) -> tuple[int, str]:
        """Calculate security score and grade from findings."""
        # Start at 100, deduct points for findings
        score = 100
        
        severity_weights = {
            "critical": 25,
            "high": 15,
            "medium": 8,
            "low": 3,
            "info": 0,
        }
        
        for finding in findings:
            score -= severity_weights.get(finding.severity, 0)
        
        # Clamp to 0-100
        score = max(0, min(100, score))
        
        # Assign grade
        if score >= 90:
            grade = "A"
        elif score >= 80:
            grade = "B"
        elif score >= 70:
            grade = "C"
        elif score >= 60:
            grade = "D"
        else:
            grade = "F"
        
        return score, grade
    
    def _check_https(self, url: str, headers: Dict[str, str]) -> List[Finding]:
        """Check HTTPS configuration."""
        findings = []
        parsed = urlparse(url)
        
        if parsed.scheme != "https":
            findings.append(Finding(
                type="no-https",
                severity="critical",
                description=f"Site does not use HTTPS: {url}",
                remediation="Enable HTTPS by installing an SSL/TLS certificate. Use Let's Encrypt for free certificates.",
                references=["https://letsencrypt.org/"],
            ))
        else:
            # Check for mixed content indicator (would need page content)
            pass
        
        return findings
    
    def _check_headers(self, headers: Dict[str, str]) -> List[Finding]:
        """Check security headers."""
        findings = []
        headers_lower = {k.lower(): v for k, v in headers.items()}
        
        for header, config in self.RECOMMENDED_HEADERS.items():
            if header not in headers_lower:
                findings.append(Finding(
                    type=f"missing-{header.replace('-', '-')}",
                    severity=config["severity"],
                    description=config["description"],
                    remediation=config["remediation"],
                    references=["https://securityheaders.com/"],
                ))
            else:
                # Check for weak configurations
                if header == "x-frame-options":
                    value = headers_lower[header].upper()
                    if value not in ["DENY", "SAMEORIGIN"]:
                        findings.append(Finding(
                            type="weak-x-frame-options",
                            severity="medium",
                            description=f"X-Frame-Options has weak value: {value}",
                            remediation="Use DENY (strongest) or SAMEORIGIN instead of ALLOW-FROM or other values",
                        ))
        
        return findings
    
    def _detect_technologies(self, headers: Dict[str, str]) -> List[str]:
        """Detect technologies from headers."""
        technologies = []
        headers_lower = {k.lower(): v for k, v in headers.items()}
        
        for header, signatures in self.TECH_SIGNATURES.items():
            if header in headers_lower:
                value = headers_lower[header].lower()
                for sig in signatures:
                    if sig in value:
                        technologies.append(sig)
        
        # Check Server header
        if "server" in headers_lower:
            server = headers_lower["server"]
            if "/" in server:
                technologies.append(server.split("/")[0])
            else:
                technologies.append(server.split()[0])
        
        return list(set(technologies))
    
    def _check_information_disclosure(self, headers: Dict[str, str]) -> List[Finding]:
        """Check for information disclosure in headers."""
        findings = []
        headers_lower = {k.lower(): v for k, v in headers.items()}
        
        # Check X-Powered-By
        if "x-powered-by" in headers_lower:
            findings.append(Finding(
                type="information-disclosure",
                severity="low",
                description=f"X-Powered-By reveals technology: {headers_lower['x-powered-by']}",
                remediation="Remove the X-Powered-By header from server configuration to hide implementation details",
            ))
        
        # Check Server header for version info
        if "server" in headers_lower:
            server = headers_lower["server"]
            if "/" in server and any(c.isdigit() for c in server):
                findings.append(Finding(
                    type="server-version-exposed",
                    severity="low",
                    description=f"Server version information exposed: {server}",
                    remediation="Configure server to hide version numbers. In nginx: server_tokens off; in Apache: ServerTokens Prod",
                ))
        
        return findings
    
    async def _analyze_page(self, url: str, response: httpx.Response) -> List[Finding]:
        """Analyze page content for security issues."""
        findings = []
        
        try:
            soup = BeautifulSoup(response.text, "html.parser")
            
            # Check forms
            forms = soup.find_all("form")
            for form in forms:
                action = form.get("action", "")
                method = form.get("method", "get").lower()
                
                # Check for insecure form action
                if action.startswith("http://"):
                    findings.append(Finding(
                        type="insecure-form-action",
                        severity="high",
                        description=f"Form submits to HTTP endpoint: {action}",
                        remediation="Update form action to use HTTPS",
                    ))
                
                # Check for missing CSRF token on POST forms
                if method == "post":
                    csrf_inputs = form.find_all("input", {"name": ["csrf", "csrf_token", "_token", "authenticity_token"]})
                    if not csrf_inputs:
                        findings.append(Finding(
                            type="missing-csrf-protection",
                            severity="medium",
                            description="POST form without CSRF token detected",
                            remediation="Add CSRF tokens to all state-changing forms",
                        ))
            
            # Check for inline scripts (potential XSS indicator)
            inline_scripts = soup.find_all("script", src=False)
            if len(inline_scripts) > 5:
                findings.append(Finding(
                    type="many-inline-scripts",
                    severity="info",
                    description=f"Found {len(inline_scripts)} inline scripts",
                    remediation="Consider moving scripts to external files and using CSP for better security",
                ))
        
        except Exception:
            # If parsing fails, skip content analysis
            pass
        
        return findings
    
    async def scan(self, url: str) -> ScanResult:
        """Perform a full security scan on a URL."""
        start_time = time.time()
        
        if not self._client:
            raise RuntimeError("Scanner must be used as async context manager")
        
        # Validate URL
        if not url.startswith(("http://", "https://")):
            return ScanResult(
                url=url,
                score=0,
                grade="F",
                findings=[],
                error="URL must start with http:// or https://",
                scan_time_ms=0,
            )
        
        try:
            # Make request
            response = await self._client.get(url)
            headers = dict(response.headers)
            
            # Collect all findings
            all_findings: List[Finding] = []
            
            # HTTPS check
            all_findings.extend(self._check_https(str(response.url), headers))
            
            # Header checks
            all_findings.extend(self._check_headers(headers))
            
            # Information disclosure
            all_findings.extend(self._check_information_disclosure(headers))
            
            # Page content analysis
            all_findings.extend(await self._analyze_page(str(response.url), response))
            
            # Detect technologies
            technologies = self._detect_technologies(headers)
            
            # Calculate score
            score, grade = self._calculate_score(all_findings)
            
            scan_time_ms = int((time.time() - start_time) * 1000)
            
            return ScanResult(
                url=str(response.url),
                score=score,
                grade=grade,
                findings=all_findings,
                headers=headers,
                technologies=technologies,
                scan_time_ms=scan_time_ms,
            )
        
        except httpx.TimeoutException:
            return ScanResult(
                url=url,
                score=0,
                grade="F",
                findings=[],
                error=f"Request timed out after {self.timeout}s",
                scan_time_ms=int((time.time() - start_time) * 1000),
            )
        
        except httpx.ConnectError as e:
            return ScanResult(
                url=url,
                score=0,
                grade="F",
                findings=[],
                error=f"Could not connect to {url}: {str(e)}",
                scan_time_ms=int((time.time() - start_time) * 1000),
            )
        
        except Exception as e:
            return ScanResult(
                url=url,
                score=0,
                grade="F",
                findings=[],
                error=f"Scan failed: {str(e)}",
                scan_time_ms=int((time.time() - start_time) * 1000),
            )
    
    async def get_score(self, url: str) -> tuple[int, str]:
        """Get just the security score (faster than full scan)."""
        result = await self.scan(url)
        return result.score, result.grade


path: src/skill_scanner.py
content: """Local CLAW skill scanner. Downloads, extracts, analyses, and cleans up."""

import json
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse

import httpx


CLAWHUB_HOSTS = {
    "clawhub.ai",
    "www.clawhub.ai",
    "claw-hub.net",
    "www.claw-hub.net",
    "openclaw-hub.org",
    "www.openclaw-hub.org",
}

# Dangerous code patterns
_CODE_PATTERNS: List[Dict[str, Any]] = [
    # Critical
    {
        "pattern": re.compile(r"eval\s*\("),
        "severity": "critical",
        "title": "eval() usage detected",
        "description": "eval() executes arbitrary code and is a common injection vector.",
        "recommendation": "Replace eval() with safer alternatives like JSON parsing or AST-based evaluation.",
    },
    {
        "pattern": re.compile(r"exec\s*\(\s*[\"'`]|os\.system\s*\(|subprocess\.(call|run|Popen)\s*\("),
        "severity": "critical",
        "title": "Shell command execution",
        "description": "Direct shell command execution can lead to command injection.",
        "recommendation": "Use parameterised commands and avoid passing user input to shell execution.",
    },
    {
        "pattern": re.compile(r"child_process"),
        "severity": "critical",
        "title": "child_process module imported",
        "description": "child_process allows arbitrary command execution on the host system.",
        "recommendation": "Avoid child_process unless absolutely necessary. Document why it is needed.",
    },
    # High
    {
        "pattern": re.compile(
            r"(?:api[_-]?key|secret[_-]?key|password|token|auth)\s*[:=]\s*[\"'][A-Za-z0-9+/=_-]{16,}[\"']",
            re.IGNORECASE,
        ),
        "severity": "high",
        "title": "Possible hardcoded secret",
        "description": "A string resembling an API key, token, or password is hardcoded in the source.",
        "recommendation": "Move secrets to environment variables or a secure vault. Never commit credentials.",
    },
    {
        "pattern": re.compile(r"fetch\s*\(\s*[\"']http://"),
        "severity": "high",
        "title": "Insecure HTTP request",
        "description": "Plaintext HTTP requests expose data in transit to interception.",
        "recommendation": "Use HTTPS for all external requests.",
    },
    {
        "pattern": re.compile(r"requests\.get\s*\(\s*[\"']http://|httpx\.(get|post)\s*\(\s*[\"']http://"),
        "severity": "high",
        "title": "Insecure HTTP request (Python)",
        "description": "Plaintext HTTP requests expose data in transit to interception.",
        "recommendation": "Use HTTPS for all external requests.",
    },
    {
        "pattern": re.compile(r"fs\.unlink|fs\.rmdir|fs\.rm\b|os\.remove|os\.unlink|shutil\.rmtree"),
        "severity": "high",
        "title": "File deletion operation",
        "description": "Skill performs file deletion which could damage user data.",
        "recommendation": "Ensure file deletion is scoped to the skill's own working directory only.",
    },
    {
        "pattern": re.compile(r"\bopen\s*\([^)]*,\s*[\"']w"),
        "severity": "medium",
        "title": "File write operation",
        "description": "Skill writes to the filesystem. Verify writes are scoped and intentional.",
        "recommendation": "Ensure file writes are limited to expected directories (e.g., memory/, output/).",
    },
    # Medium
    {
        "pattern": re.compile(r"import\s+pickle|from\s+pickle\s+import|pickle\.loads?\("),
        "severity": "medium",
        "title": "Pickle deserialisation",
        "description": "Pickle can execute arbitrary code during deserialisation.",
        "recommendation": "Use JSON or another safe serialisation format instead of pickle.",
    },
    {
        "pattern": re.compile(r"import\s+ctypes|from\s+ctypes"),
        "severity": "medium",
        "title": "ctypes usage",
        "description": "ctypes allows calling C functions and manipulating memory directly.",
        "recommendation": "Avoid ctypes unless there is a clear, documented need for native code access.",
    },
    # Low
    {
        "pattern": re.compile(r"process\.env|os\.environ|os\.getenv"),
        "severity": "low",
        "title": "Environment variable access",
        "description": "Skill reads environment variables, which may contain sensitive data.",
        "recommendation": "Document which environment variables the skill needs and why.",
    },
]

_SEVERITY_WEIGHT = {"critical": 20, "high": 10, "medium": 5, "low": 2, "info": 0}
_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
_CODE_EXTENSIONS = {".py", ".js", ".ts", ".sh", ".bash", ".yaml", ".yml", ".json", ".toml"}


async def resolve_skill_download_url(skill_url: str) -> str:
    """Resolve a Claw Hub or direct URL to a downloadable skill zip URL.

    Accepted inputs:
        - https://clawhub.ai/skills/skill-name  (Claw Hub page)
        - https://clawhub.ai/author/skill-name  (Claw Hub page)
        - https://*.convex.site/api/v1/download?slug=skill-name  (direct)

    Returns the direct download URL.
    """
    parsed = urlparse(skill_url)

    # Already a direct download link
    if parsed.hostname and parsed.hostname.endswith(".convex.site"):
        return skill_url

    # Claw Hub URL — fetch page, extract download link
    if parsed.hostname and parsed.hostname in CLAWHUB_HOSTS:
        async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
            response = await client.get(skill_url)
            response.raise_for_status()
            html = response.text

        match = re.search(
            r"https://[a-z0-9-]+\.convex\.site/api/v1/download\?[^\s\"'<>]+",
            html,
        )
        if not match:
            raise ValueError(
                "Could not find a download link on the Claw Hub page. "
                "The skill may not be published or the page format has changed."
            )
        return match.group(0)

    # GitHub/GitLab — not a CLAW skill zip
    if parsed.hostname in {"github.com", "gitlab.com", "bitbucket.org"}:
        raise ValueError(
            "Repository URLs cannot be scanned as CLAW skills directly. "
            "Use scan_repository for repo security audits, or provide the Claw Hub URL."
        )

    # Unknown — try as-is
    return skill_url


def _analyse_file(content: str, relative_path: str) -> List[Dict[str, Any]]:
    """Run pattern checks against a single file."""
    findings: List[Dict[str, Any]] = []
    for check in _CODE_PATTERNS:
        for match in check["pattern"].finditer(content):
            line_num = content[:match.start()].count("\n") + 1
            findings.append({
                "severity": check["severity"],
                "title": check["title"],
                "description": check["description"],
                "file": relative_path,
                "line": line_num,
                "recommendation": check["recommendation"],
            })
    return findings


def _analyse_manifest(meta: Dict[str, Any], skill_md: Optional[str]) -> List[Dict[str, Any]]:
    """Check _meta.json and SKILL.md for completeness."""
    findings: List[Dict[str, Any]] = []

    if not meta.get("slug"):
        findings.append({
            "severity": "medium",
            "title": "Missing skill slug",
            "description": "Skill metadata is missing a slug identifier.",
            "recommendation": "Ensure _meta.json includes a slug field.",
        })
    if not meta.get("version"):
        findings.append({
            "severity": "low",
            "title": "Missing version",
            "description": "Skill metadata does not specify a version.",
            "recommendation": "Include a version field in _meta.json for tracking.",
        })

    if skill_md:
        fm_match = re.match(r"^---\n(.*?)\n---", skill_md, re.DOTALL)
        if not fm_match:
            findings.append({
                "severity": "medium",
                "title": "Missing SKILL.md frontmatter",
                "description": "SKILL.md should have YAML frontmatter with name and description.",
                "recommendation": "Add frontmatter with at minimum: name, description.",
            })
        else:
            fm = fm_match.group(1)
            if "name:" not in fm:
                findings.append({
                    "severity": "medium",
                    "title": "Missing skill name in SKILL.md",
                    "description": "SKILL.md frontmatter should include a name field.",
                    "recommendation": "Add 'name:' to SKILL.md frontmatter.",
                })
            if "description:" not in fm:
                findings.append({
                    "severity": "low",
                    "title": "Missing description in SKILL.md",
                    "description": "SKILL.md frontmatter should include a description.",
                    "recommendation": "Add 'description:' to SKILL.md frontmatter.",
                })
    else:
        findings.append({
            "severity": "medium",
            "title": "Missing SKILL.md",
            "description": "No SKILL.md found. This file documents the skill's purpose and usage.",
            "recommendation": "Add a SKILL.md with frontmatter (name, description) and usage documentation.",
        })

    return findings


def _analyse_dependencies(content: str, file_path: str) -> List[Dict[str, Any]]:
    """Check for unpinned dependencies."""
    findings: List[Dict[str, Any]] = []
    for line in content.splitlines():
        trimmed = line.strip()
        if not trimmed or trimmed.startswith("#"):
            continue
        if file_path.endswith("requirements.txt"):
            if "==" not in trimmed and ">=" not in trimmed and "~=" not in trimmed:
                findings.append({
                    "severity": "low",
                    "title": "Unpinned dependency",
                    "description": f'Dependency "{trimmed}" has no version constraint.',
                    "file": file_path,
                    "recommendation": "Pin dependencies to specific versions for reproducibility.",
                })
    return findings


def _collect_external_urls(files: Dict[str, str]) -> List[Dict[str, Any]]:
    """Check all files for insecure external URLs."""
    url_re = re.compile(r"https?://[^\s\"'`,)}\]>]+")
    all_urls: set[str] = set()
    for content in files.values():
        all_urls.update(url_re.findall(content))

    findings: List[Dict[str, Any]] = []
    for url in all_urls:
        if url.startswith("http://") and not url.startswith(("http://localhost", "http://127.0.0.1")):
            findings.append({
                "severity": "medium",
                "title": "Insecure HTTP URL referenced",
                "description": f"The skill references an insecure URL: {url}",
                "recommendation": "Use HTTPS for all external communications.",
            })

    https_urls = [u for u in all_urls if u.startswith("https://")]
    if https_urls:
        preview = ", ".join(https_urls[:5])
        extra = f" and {len(https_urls) - 5} more" if len(https_urls) > 5 else ""
        findings.append({
            "severity": "info",
            "title": "External endpoints",
            "description": f"Skill communicates with {len(https_urls)} external HTTPS endpoint(s): {preview}{extra}",
            "recommendation": "Verify these endpoints are legitimate and necessary.",
        })

    return findings


def _calculate_score(findings: List[Dict[str, Any]]) -> int:
    """Calculate a 0-100 security score from findings."""
    score = 100
    for f in findings:
        score -= _SEVERITY_WEIGHT.get(f["severity"], 0)
    return max(0, score)


def _score_to_grade(score: int) -> str:
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    if score >= 60:
        return "D"
    return "F"


async def scan_skill_local(skill_url: str, timeout: float = 30.0) -> Dict[str, Any]:
    """Download a CLAW skill, analyse its contents locally, and return results.

    Args:
        skill_url: Claw Hub URL or direct download URL.
        timeout: HTTP timeout for downloading.

    Returns:
        Dictionary matching the format used by other scan_* tools.
    """
    download_url = await resolve_skill_download_url(skill_url)

    tmp_dir = Path(tempfile.mkdtemp(prefix="cyberlens-skill-"))
    zip_path = tmp_dir / "skill.zip"
    extract_dir = tmp_dir / "extracted"

    try:
        # Download
        async with httpx.AsyncClient(timeout=timeout) as client:
            response = await client.get(download_url)
            response.raise_for_status()
            zip_path.write_bytes(response.content)

        # Extract
        extract_dir.mkdir()
        with zipfile.ZipFile(zip_path) as zf:
            zf.extractall(extract_dir)

        # Read all files
        files: Dict[str, str] = {}
        for file_path in extract_dir.rglob("*"):
            if file_path.is_file():
                try:
                    relative = str(file_path.relative_to(extract_dir))
                    files[relative] = file_path.read_text(encoding="utf-8", errors="ignore")
                except Exception:
                    pass

        # Parse metadata
        meta: Dict[str, Any] = {}
        if "_meta.json" in files:
            try:
                meta = json.loads(files["_meta.json"])
            except (json.JSONDecodeError, ValueError):
                pass

        skill_md = files.get("SKILL.md")

        # Run analyses
        findings: List[Dict[str, Any]] = []
        findings.extend(_analyse_manifest(meta, skill_md))

        files_analysed = 0
        for relative_path, content in files.items():
            suffix = Path(relative_path).suffix
            if suffix in _CODE_EXTENSIONS:
                findings.extend(_analyse_file(content, relative_path))
                files_analysed += 1
            if relative_path.endswith("requirements.txt"):
                findings.extend(_analyse_dependencies(content, relative_path))
                files_analysed += 1
            if suffix == ".md":
                files_analysed += 1

        findings.extend(_collect_external_urls(files))

        # Sort by severity
        findings.sort(key=lambda f: _SEVERITY_ORDER.get(f["severity"], 99))

        score = _calculate_score(findings)
        grade = _score_to_grade(score)
        skill_name = meta.get("slug", "unknown-skill")
        version = meta.get("version", "unknown")

        crit_count = sum(1 for f in findings if f["severity"] == "critical")
        high_count = sum(1 for f in findings if f["severity"] == "high")

        if crit_count > 0:
            assessment = f"Critical security issues found. Do NOT install without reviewing the {crit_count} critical finding(s)."
        elif high_count > 0:
            assessment = f"{high_count} high-severity issue(s) found. Review before installing."
        elif score >= 80:
            assessment = "Skill appears safe to install. Minor issues noted below."
        else:
            assessment = "Several issues found. Review findings before installing."

        return {
            "success": True,
            "source": "local",
            "scan_mode": "local_skill_package",
            "coverage": "local skill package analysis",
            "target_type": "skill",
            "url": skill_url,
            "download_url": download_url,
            "skill_name": skill_name,
            "version": version,
            "score": score,
            "security_score": score,
            "grade": grade,
            "assessment": assessment,
            "files_analysed": files_analysed,
            "total_files": len(files),
            "findings_count": len(findings),
            "findings": findings,
        }

    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)


path: src/tools.py
content: """Tool function implementations for OpenClaw integration."""

import json
import os
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
from urllib.parse import urlparse
from .scanner import SecurityScanner
from .api_client import CyberLensAPIClient, CyberLensQuotaExceededError
from .auth import (
    build_upgrade_url,
    load_api_base_url,
    load_api_key,
    open_upgrade_page,
    run_connect_flow,
)
from .skill_scanner import scan_skill_local
from .models import (
    ScanResult,
    SecurityScore,
    Finding,
    FindingExplanation,
    ScanRule,
)


CLAWHUB_HOSTS = {
    "clawhub.ai",
    "www.clawhub.ai",
    "claw-hub.net",
    "www.claw-hub.net",
    "openclaw-hub.org",
    "www.openclaw-hub.org",
}


CLAWHUB_RESERVED_PATHS = {
    "about",
    "api",
    "plugins",
    "privacy",
    "search",
    "settings",
    "sign-in",
    "terms",
}


GITHUB_RESERVED_PATHS = {
    "about",
    "account",
    "apps",
    "collections",
    "contact",
    "customer-stories",
    "enterprise",
    "events",
    "explore",
    "features",
    "gist",
    "gists",
    "issues",
    "login",
    "marketplace",
    "new",
    "notifications",
    "orgs",
    "organizations",
    "pricing",
    "pulls",
    "search",
    "security",
    "settings",
    "site",
    "sponsors",
    "topics",
    "trending",
    "users",
}


# Finding explanations database
FINDING_EXPLANATIONS = {
    "missing-csp": {
        "explanation": "Content Security Policy (CSP) is a browser security feature that controls what resources (scripts, styles, images) can load on your page. Without it, attackers can inject malicious scripts that steal data or take over user sessions.",
        "severity": "medium",
        "remediation": "Add a Content-Security-Policy header to your HTTP responses. Start with a permissive policy like \"default-src 'self'\" and gradually tighten it as you test.",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP",
            "https://csp-evaluator.withgoogle.com/",
        ],
    },
    "missing-hsts": {
        "explanation": "HTTP Strict Transport Security (HSTS) tells browsers to always use HTTPS for your site, preventing SSL stripping attacks where attackers downgrade connections to HTTP.",
        "severity": "high",
        "remediation": "Add Strict-Transport-Security: max-age=31536000; includeSubDomains; preload to all HTTPS responses. Test thoroughly before enabling preload.",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security",
            "https://hstspreload.org/",
        ],
    },
    "missing-x-frame-options": {
        "explanation": "X-Frame-Controls whether your site can be embedded in iframes. Without it, attackers can embed your site in a hidden frame and trick users into clicking elements (clickjacking attacks).",
        "severity": "medium",
        "remediation": "Add X-Frame-Options: DENY to prevent all framing, or SAMEORIGIN to allow only same-site framing.",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options",
        ],
    },
    "missing-x-content-type-options": {
        "explanation": "X-Content-Type-Options prevents browsers from MIME-sniffing responses away from the declared content type. Without it, attackers might upload files that execute as scripts.",
        "severity": "low",
        "remediation": "Add X-Content-Type-Options: nosniff to all responses.",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options",
        ],
    },
    "missing-referrer-policy": {
        "explanation": "Referrer Policy controls how much referrer information is sent with requests. Without it, sensitive URL parameters might leak to third parties.",
        "severity": "low",
        "remediation": "Add Referrer-Policy: strict-origin-when-cross-origin to balance privacy and functionality.",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy",
        ],
    },
    "missing-permissions-policy": {
        "explanation": "Permissions Policy (formerly Feature Policy) controls which browser features (camera, microphone, geolocation) can be used on your site. Without it, embedded content might access sensitive APIs.",
        "severity": "low",
        "remediation": "Add Permissions-Policy with restrictive defaults: camera=(), microphone=(), geolocation=(), etc.",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Permissions-Policy",
        ],
    },
    "no-https": {
        "explanation": "Your site doesn't use HTTPS, which means all data transmitted between users and your server is unencrypted and can be intercepted or modified by attackers.",
        "severity": "critical",
        "remediation": "Install an SSL/TLS certificate and redirect all HTTP traffic to HTTPS. Let's Encrypt provides free certificates.",
        "references": [
            "https://letsencrypt.org/",
            "https://developer.mozilla.org/en-US/docs/Web/Security/Transport_Layer_Security",
        ],
    },
    "information-disclosure": {
        "explanation": "Your server is revealing technology information in HTTP headers. Attackers can use this to find known vulnerabilities for your specific software versions.",
        "severity": "low",
        "remediation": "Remove X-Powered-By headers from your server configuration. This doesn't prevent attacks but makes reconnaissance harder.",
        "references": [
            "https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/01-Information_Gathering/",
        ],
    },
    "server-version-exposed": {
        "explanation": "Your server software version is visible in the Server header. Attackers can look up CVEs for that specific version and craft targeted attacks.",
        "severity": "low",
        "remediation": "Configure your web server to hide version information. In nginx: server_tokens off; In Apache: ServerTokens Prod",
        "references": [
            "https://nginx.org/en/docs/http/ngx_http_core_module.html#server_tokens",
            "https://httpd.apache.org/docs/current/mod/core.html#servertokens",
        ],
    },
    "insecure-form-action": {
        "explanation": "A form on your page submits to an HTTP URL. Even if your page is HTTPS, the form data will be sent unencrypted, exposing passwords and other sensitive data.",
        "severity": "high",
        "remediation": "Update all form actions to use HTTPS URLs. Also check that the form's action attribute uses https://",
        "references": [
            "https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form",
        ],
    },
    "missing-csrf-protection": {
        "explanation": "A form that changes state (POST request) doesn't include a CSRF token. Attackers can trick users into submitting forms they didn't intend to, potentially changing passwords or making purchases.",
        "severity": "medium",
        "remediation": "Add CSRF tokens to all state-changing forms. Most web frameworks (Django, Rails, Laravel) have built-in CSRF protection.",
        "references": [
            "https://owasp.org/www-community/attacks/cs",
            "https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html",
        ],
    },
}


def _validate_target_url(target: str) -> Optional[str]:
    """Validate that the provided target is an HTTP(S) URL."""
    if not target.startswith(("http://", "https://")):
        return "URL must start with http:// or https://"

    parsed = urlparse(target)
    if not parsed.scheme or not parsed.netloc:
        return "URL must include a valid hostname"

    return None


def _is_clawhub_skill_path(path_parts: List[str]) -> bool:
    """Return True when the Claw Hub path looks like a skill detail page."""
    if len(path_parts) < 2:
        return False

    first_segment = path_parts[0].lower()
    if first_segment == "skills":
        return True

    return first_segment not in CLAWHUB_RESERVED_PATHS


def _is_skill_download_url(parsed) -> bool:
    """Return True when the URL points at a direct skill download endpoint."""
    host = (parsed.hostname or "").lower()
    return host.endswith(".convex.site") and parsed.path.rstrip("/") == "/api/v1/download"


def _classify_target(target: str) -> str:
    """Classify a scan target as a website, GitHub repository, or Claw Hub skill."""
    validation_error = _validate_target_url(target)
    if validation_error:
        return "invalid"

    parsed = urlparse(target)
    host = (parsed.hostname or "").lower()
    path_parts = [part for part in parsed.path.split("/") if part]

    if _is_skill_download_url(parsed):
        return "skill"

    # Claw Hub skill pages: clawhub.ai/skills/skill-name or clawhub.ai/author/skill-name
    if host in CLAWHUB_HOSTS and _is_clawhub_skill_path(path_parts):
        return "skill"

    if host in {"github.com", "www.github.com"} and len(path_parts) >= 2:
        owner = path_parts[0].lower()
        repo = path_parts[1]
        if owner not in GITHUB_RESERVED_PATHS and repo:
            return "repository"

    return "website"


def _normalize_cloud_vulnerabilities(vulnerabilities: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Normalize website vulnerability payloads from the CyberLens cloud API."""
    return [
        {
            "test_id": vulnerability.get("testId") or vulnerability.get("type", "unknown"),
            "type": vulnerability.get("testId") or vulnerability.get("type", "unknown"),
            "severity": vulnerability.get("severity", "info"),
            "message": vulnerability.get("message") or vulnerability.get("title") or vulnerability.get("description", ""),
            "description": vulnerability.get("message") or vulnerability.get("title") or vulnerability.get("description", ""),
            "details": vulnerability.get("details") or vulnerability.get("description", ""),
            "recommendation": vulnerability.get("recommendation", ""),
            "passed": bool(vulnerability.get("passed", False)),
        }
        for vulnerability in vulnerabilities
    ]


def _flatten_repository_findings(result: Dict[str, Any]) -> List[Dict[str, Any]]:
    """Flatten repository scan sections into a single findings list."""
    findings: List[Dict[str, Any]] = []

    for finding in result.get("security_findings", []) or []:
        findings.append({
            "source_section": "security_findings",
            "type": finding.get("test_id") or finding.get("category") or "repository-security-finding",
            "test_id": finding.get("test_id"),
            "severity": finding.get("severity", "info"),
            "confidence": finding.get("confidence"),
            "message": finding.get("message", ""),
            "description": finding.get("details") or finding.get("message", ""),
            "details": finding.get("details"),
            "recommendation": finding.get("recommendation"),
            "category": finding.get("category"),
            "cve": finding.get("cve"),
        })

    for finding in result.get("dependency_vulnerabilities", []) or []:
        package_name = finding.get("package_name", "unknown")
        current_version = finding.get("current_version", "unknown")
        findings.append({
            "source_section": "dependency_vulnerabilities",
            "type": "dependency-vulnerability",
            "severity": finding.get("severity", "info"),
            "message": f"{package_name}@{current_version} may have vulnerabilities",
            "description": finding.get("remediation", ""),
            "details": f"Current version: {current_version}",
            "recommendation": finding.get("remediation"),
            "package_name": package_name,
            "current_version": current_version,
            "patched_version": finding.get("patched_version"),
            "cve_ids": finding.get("cve_ids", []),
        })

    for finding in result.get("trust_posture_findings", []) or []:
        findings.append({
            "source_section": "trust_posture_findings",
            "type": "trust-posture",
            "severity": finding.get("severity", "info"),
            "message": finding.get("title") or finding.get("message", ""),
            "description": finding.get("message", ""),
            "details": finding.get("message"),
            "recommendation": finding.get("remediation"),
            "classification": finding.get("classification"),
        })

    for section_name in (
        "secret_findings",
        "behavioral_findings",
        "malicious_code_findings",
        "malicious_package_findings",
        "artifact_findings",
    ):
        for finding in result.get(section_name, []) or []:
            findings.append({
                "source_section": section_name,
                "type": finding.get("type") or finding.get("title") or section_name.rstrip("s"),
                "severity": finding.get("severity", "info"),
                "message": finding.get("message") or finding.get("title", ""),
                "description": finding.get("details") or finding.get("message") or finding.get("title", ""),
                "details": finding.get("details"),
                "recommendation": finding.get("recommendation") or finding.get("remediation"),
            })

    return findings


def _is_repository_scan_result(result: Dict[str, Any]) -> bool:
    """Return True when a cloud result matches the repository assessment schema."""
    return result.get("report_type") == "repository_security_assessment" or any(
        key in result
        for key in (
            "security_findings",
            "dependency_vulnerabilities",
            "trust_posture_findings",
            "repository",
        )
    )


def _format_repository_cloud_result(result: Dict[str, Any], target: str) -> Dict[str, Any]:
    """Format a repository assessment response from the CyberLens cloud API."""
    security_score = result.get("security_score", 0) or 0
    grade = _score_to_grade(security_score)
    findings = _flatten_repository_findings(result)
    findings_count = result.get("summary", {}).get("total_findings")
    if not isinstance(findings_count, int):
        findings_count = len(findings)

    return {
        "success": True,
        "source": "cloud",
        "scan_mode": "cloud_full",
        "coverage": "cloud repository assessment",
        "target_type": "repository",
        "url": result.get("target", target),
        "score": security_score,
        "security_score": security_score,
        "trust_score": result.get("trust_score"),
        "grade": grade,
        "assessment": _get_grade_assessment(grade),
        "report_type": result.get("report_type", "repository_security_assessment"),
        "generated_at": result.get("generated_at"),
        "scan_date": result.get("scan_date"),
        "scan_type": result.get("scan_type"),
        "findings_count": findings_count,
        "summary": result.get("summary", {}),
        "repository": result.get("repository", {}),
        "ai_analysis": result.get("ai_analysis"),
        "findings": findings,
        "security_findings": result.get("security_findings", []),
        "dependency_vulnerabilities": result.get("dependency_vulnerabilities", []),
        "trust_posture_findings": result.get("trust_posture_findings", []),
        "secret_findings": result.get("secret_findings", []),
        "behavioral_findings": result.get("behavioral_findings", []),
        "malicious_code_findings": result.get("malicious_code_findings", []),
        "malicious_package_findings": result.get("malicious_package_findings", []),
        "artifact_findings": result.get("artifact_findings", []),
    }


def _format_website_cloud_result(result: Dict[str, Any], target: str) -> Dict[str, Any]:
    """Format a website scan response from the CyberLens cloud API."""
    vulnerabilities = result.get("vulnerabilities", []) or []
    findings_count = result.get("summary", {}).get("vulnerabilities_found")
    if not isinstance(findings_count, int):
        findings_count = len(vulnerabilities)

    return {
        "success": True,
        "source": "cloud",
        "scan_mode": "cloud_full",
        "coverage": "70+ cloud checks",
        "target_type": "website",
        "url": result.get("url", target),
        "score": result.get("scores", {}).get("overall", 0),
        "grade": _score_to_grade(result.get("scores", {}).get("overall", 0)),
        "scan_type": result.get("scan_type"),
        "started_at": result.get("started_at"),
        "completed_at": result.get("completed_at"),
        "findings_count": findings_count,
        "summary": result.get("summary", {}),
        "ssl_info": result.get("ssl_info", {}),
        "headers_analysis": result.get("headers_analysis", {}),
        "database_passive_results": result.get("database_passive_results", []),
        "ai_insights": result.get("ai_insights"),
        "findings": _normalize_cloud_vulnerabilities(vulnerabilities),
    }


def _format_cloud_scan_result(result: Dict[str, Any], target: str) -> Dict[str, Any]:
    """Format a CyberLens cloud result for either websites or repositories."""
    if _is_repository_scan_result(result):
        return _format_repository_cloud_result(result, target)
    return _format_website_cloud_result(result, target)


def _build_quota_upgrade_payload(
    error: CyberLensQuotaExceededError,
    target_type: str,
) -> Dict[str, Any]:
    """Build a user-facing upgrade prompt from a quota exhaustion error."""
    quota_type = error.quota_type or ("repository" if target_type in ("repository", "skill") else "website")
    upgrade_url = error.upgrade_url or build_upgrade_url(quota_type)
    open_upgrade_page(upgrade_url)

    payload: Dict[str, Any] = {
        "upgrade_required": True,
        "upgrade_url": upgrade_url,
        "upgrade_message": str(error),
        "opened_upgrade_page": True,
        "quota_type": quota_type,
    }

    if error.used is not None:
        payload["quota_used"] = error.used
    if error.limit is not None:
        payload["quota_limit"] = error.limit

    return payload


def _build_local_website_mode_payload(
    scan_depth: str,
    reason: str,
    quota_prompt: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Describe the local website fallback mode honestly for callers and UIs."""
    messages = [
        "This result came from the local quick website scan with roughly 15 core checks."
    ]

    if scan_depth != "quick":
        messages.append(
            f'Requested scan depth "{scan_depth}" is not available in local mode. '
            'The local quick scan was used instead.'
        )

    if reason == "no_account":
        messages.append(
            "Connect a CyberLens account to unlock the full cloud scan with 70+ checks, "
            "AI analysis, scan history, and repository scanning."
        )
    elif reason == "quota_exhausted" and quota_prompt:
        messages.append(
            "Cloud website quota is exhausted, so CyberLens fell back to the local quick scan. "
            f"Upgrade to restore full cloud coverage: {quota_prompt['upgrade_url']}"
        )
    elif reason == "local_requested":
        messages.append("Local mode was used because use_cloud=False was requested.")

    return {
        "scan_mode": "local_quick",
        "coverage": "~15 core local checks",
        "requested_scan_depth": scan_depth,
        "effective_scan_depth": "quick",
        "account_recommended": True,
        "notice": " ".join(messages),
    }


async def connect_account() -> Dict[str, Any]:
    """
    Connect your CyberLens account for cloud-powered scanning.

    Opens a browser window to sign in or create a CyberLens account.
    Once authenticated, your API key is stored locally for future scans.

    Returns:
        Dictionary with connection status
    """
    try:
        existing = load_api_key()
        if existing:
            return {
                "success": True,
                "message": "Already connected to CyberLens.",
                "key_prefix": existing[:12] + "...",
                "hint": "Run this again to reconnect with a new key.",
            }

        key = await run_connect_flow()
        return {
            "success": True,
            "message": "Successfully connected to CyberLens!",
            "key_prefix": key[:12] + "...",
        }
    except Exception as e:
        return {
            "success": False,
            "error": str(e),
        }


async def scan_target(
    target: str,
    scan_depth: str = "standard",
    timeout: float = 30.0,
    use_cloud: Optional[bool] = None,
) -> Dict[str, Any]:
    """
    Scan a live website, GitHub repository URL, or Claw Hub skill.

    Website targets use the local quick scan without an account and the full
    CyberLens cloud scan when connected.
    GitHub repository targets use the CyberLens cloud API and require an account.
    Claw Hub skills are downloaded and analysed locally so the package contents
    are inspected instead of the hosted marketplace page.

    Args:
        target: Website URL, GitHub repository URL, Claw Hub skill URL, or direct skill download URL
        scan_depth: Requested depth ("quick", "standard", "deep"). Local mode
            always uses the quick website scan and warns when a deeper mode was
            requested.
        timeout: Request timeout in seconds
        use_cloud: Force cloud (True) or local (False) scanning. None = auto-detect.

    Returns:
        Dictionary with scan results tailored to the detected target type
    """
    validation_error = _validate_target_url(target)
    if validation_error:
        return {"success": False, "error": validation_error, "url": target}

    target_type = _classify_target(target)
    [REDACTED_SECRET])
    api_base_url = load_api_base_url()
    should_use_cloud = use_cloud if use_cloud is not None else bool(api_key)
    quota_prompt: Optional[Dict[str, Any]] = None

    if target_type == "skill":
        try:
            result = await scan_skill_local(target, timeout=timeout)
            result["account_recommended"] = True
            messages = ["This result came from the local skill package scan."]
            if should_use_cloud and [REDACTED_SECRET]
                    "Connected-account cloud scanning is not used for Claw Hub skill URLs because CyberLens should inspect the skill package, not the Claw Hub page."
                )
            else:
                messages.append(
                    "Connect a CyberLens account when you want cloud website scanning or repository scanning."
                )
            result["notice"] = " ".join(messages)
            return result
        except Exception as e:
            return {
                "success": False,
                "target_type": "skill",
                "url": target,
                "error": f"Local skill scan failed: {e}",
            }

    if target_type == "repository":
        if not (should_use_cloud and api_key):
            return {
                "success": False,
                "target_type": "repository",
                "url": target,
                "error": (
                    "Repository scanning requires a connected CyberLens account. "
                    "Run connect_account or set CYBERLENS_API_KEY."
                ),
            }

        try:
            async with CyberLensAPIClient(api_key, timeout=timeout, api_base=api_base_url) as client:
                result = await client.scan(target)
                return _format_cloud_scan_result(result, target)
        except CyberLensQuotaExceededError as e:
            return {
                "success": False,
                "target_type": "repository",
                "url": target,
                "error": str(e),
                **_build_quota_upgrade_payload(e, "repository"),
            }
        except Exception as e:
            return {
                "success": False,
                "target_type": "repository",
                "url": target,
                "error": f"Cloud repository scan failed: {e}",
            }

    if should_use_cloud and api_key:
        try:
            async with CyberLensAPIClient(api_key, timeout=timeout, api_base=api_base_url) as client:
                result = await client.scan(target)
                return _format_cloud_scan_result(result, target)
        except CyberLensQuotaExceededError as e:
            quota_prompt = _build_quota_upgrade_payload(e, "website")
        except Exception as e:
            if use_cloud is True:
                return {
                    "success": False,
                    "target_type": "website",
                    "error": f"Cloud scan failed: {e}",
                    "url": target,
                }
            # Fall through to local website scan

    async with SecurityScanner(timeout=timeout) as scanner:
        result = await scanner.scan(target)

        if result.error:
            failure = {
                "success": False,
                "target_type": "website",
                "error": result.error,
                "url": result.url,
            }
            failure.update(
                _build_local_website_mode_payload(
                    scan_depth,
                    "quota_exhausted" if quota_prompt else ("local_requested" if use_cloud is False else "no_account"),
                    quota_prompt,
                )
            )
            if quota_prompt:
                failure.update(quota_prompt)
            return failure

        output = {
            "success": True,
            "source": "local",
            "target_type": "website",
            "url": result.url,
            "score": result.score,
            "grade": result.grade,
            "scan_time_ms": result.scan_time_ms,
            "technologies": result.technologies,
            "findings_count": len(result.findings),
            "findings": [
                {
                    "type": f.type,
                    "severity": f.severity,
                    "description": f.description,
                    "remediation": f.remediation,
                    "evidence": f.evidence,
                }
                for f in result.findings
            ],
        }
        output.update(
            _build_local_website_mode_payload(
                scan_depth,
                "quota_exhausted" if quota_prompt else ("local_requested" if use_cloud is False else "no_account"),
                quota_prompt,
            )
        )
        if quota_prompt:
            output.update(quota_prompt)
            output["cloud_quota_exceeded"] = True

        return output


async def scan_website(
    url: str,
    scan_depth: str = "standard",
    timeout: float = 30.0,
    use_cloud: Optional[bool] = None,
) -> Dict[str, Any]:
    """
    Perform a security scan on a website URL.

    Uses the local quick scan without an account and the full CyberLens cloud
    API when connected. If cloud quota is exhausted, it falls back to the local
    quick scan automatically.

    Args:
        url: The website URL to scan (must include https:// or http://)
        scan_depth: Requested depth ("quick", "standard", "deep"). Local mode
            always uses the quick scan and warns when a deeper mode was
            requested.
        timeout: Request timeout in seconds
        use_cloud: Force cloud (True) or local (False) scanning. None = auto-detect.

    Returns:
        Dictionary with scan results including score, grade, and findings
    """
    return await scan_target(
        target=url,
        scan_depth=scan_depth,
        timeout=timeout,
        use_cloud=use_cloud,
    )


async def scan_repository(
    repository_url: str,
    timeout: float = 60.0,
    use_cloud: Optional[bool] = None,
) -> Dict[str, Any]:
    """
    Scan a GitHub repository URL with CyberLens cloud analysis.

    Args:
        repository_url: GitHub repository URL such as https://github.com/owner/repo
        timeout: Request timeout in seconds
        use_cloud: Force cloud behavior. Repository scanning requires cloud access.

    Returns:
        Dictionary with repository security findings, scores, and summary
    """
    if _classify_target(repository_url) not in ("repository", "skill"):
        return {
            "success": False,
            "error": (
                "Expected a GitHub repository URL (e.g. https://github.com/owner/repo) "
                "or a Claw Hub skill URL (e.g. https://clawhub.ai/author/skill-name)."
            ),
            "url": repository_url,
        }

    return await scan_target(
        target=repository_url,
        timeout=timeout,
        use_cloud=use_cloud,
    )


async def get_security_score(
    url: str,
    timeout: float = 30.0,
) -> Dict[str, Any]:
    """
    Quick security score check for a URL.

    Uses the CyberLens cloud API if connected, otherwise local scanning.

    Args:
        url: The website URL to check
        timeout: Request timeout in seconds

    Returns:
        Dictionary with score and grade
    """
    validation_error = _validate_target_url(url)
    if validation_error:
        return {"success": False, "error": validation_error, "url": url}

    target_type = _classify_target(url)
    [REDACTED_SECRET])
    api_base_url = load_api_base_url()
    quota_result: Optional[Dict[str, Any]] = None

    if target_type == "skill":
        try:
            result = await scan_skill_local(url, timeout=timeout)
            score = result.get("score", result.get("security_score", 0))
            grade = _score_to_grade(score)
            messages = ["This score came from the local skill package scan."]
            if [REDACTED_SECRET]
                    "Connected-account cloud scanning is not used for Claw Hub skill URLs because CyberLens should score the skill package, not the Claw Hub page."
                )
            else:
                messages.append(
                    "Connect a CyberLens account for cloud website scanning or repository scanning."
                )
            return {
                "success": True,
                "source": "local",
                "scan_mode": "local_skill_package",
                "coverage": "local skill package analysis",
                "target_type": "skill",
                "url": result.get("url", url),
                "score": score,
                "security_score": score,
                "grade": grade,
                "assessment": result.get("assessment", _get_grade_assessment(grade)),
                "account_recommended": True,
                "notice": " ".join(messages),
            }
        except Exception as e:
            return {
                "success": False,
                "target_type": "skill",
                "url": url,
                "error": f"Local skill scoring failed: {e}",
            }

    if target_type == "repository":
        if not [REDACTED_SECRET] {
                "success": False,
                "target_type": "repository",
                "url": url,
                "error": (
                    "Repository scoring requires a connected CyberLens account. "
                    "Run connect_account or set CYBERLENS_API_KEY."
                ),
            }

        try:
            async with CyberLensAPIClient(api_key, timeout=timeout, api_base=api_base_url) as client:
                result = await client.scan(url)
                score = result.get("security_score", 0) or 0
                grade = _score_to_grade(score)
                return {
                    "success": True,
                    "source": "cloud",
                    "scan_mode": "cloud_full",
                    "coverage": "cloud repository assessment",
                    "target_type": target_type,
                    "url": result.get("target", url),
                    "score": score,
                    "security_score": score,
                    "trust_score": result.get("trust_score"),
                    "grade": grade,
                    "assessment": _get_grade_assessment(grade),
                }
        except CyberLensQuotaExceededError as e:
            return {
                "success": False,
                "target_type": "repository",
                "url": url,
                "error": str(e),
                **_build_quota_upgrade_payload(e, "repository"),
            }
        except Exception as e:
            return {
                "success": False,
                "target_type": "repository",
                "url": url,
                "error": f"Cloud repository scoring failed: {e}",
            }

    if api_key:
        try:
            async with CyberLensAPIClient(api_key, timeout=timeout, api_base=api_base_url) as client:
                result = await client.scan(url)
                score = result.get("scores", {}).get("overall", 0)
                grade = _score_to_grade(score)
                return {
                    "success": True,
                    "source": "cloud",
                    "scan_mode": "cloud_full",
                    "coverage": "70+ cloud checks",
                    "target_type": "website",
                    "url": url,
                    "score": score,
                    "grade": grade,
                    "assessment": _get_grade_assessment(grade),
                }
        except CyberLensQuotaExceededError as e:
            quota_prompt = _build_quota_upgrade_payload(e, "website")
            quota_result = {
                "upgrade_required": True,
                "upgrade_url": quota_prompt["upgrade_url"],
                "upgrade_message": quota_prompt["upgrade_message"],
                "opened_upgrade_page": quota_prompt["opened_upgrade_page"],
                "quota_type": quota_prompt["quota_type"],
                "cloud_quota_exceeded": True,
            }
            if "quota_used" in quota_prompt:
                quota_result["quota_used"] = quota_prompt["quota_used"]
            if "quota_limit" in quota_prompt:
                quota_result["quota_limit"] = quota_prompt["quota_limit"]
        except Exception:
            quota_result = None

    async with SecurityScanner(timeout=timeout) as scanner:
        result = await scanner.scan(url)
        if result.error:
            failure = {
                "success": False,
                "target_type": "website",
                "url": result.url,
                "error": result.error,
            }
            failure.update(
                _build_local_website_mode_payload(
                    "quick",
                    "quota_exhausted" if quota_result else "no_account",
                    quota_result,
                )
            )
            if quota_result:
                failure.update(quota_result)
            return failure

        output = {
            "success": True,
            "source": "local",
            "target_type": "website",
            "url": result.url,
            "score": result.score,
            "grade": result.grade,
            "assessment": _get_grade_assessment(result.grade),
        }
        output.update(
            _build_local_website_mode_payload(
                "quick",
                "quota_exhausted" if quota_result else "no_account",
                quota_result,
            )
        )
        if quota_result:
            output.update(quota_result)

        return output


def _score_to_grade(score: int) -> str:
    """Convert a numeric score to a letter grade."""
    if score >= 90: return "A"
    if score >= 80: return "B"
    if score >= 70: return "C"
    if score >= 60: return "D"
    return "F"


def _get_grade_assessment(grade: str) -> str:
    """Get a human-readable assessment for a grade."""
    assessments = {
        "A": "Excellent security posture. Minor improvements possible.",
        "B": "Good security with some room for improvement.",
        "C": "Average security. Several issues should be addressed.",
        "D": "Below average. Significant security improvements needed.",
        "F": "Poor security. Critical issues must be fixed immediately.",
    }
    return assessments.get(grade, "Unknown grade")


def explain_finding(
    finding_type: str,
    context: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Get a plain-English explanation of a security finding.
    
    Args:
        finding_type: The type of finding (e.g., "missing-csp", "no-https")
        context: Optional context about where the finding was detected
    
    Returns:
        Dictionary with explanation, severity, and remediation advice
    """
    explanation = FINDING_EXPLANATIONS.get(finding_type)
    
    if not explanation:
        return {
            "success": False,
            "error": f"Unknown finding type: {finding_type}",
            "known_types": list(FINDING_EXPLANATIONS.keys()),
        }
    
    result = {
        "success": True,
        "finding_type": finding_type,
        "explanation": explanation["explanation"],
        "severity": explanation["severity"],
        "remediation": explanation["remediation"],
        "references": explanation["references"],
    }
    
    if context:
        result["context"] = context
    
    return result


def list_scan_rules() -> Dict[str, Any]:
    """
    List all available scan rules/categories.
    
    Returns:
        Dictionary with available scan rules organized by category
    """
    categories = {
        "headers": {
            "description": "HTTP security header checks",
            "rules": [
                {"name": "content-security-policy", "severity": "medium"},
                {"name": "strict-transport-security", "severity": "high"},
                {"name": "x-frame-options", "severity": "medium"},
                {"name": "x-content-type-options", "severity": "low"},
                {"name": "referrer-policy", "severity": "low"},
                {"name": "permissions-policy", "severity": "low"},
            ],
        },
        "https": {
            "description": "HTTPS and TLS configuration checks",
            "rules": [
                {"name": "https-enforced", "severity": "critical"},
                {"name": "hsts-enabled", "severity": "high"},
            ],
        },
        "disclosure": {
            "description": "Information disclosure detection",
            "rules": [
                {"name": "server-header", "severity": "low"},
                {"name": "x-powered-by", "severity": "low"},
                {"name": "version-exposure", "severity": "low"},
            ],
        },
        "forms": {
            "description": "Form security analysis",
            "rules": [
                {"name": "csrf-protection", "severity": "medium"},
                {"name": "secure-form-action", "severity": "high"},
            ],
        },
        "repository": {
            "description": "Cloud repository scanning categories for GitHub repositories and OpenClaw skills",
            "rules": [
                {"name": "dependency-vulnerabilities", "severity": "high"},
                {"name": "trust-posture", "severity": "medium"},
                {"name": "secret-detection", "severity": "high"},
                {"name": "malicious-package-review", "severity": "high"},
            ],
        },
    }
    
    total_rules = sum(len(cat["rules"]) for cat in categories.values())

    return {
        "success": True,
        "total_rules": total_rules,
        "categories": categories,
    }


async def scan_skill(
    skill_url: str,
    timeout: float = 60.0,
) -> Dict[str, Any]:
    """
    Scan a Claw Hub skill before installing it.

    Accepts a Claw Hub URL (e.g. https://clawhub.ai/skills/skill-name),
    a direct download URL (e.g. https://*.convex.site/api/v1/download?slug=name),
    or a GitHub repository URL for an OpenClaw skill.

    The skill zip is downloaded, extracted, and analysed locally for security
    issues including hardcoded secrets, dangerous code patterns, insecure
    network requests, and manifest completeness. No CyberLens account is
    required for skill scanning.

    For GitHub repository URLs, use scan_repository instead (requires a
    CyberLens account).

    Args:
        skill_url: Claw Hub skill URL, direct download URL, or GitHub repo URL
        timeout: Request timeout in seconds (default: 60)

    Returns:
        Dictionary with security score, grade, assessment, and detailed findings
    """
    validation_error = _validate_target_url(skill_url)
    if validation_error:
        return {"success": False, "error": validation_error, "url": skill_url}

    target_type = _classify_target(skill_url)

    # GitHub repos should use scan_repository
    if target_type == "repository":
        return {
            "success": False,
            "error": (
                "Repository URLs cannot be scanned as CLAW skills directly. "
                "Use scan_repository for repo security audits, or provide the "
                "Claw Hub URL (e.g. https://clawhub.ai/skills/skill-name)."
            ),
            "url": skill_url,
        }

    if target_type not in ("skill", "website"):
        return {
            "success": False,
            "error": (
                "Expected a Claw Hub skill URL (e.g. https://clawhub.ai/skills/skill-name) "
                "or a direct download URL."
            ),
            "url": skill_url,
        }

    try:
        return await scan_skill_local(skill_url, timeout=timeout)
    except Exception as e:
        return {
            "success": False,
            "target_type": "skill",
            "url": skill_url,
            "error": f"Skill scan failed: {e}",
        }


# ---------------------------------------------------------------------------
# Report generation helpers
# ---------------------------------------------------------------------------

_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}


def _severity_sort_key(finding: Dict[str, Any]) -> int:
    return _SEVERITY_ORDER.get(finding.get("severity", "info"), 5)


def _severity_emoji(severity: str) -> str:
    return {
        "critical": "[!]",
        "high": "[H]",
        "medium": "[M]",
        "low": "[L]",
        "info": "[i]",
    }.get(severity, "[-]")


def generate_report(scan_result: Dict[str, Any]) -> Dict[str, Any]:
    """
    Generate a formatted markdown report from scan results.

    Takes the output of scan_target, scan_skill, scan_website, or
    scan_repository and produces a clean markdown report suitable for
    sharing via messaging (Telegram, Discord, Signal), the web UI, or
    any channel that renders markdown.

    Args:
        scan_result: The dictionary returned by any CyberLens scan tool

    Returns:
        Dictionary with 'success', 'format' ("markdown"), and 'report' (the
        markdown string)
    """
    if not scan_result.get("success"):
        return {
            "success": False,
            "error": scan_result.get("error", "Cannot generate report from a failed scan."),
        }

    target_type = scan_result.get("target_type", "website")
    url = scan_result.get("url", "Unknown")
    score = scan_result.get("score") or scan_result.get("security_score", "N/A")
    grade = scan_result.get("grade", "N/A")
    trust_score = scan_result.get("trust_score")
    assessment = scan_result.get("assessment", "")
    source = scan_result.get("source", "cloud")
    generated_at = scan_result.get("generated_at") or scan_result.get("scan_date") or datetime.now(timezone.utc).isoformat()
    findings = scan_result.get("findings", [])
    ai_analysis = scan_result.get("ai_analysis") or scan_result.get("ai_insights")

    # Header
    type_label = {"skill": "Claw Hub Skill", "repository": "Repository", "website": "Website"}.get(target_type, target_type.title())
    lines: List[str] = []
    lines.append(f"# CyberLens Security Report")
    lines.append("")
    lines.append(f"**Target:** {url}")
    lines.append(f"**Type:** {type_label}")
    lines.append(f"**Scan Source:** {source}")
    lines.append(f"**Date:** {generated_at}")
    lines.append("")

    # Score card
    lines.append("## Score")
    lines.append("")
    lines.append(f"| Metric | Value |")
    lines.append(f"|--------|-------|")
    lines.append(f"| Security Score | {score} / 100 |")
    lines.append(f"| Grade | {grade} |")
    if trust_score is not None:
        lines.append(f"| Trust Score | {trust_score} / 100 |")
    if assessment:
        lines.append(f"| Assessment | {assessment} |")
    lines.append("")

    # AI analysis
    if ai_analysis:
        lines.append("## AI Analysis")
        lines.append("")
        if isinstance(ai_analysis, dict):
            for key, value in ai_analysis.items():
                lines.append(f"**{key.replace('_', ' ').title()}:** {value}")
                lines.append("")
        else:
            lines.append(str(ai_analysis))
            lines.append("")

    # Summary
    summary = scan_result.get("summary")
    if summary and isinstance(summary, dict):
        lines.append("## Summary")
        lines.append("")
        for key, value in summary.items():
            lines.append(f"- **{key.replace('_', ' ').title()}:** {value}")
        lines.append("")

    # Findings
    if findings:
        sorted_findings = sorted(findings, key=_severity_sort_key)
        lines.append(f"## Findings ({len(findings)} total)")
        lines.append("")
        for i, f in enumerate(sorted_findings, 1):
            sev = f.get("severity", "info")
            marker = _severity_emoji(sev)
            msg = f.get("message") or f.get("description", "No description")
            lines.append(f"### {i}. {marker} {msg}")
            lines.append("")
            lines.append(f"- **Severity:** {sev}")
            if f.get("type"):
                lines.append(f"- **Type:** {f['type']}")
            if f.get("category"):
                lines.append(f"- **Category:** {f['category']}")
            if f.get("description") and f.get("description") != msg:
                lines.append(f"- **Details:** {f['description']}")
            if f.get("details") and f.get("details") != f.get("description"):
                lines.append(f"- **Details:** {f['details']}")
            if f.get("recommendation") or f.get("remediation"):
                lines.append(f"- **Recommendation:** {f.get('recommendation') or f.get('remediation')}")
            if f.get("cve"):
                lines.append(f"- **CVE:** {f['cve']}")
            if f.get("cve_ids"):
                lines.append(f"- **CVE IDs:** {', '.join(f['cve_ids'])}")
            lines.append("")
    else:
        lines.append("## Findings")
        lines.append("")
        lines.append("No findings detected.")
        lines.append("")

    # Footer
    lines.append("---")
    lines.append("*Generated by CyberLens Security Scanner — https://cyberlensai.com*")

    report_text = "\n".join(lines)

    return {
        "success": True,
        "format": "markdown",
        "report": report_text,
        "url": url,
        "target_type": target_type,
        "score": score,
        "grade": grade,
    }


def export_report_pdf(
    scan_result: Dict[str, Any],
    output_path: Optional[str] = None,
) -> Dict[str, Any]:
    """
    Export scan results as a PDF file.

    Takes the output of scan_target, scan_skill, scan_website, or
    scan_repository and writes a professionally formatted PDF report.
    Requires the reportlab package (pip install reportlab).

    Args:
        scan_result: The dictionary returned by any CyberLens scan tool
        output_path: Where to save the PDF. Defaults to
                     ~/cyberlens-report-<timestamp>.pdf

    Returns:
        Dictionary with 'success' and 'path' (absolute path to the PDF)
    """
    if not scan_result.get("success"):
        return {
            "success": False,
            "error": scan_result.get("error", "Cannot generate PDF from a failed scan."),
        }

    try:
        from reportlab.lib import colors
        from reportlab.lib.pagesizes import letter
        from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
        from reportlab.lib.units import inch
        from reportlab.platypus import (
            Paragraph,
            SimpleDocTemplate,
            Spacer,
            Table,
            TableStyle,
        )
    except ImportError:
        return {
            "success": False,
            "error": (
                "PDF export requires the reportlab package. "
                "Install it with: pip install reportlab"
            ),
        }

    # Resolve output path
    if not output_path:
        ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
        output_path = os.path.expanduser(f"~/cyberlens-report-{ts}.pdf")
    output_path = os.path.abspath(os.path.expanduser(output_path))
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    # Extract data
    target_type = scan_result.get("target_type", "website")
    url = scan_result.get("url", "Unknown")
    score = scan_result.get("score") or scan_result.get("security_score", "N/A")
    grade = scan_result.get("grade", "N/A")
    trust_score = scan_result.get("trust_score")
    assessment = scan_result.get("assessment", "")
    source = scan_result.get("source", "cloud")
    generated_at = scan_result.get("generated_at") or scan_result.get("scan_date") or datetime.now(timezone.utc).isoformat()
    findings = scan_result.get("findings", [])
    ai_analysis = scan_result.get("ai_analysis") or scan_result.get("ai_insights")
    summary = scan_result.get("summary")
    type_label = {"skill": "Claw Hub Skill", "repository": "Repository", "website": "Website"}.get(target_type, target_type.title())

    # Grade colour
    grade_colour = {
        "A": colors.HexColor("#22c55e"),
        "B": colors.HexColor("#84cc16"),
        "C": colors.HexColor("#eab308"),
        "D": colors.HexColor("#f97316"),
        "F": colors.HexColor("#ef4444"),
    }.get(grade, colors.grey)

    severity_colour = {
        "critical": colors.HexColor("#ef4444"),
        "high": colors.HexColor("#f97316"),
        "medium": colors.HexColor("#eab308"),
        "low": colors.HexColor("#3b82f6"),
        "info": colors.HexColor("#6b7280"),
    }

    # Build PDF
    styles = getSampleStyleSheet()
    title_style = ParagraphStyle("CL_Title", parent=styles["Title"], fontSize=22, spaceAfter=6)
    heading_style = ParagraphStyle("CL_Heading", parent=styles["Heading2"], fontSize=14, spaceBefore=16, spaceAfter=6)
    body_style = ParagraphStyle("CL_Body", parent=styles["Normal"], fontSize=10, spaceAfter=4)
    small_style = ParagraphStyle("CL_Small", parent=styles["Normal"], fontSize=8, textColor=colors.grey)

    story: list = []

    # Title
    story.append(Paragraph("CyberLens Security Report", title_style))
    story.append(Spacer(1, 8))

    # Meta table
    meta_data = [
        ["Target", url],
        ["Type", type_label],
        ["Scan Source", source],
        ["Date", str(generated_at)],
    ]
    meta_table = Table(meta_data, colWidths=[1.4 * inch, 5.0 * inch])
    meta_table.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, -1), 10),
        ("VALIGN", (0, 0), (-1, -1), "TOP"),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
    ]))
    story.append(meta_table)
    story.append(Spacer(1, 12))

    # Score card
    story.append(Paragraph("Score", heading_style))
    score_data = [["Security Score", f"{score} / 100"], ["Grade", str(grade)]]
    if trust_score is not None:
        score_data.append(["Trust Score", f"{trust_score} / 100"])
    if assessment:
        score_data.append(["Assessment", assessment])
    score_table = Table(score_data, colWidths=[1.8 * inch, 4.6 * inch])
    score_table.setStyle(TableStyle([
        ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
        ("FONTSIZE", (0, 0), (-1, -1), 10),
        ("BACKGROUND", (1, 0), (1, 0), grade_colour),
        ("TEXTCOLOR", (1, 0), (1, 0), colors.white),
        ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
        ("BOTTOMPADDING", (0, 0), (-1, -1), 6),
        ("TOPPADDING", (0, 0), (-1, -1), 6),
        ("BOX", (0, 0), (-1, -1), 0.5, colors.grey),
        ("INNERGRID", (0, 0), (-1, -1), 0.25, colors.lightgrey),
    ]))
    story.append(score_table)
    story.append(Spacer(1, 12))

    # AI Analysis
    if ai_analysis:
        story.append(Paragraph("AI Analysis", heading_style))
        if isinstance(ai_analysis, dict):
            for key, value in ai_analysis.items():
                story.append(Paragraph(f"<b>{key.replace('_', ' ').title()}:</b> {value}", body_style))
        else:
            story.append(Paragraph(str(ai_analysis), body_style))
        story.append(Spacer(1, 8))

    # Summary
    if summary and isinstance(summary, dict):
        story.append(Paragraph("Summary", heading_style))
        for key, value in summary.items():
            story.append(Paragraph(f"<b>{key.replace('_', ' ').title()}:</b> {value}", body_style))
        story.append(Spacer(1, 8))

    # Findings
    story.append(Paragraph(f"Findings ({len(findings)} total)", heading_style))
    if findings:
        sorted_findings = sorted(findings, key=_severity_sort_key)
        for i, f in enumerate(sorted_findings, 1):
            sev = f.get("severity", "info")
            msg = f.get("message") or f.get("description", "No description")
            sev_color = severity_colour.get(sev, colors.grey)

            # Finding header row
            header_data = [[f"#{i}", sev.upper(), msg]]
            header_table = Table(header_data, colWidths=[0.4 * inch, 0.8 * inch, 5.2 * inch])
            header_table.setStyle(TableStyle([
                ("FONTNAME", (0, 0), (-1, -1), "Helvetica-Bold"),
                ("FONTSIZE", (0, 0), (-1, -1), 10),
                ("BACKGROUND", (1, 0), (1, 0), sev_color),
                ("TEXTCOLOR", (1, 0), (1, 0), colors.white),
                ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
                ("TOPPADDING", (0, 0), (-1, -1), 4),
                ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
            ]))
            story.append(header_table)

            detail_parts = []
            if f.get("type"):
                detail_parts.append(f"Type: {f['type']}")
            if f.get("description") and f.get("description") != msg:
                detail_parts.append(f"Details: {f['description']}")
            if f.get("recommendation") or f.get("remediation"):
                detail_parts.append(f"Recommendation: {f.get('recommendation') or f.get('remediation')}")
            if f.get("cve"):
                detail_parts.append(f"CVE: {f['cve']}")
            if detail_parts:
                story.append(Paragraph("<br/>".join(detail_parts), body_style))
            story.append(Spacer(1, 6))
    else:
        story.append(Paragraph("No findings detected.", body_style))

    # Footer
    story.append(Spacer(1, 24))
    story.append(Paragraph("Generated by CyberLens Security Scanner — https://cyberlensai.com", small_style))

    doc = SimpleDocTemplate(
        output_path,
        pagesize=letter,
        leftMargin=0.75 * inch,
        rightMargin=0.75 * inch,
        topMargin=0.75 * inch,
        bottomMargin=0.75 * inch,
    )
    doc.build(story)

    return {
        "success": True,
        "path": output_path,
        "url": url,
        "target_type": target_type,
        "score": score,
        "grade": grade,
    }


path: tests/test_api_client.py
content: """Tests for CyberLens API base URL resolution."""

import pytest
import httpx

from src.api_client import CyberLensAPIClient, CyberLensQuotaExceededError, DEFAULT_API_BASE


def test_client_uses_default_api_base(monkeypatch):
    monkeypatch.delenv("CYBERLENS_API_BASE_URL", raising=False)

    client = CyberLensAPIClient("clns_acct_test")

    assert client.api_base == DEFAULT_API_BASE


def test_client_uses_env_api_base_override(monkeypatch):
    monkeypatch.setenv(
        "CYBERLENS_API_BASE_URL",
        "https://alt-api.cyberlensai.com/functions/v1/public-api-scan",
    )

    client = CyberLensAPIClient("clns_acct_test")

    assert client.api_base == "https://alt-api.cyberlensai.com/functions/v1/public-api-scan"


def test_client_rejects_non_https_api_base():
    with pytest.raises(ValueError, match="https:// URL"):
        CyberLensAPIClient(
            "clns_acct_test",
            api_base="http://localhost:8000/functions/v1/public-api-scan",
        )


def test_client_raises_structured_quota_error():
    client = CyberLensAPIClient("clns_acct_test")
    response = httpx.Response(
        402,
        json={
            "error": {
                "code": "QUOTA_EXCEEDED",
                "message": "Monthly website scan limit reached (3/3). Upgrade your plan to continue scanning immediately.",
                "upgrade_url": "https://www.cyberlensai.com/pricing?source=api_quota_exceeded&quota_type=website#plans",
                "quota_type": "website",
                "used": 3,
                "limit": 3,
            }
        },
    )

    with pytest.raises(CyberLensQuotaExceededError) as exc_info:
        client._read_response_data(response)

    error = exc_info.value
    assert str(error) == "Monthly website scan limit reached (3/3). Upgrade your plan to continue scanning immediately."
    assert error.upgrade_url.endswith("quota_type=website#plans")
    assert error.quota_type == "website"
    assert error.used == 3
    assert error.limit == 3


path: tests/test_auth.py
content: """Tests for the CyberLens connect flow helpers."""

import pytest

from src import auth


class _MockResponse:
    def __init__(self, status_code, payload):
        self.status_code = status_code
        self._payload = payload

    def json(self):
        return self._payload


class _MockAsyncClient:
    def __init__(self, response, calls, *args, **kwargs):
        self._response = response
        self._calls = calls

    async def __aenter__(self):
        return self

    async def __aexit__(self, exc_type, exc, tb):
        return False

    async def post(self, url, json):
        self._calls["url"] = url
        self._calls["json"] = json
        return self._response


def test_resolve_callback_config_defaults_to_localhost(monkeypatch):
    monkeypatch.delenv("CYBERLENS_CONNECT_CALLBACK_URL", raising=False)
    monkeypatch.delenv("CYBERLENS_CONNECT_BIND_HOST", raising=False)
    monkeypatch.delenv("CYBERLENS_CONNECT_BIND_PORT", raising=False)
    monkeypatch.setattr(auth, "_find_open_port", lambda: 54321)

    bind_host, bind_port, callback_url = auth._resolve_callback_config()

    assert bind_host == "127.0.0.1"
    assert bind_port == 54321
    assert callback_url == "http://localhost:54321/callback"


def test_resolve_callback_config_uses_remote_callback(monkeypatch):
    monkeypatch.setenv(
        "CYBERLENS_CONNECT_CALLBACK_URL",
        "http://10.0.0.5:55432/callback",
    )
    monkeypatch.delenv("CYBERLENS_CONNECT_BIND_HOST", raising=False)
    monkeypatch.delenv("CYBERLENS_CONNECT_BIND_PORT", raising=False)

    bind_host, bind_port, callback_url = auth._resolve_callback_config()

    assert bind_host == "0.0.0.0"
    assert bind_port == 55432
    assert callback_url == "http://10.0.0.5:55432/callback"


def test_resolve_callback_config_supports_proxy_bind_override(monkeypatch):
    monkeypatch.setenv(
        "CYBERLENS_CONNECT_CALLBACK_URL",
        "https://openclaw.example.com/cyberlens/callback",
    )
    monkeypatch.setenv("CYBERLENS_CONNECT_BIND_HOST", "127.0.0.1")
    monkeypatch.setenv("CYBERLENS_CONNECT_BIND_PORT", "54321")

    bind_host, bind_port, callback_url = auth._resolve_callback_config()

    assert bind_host == "127.0.0.1"
    assert bind_port == 54321
    assert callback_url == "https://openclaw.example.com/cyberlens/callback"


def test_resolve_callback_config_requires_bind_port_when_callback_has_no_port(monkeypatch):
    monkeypatch.setenv(
        "CYBERLENS_CONNECT_CALLBACK_URL",
        "https://openclaw.example.com/cyberlens/callback",
    )
    monkeypatch.delenv("CYBERLENS_CONNECT_BIND_HOST", raising=False)
    monkeypatch.delenv("CYBERLENS_CONNECT_BIND_PORT", raising=False)

    with pytest.raises(ValueError, match="include a port"):
        auth._resolve_callback_config()


@pytest.mark.asyncio
async def test_exchange_connect_code_returns_full_key(monkeypatch):
    calls = {}

    def _client_factory(*args, **kwargs):
        return _MockAsyncClient(
            _MockResponse(200, {"fullKey": "clns_acct_test"}),
            calls,
            *args,
            **kwargs,
        )

    monkeypatch.setattr(auth.httpx, "AsyncClient", _client_factory)

    key = await auth._exchange_connect_code(
        "clcn_test",
        "https://api.cyberlensai.com/functions/v1/integration-connect/exchange",
    )

    assert key == "clns_acct_test"
    assert calls["url"] == "https://api.cyberlensai.com/functions/v1/integration-connect/exchange"
    assert calls["json"] == {"code": "clcn_test"}


@pytest.mark.asyncio
async def test_exchange_connect_code_raises_for_expired_code(monkeypatch):
    def _client_factory(*args, **kwargs):
        return _MockAsyncClient(
            _MockResponse(410, {"error": "Exchange code has expired."}),
            {},
            *args,
            **kwargs,
        )

    monkeypatch.setattr(auth.httpx, "AsyncClient", _client_factory)

    with pytest.raises(RuntimeError, match="expired"):
        await auth._exchange_connect_code(
            "clcn_test",
            "https://api.cyberlensai.com/functions/v1/integration-connect/exchange",
        )


@pytest.mark.asyncio
async def test_exchange_connect_code_rejects_untrusted_host():
    with pytest.raises(ValueError, match="untrusted exchange host"):
        await auth._exchange_connect_code(
            "clcn_test",
            "https://evil.example.com/functions/v1/integration-connect/exchange",
        )


path: tests/test_scanner.py
content: """Tests for CyberLens scanner module."""

import pytest
import httpx
from src import __version__
from src import scanner as scanner_module
from src.scanner import SecurityScanner, Finding, ScanResult


class _FakeAsyncClient:
    """Minimal async HTTP client stub for scanner tests."""

    def __init__(self, response=None, error=None):
        self._response = response
        self._error = error

    async def get(self, url):
        if self._error:
            raise self._error
        return self._response

    async def aclose(self):
        return None


def _build_response(url: str, headers=None, text: str = "<html></html>") -> httpx.Response:
    """Create an httpx response with an attached request and URL."""
    return httpx.Response(
        200,
        headers=headers or {},
        text=text,
        request=httpx.Request("GET", url),
    )


def _patch_async_client(monkeypatch, response=None, error=None):
    """Patch the scanner's AsyncClient factory with a deterministic stub."""
    monkeypatch.setattr(
        scanner_module.httpx,
        "AsyncClient",
        lambda *args, **kwargs: _FakeAsyncClient(response=response, error=error),
    )


class TestFinding:
    """Tests for Finding dataclass."""
    
    def test_finding_creation(self):
        """Test creating a Finding."""
        finding = Finding(
            type="test-finding",
            severity="high",
            description="Test description",
            remediation="Fix it",
        )
        
        assert finding.type == "test-finding"
        assert finding.severity == "high"
        assert finding.description == "Test description"
        assert finding.remediation == "Fix it"
        assert finding.references == []
    
    def test_finding_with_references(self):
        """Test creating a Finding with references."""
        finding = Finding(
            type="test-finding",
            severity="medium",
            description="Test",
            remediation="Fix",
            references=["https://example.com"],
        )
        
        assert finding.references == ["https://example.com"]


class TestScanResult:
    """Tests for ScanResult dataclass."""
    
    def test_scan_result_creation(self):
        """Test creating a ScanResult."""
        result = ScanResult(
            url="https://example.com",
            score=85,
            grade="B",
            findings=[],
        )
        
        assert result.url == "https://example.com"
        assert result.score == 85
        assert result.grade == "B"
        assert result.findings == []
    
    def test_scan_result_with_findings(self):
        """Test ScanResult with findings."""
        finding = Finding(
            type="test",
            severity="low",
            description="Test finding",
            remediation="Fix it",
        )
        
        result = ScanResult(
            url="https://example.com",
            score=90,
            grade="A",
            findings=[finding],
        )
        
        assert len(result.findings) == 1
        assert result.findings[0].type == "test"


class TestSecurityScannerInit:
    """Tests for SecurityScanner initialization."""
    
    def test_default_init(self):
        """Test default initialization."""
        scanner = SecurityScanner()
        
        assert scanner.timeout == 30.0
        assert scanner.max_redirects == 5
        assert scanner.user_agent == f"CyberLens-Skill/{__version__}"
    
    def test_custom_init(self):
        """Test initialization with custom values."""
        scanner = SecurityScanner(
            timeout=60.0,
            max_redirects=10,
            user_agent="CustomAgent/1.0",
        )
        
        assert scanner.timeout == 60.0
        assert scanner.max_redirects == 10
        assert scanner.user_agent == "CustomAgent/1.0"


class TestCalculateScore:
    """Tests for score calculation."""
    
    def test_perfect_score(self):
        """Test score with no findings."""
        scanner = SecurityScanner()
        score, grade = scanner._calculate_score([])
        
        assert score == 100
        assert grade == "A"
    
    def test_critical_finding(self):
        """Test score with critical finding."""
        scanner = SecurityScanner()
        finding = Finding(
            type="test",
            severity="critical",
            description="Test",
            remediation="Fix",
        )
        score, grade = scanner._calculate_score([finding])
        
        assert score == 75
        assert grade == "C"
    
    def test_multiple_findings(self):
        """Test score with multiple findings."""
        scanner = SecurityScanner()
        findings = [
            Finding(type="h1", severity="high", description="High", remediation="Fix"),
            Finding(type="m1", severity="medium", description="Medium", remediation="Fix"),
            Finding(type="l1", severity="low", description="Low", remediation="Fix"),
        ]
        score, grade = scanner._calculate_score(findings)
        
        assert score == 100 - 15 - 8 - 3  # 74
        assert grade == "C"
    
    def test_minimum_score(self):
        """Test score doesn't go below 0."""
        scanner = SecurityScanner()
        findings = [
            Finding(type=f"c{i}", severity="critical", description="Critical", remediation="Fix")
            for i in range(10)
        ]
        score, grade = scanner._calculate_score(findings)
        
        assert score == 0
        assert grade == "F"


class TestCheckHTTPS:
    """Tests for HTTPS checking."""
    
    def test_http_url(self):
        """Test HTTP URL generates finding."""
        scanner = SecurityScanner()
        findings = scanner._check_https("http://example.com", {})
        
        assert len(findings) == 1
        assert findings[0].type == "no-https"
        assert findings[0].severity == "critical"
    
    def test_https_url(self):
        """Test HTTPS URL has no findings."""
        scanner = SecurityScanner()
        findings = scanner._check_https("https://example.com", {})
        
        assert len(findings) == 0


class TestCheckHeaders:
    """Tests for header security checks."""
    
    def test_all_headers_present(self):
        """Test no findings when all headers present."""
        scanner = SecurityScanner()
        headers = {
            "Content-Security-Policy": "default-src 'self'",
            "Strict-Transport-Security": "max-age=31536000",
            "X-Frame-Options": "DENY",
            "X-Content-Type-Options": "nosniff",
            "Referrer-Policy": "strict-origin",
            "Permissions-Policy": "camera=()",
        }
        findings = scanner._check_headers(headers)
        
        assert len(findings) == 0
    
    def test_missing_all_headers(self):
        """Test findings when all headers missing."""
        scanner = SecurityScanner()
        findings = scanner._check_headers({})
        
        assert len(findings) == 6  # All 6 recommended headers
        severities = [f.severity for f in findings]
        assert "high" in severities  # HSTS is high
        assert "medium" in severities  # CSP and X-Frame-Options are medium
    
    def test_weak_x_frame_options(self):
        """Test weak X-Frame-Options value."""
        scanner = SecurityScanner()
        headers = {
            "X-Frame-Options": "ALLOW-FROM https://example.com",
        }
        findings = scanner._check_headers(headers)
        
        weak_framing = [f for f in findings if f.type == "weak-x-frame-options"]
        assert len(weak_framing) == 1


class TestDetectTechnologies:
    """Tests for technology detection."""
    
    def test_nginx_detection(self):
        """Test nginx server detection."""
        scanner = SecurityScanner()
        headers = {"Server": "nginx/1.18.0"}
        techs = scanner._detect_technologies(headers)
        
        assert "nginx" in techs
    
    def test_php_detection(self):
        """Test PHP detection via X-Powered-By."""
        scanner = SecurityScanner()
        headers = {"X-Powered-By": "PHP/7.4.3"}
        techs = scanner._detect_technologies(headers)
        
        assert "php" in techs
    
    def test_multiple_technologies(self):
        """Test detection of multiple technologies."""
        scanner = SecurityScanner()
        headers = {
            "Server": "nginx/1.18.0",
            "X-Powered-By": "Express",
        }
        techs = scanner._detect_technologies(headers)
        
        assert "nginx" in techs
        assert "express" in techs


class TestInformationDisclosure:
    """Tests for information disclosure detection."""
    
    def test_x_powered_by_detected(self):
        """Test X-Powered-By detection."""
        scanner = SecurityScanner()
        headers = {"X-Powered-By": "PHP/7.4"}
        findings = scanner._check_information_disclosure(headers)
        
        assert len(findings) >= 1
        xpb = [f for f in findings if "x-powered-by" in f.type or "information-disclosure" in f.type]
        assert len(xpb) >= 1
    
    def test_server_version_detected(self):
        """Test Server version detection."""
        scanner = SecurityScanner()
        headers = {"Server": "Apache/2.4.41"}
        findings = scanner._check_information_disclosure(headers)
        
        version_finding = [f for f in findings if "version" in f.type]
        assert len(version_finding) >= 1


@pytest.mark.asyncio
class TestAsyncScan:
    """Tests for async scan functionality."""
    
    async def test_scan_valid_url(self, monkeypatch):
        """Test scanning a valid URL."""
        _patch_async_client(
            monkeypatch,
            response=_build_response(
                "https://example.com",
                headers={
                    "Content-Security-Policy": "default-src 'self'",
                    "Strict-Transport-Security": "max-age=31536000",
                    "X-Frame-Options": "DENY",
                    "X-Content-Type-Options": "nosniff",
                    "Referrer-Policy": "strict-origin",
                    "Permissions-Policy": "camera=()",
                    "Server": "nginx",
                },
                text="<html><body>Hello</body></html>",
            ),
        )

        async with SecurityScanner() as scanner:
            result = await scanner.scan("https://example.com")
            
            assert isinstance(result, ScanResult)
            assert result.error is None
            assert 0 <= result.score <= 100
            assert result.grade in ["A", "B", "C", "D", "F"]
            assert result.scan_time_ms >= 0
    
    async def test_scan_invalid_url(self):
        """Test scanning invalid URL format."""
        async with SecurityScanner() as scanner:
            result = await scanner.scan("not-a-url")
            
            assert result.error is not None
            assert "http://" in result.error or "https://" in result.error
    
    async def test_scan_timeout(self, monkeypatch):
        """Test scan timeout handling."""
        _patch_async_client(
            monkeypatch,
            error=httpx.TimeoutException("Request timed out"),
        )

        async with SecurityScanner(timeout=0.1) as scanner:
            result = await scanner.scan("https://example.com/slow")
            
            assert result.error is not None
            assert "timeout" in result.error.lower() or "timed out" in result.error.lower()
    
    async def test_get_score(self, monkeypatch):
        """Test get_score method."""
        _patch_async_client(
            monkeypatch,
            response=_build_response(
                "https://example.com",
                headers={"Server": "nginx"},
            ),
        )

        async with SecurityScanner() as scanner:
            score, grade = await scanner.get_score("https://example.com")
            
            assert isinstance(score, int)
            assert 0 <= score <= 100
            assert grade in ["A", "B", "C", "D", "F"]


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)