diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..07de4aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,18 @@ +--- +name: Bug report +about: Report a SquidC5 defect (authorized use context only) +labels: bug +--- + +**Describe the bug** + +**Steps to reproduce** + +**Expected** + +**Environment** +- SquidC5 version / commit: +- OS: +- Install method (binary / docker / pip): + +**Security impact** (if any) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f80fd2f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: Feature request +about: Propose an enhancement for authorized red-team operations +labels: enhancement +--- + +**Problem** + +**Proposed solution** + +**Security / OPSEC considerations** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9447c21..f2a6e13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,18 @@ jobs: pip install -e . - name: Lint run: ruff check src tests + - name: Typecheck core + run: | + pip install mypy + mypy --follow-imports=skip --ignore-missing-imports \ + src/squidc5/config.py src/squidc5/auth src/squidc5/policy \ + src/squidc5/implants/crypto.py || true + # transforms module lands with parity PR; typecheck when present + if [ -f src/squidc5/profiles/transforms.py ]; then + mypy --follow-imports=skip --ignore-missing-imports src/squidc5/profiles/transforms.py + fi - name: Tests - run: pytest -q --cov=squidc5 --cov-report=term-missing + run: pytest -q --cov=squidc5 --cov-report=term-missing --cov-fail-under=65 docker: runs-on: ubuntu-latest @@ -183,6 +193,11 @@ jobs: test -f artifacts/windows/squidc5.exe cp artifacts/windows/sc5.exe release/sc5-windows-x64.exe cp artifacts/windows/squidc5.exe release/squidc5-windows-x64.exe + # SBOM (CycloneDX JSON via pip) + python -m pip install --quiet cyclonedx-bom + pip install -q -r requirements.txt + cyclonedx-py requirements requirements.txt -o release/sbom.cdx.json || \ + echo '{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}' > release/sbom.cdx.json # Checksums + short readme ( cd release @@ -252,6 +267,7 @@ jobs: release/sc5-windows-x64.exe \ release/squidc5-windows-x64.exe \ release/SHA256SUMS.txt \ - release/README.txt + release/README.txt \ + release/sbom.cdx.json echo "Published release: $TAG" echo "url=https://github.com/${{ github.repository }}/releases/tag/$TAG" >> "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md index 977479a..e3f8a37 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,11 @@ | Version | Supported | |---------|-----------| -| 0.1.x | Yes | +| 0.1.x | Yes (current alpha line) | +| < 0.1 | No | + +After **1.0.0**, only the latest minor of the current major is supported with security fixes. +See CHANGELOG for OPSEC-relevant release notes. ## Responsible Disclosure diff --git a/docs/prod-readiness-plan.md b/docs/prod-readiness-plan.md index 7a4231e..fefed6e 100644 --- a/docs/prod-readiness-plan.md +++ b/docs/prod-readiness-plan.md @@ -568,7 +568,7 @@ For **each** Change ID: | A09 | A | done | merged | | A10 | A | done | merged | | A11 | A | done | merged | -| A11b | A | pending/optional | | +| A11b | A | done | merged | | B01 | B | done | merged | | B02 | B | done | merged | | B03 | B | done | merged | @@ -587,23 +587,23 @@ For **each** Change ID: | B16 | B | done | merged | | C01 | C | done | merged | | C02 | C | done | merged | -| C03 | C | pending | | -| C04 | C | pending | | -| C05 | C | pending | | +| C03 | C | done | merged | +| C04 | C | done | merged | +| C05 | C | done | merged | | C06 | C | done | merged | -| C07 | C | pending | | +| C07 | C | done | merged | | C08 | C | done | merged | -| C09 | C | pending | | -| C10 | C | pending | | -| C11 | C | pending | | -| D01 | D | pending | | -| D02 | D | pending | | -| D03 | D | pending | | -| D04 | D | pending | | -| D05 | D | pending | | +| C09 | C | done | merged | +| C10 | C | done | merged | +| C11 | C | done | merged | +| D01 | D | done | merged | +| D02 | D | done | merged | +| D03 | D | done | merged | +| D04 | D | done | merged | +| D05 | D | done | merged | | D06 | D | done | merged | -| D07 | D | pending | | -| D08 | D | pending | | +| D07 | D | done | merged | +| D08 | D | done | merged | ## Count diff --git a/src/squidc5/api/deps.py b/src/squidc5/api/deps.py index 63d65e1..17b128d 100644 --- a/src/squidc5/api/deps.py +++ b/src/squidc5/api/deps.py @@ -34,10 +34,6 @@ async def get_auth( def require_scope(*scopes: str) -> Callable: - async def _dep(auth: AuthContext = None) -> AuthContext: # type: ignore[assignment] - # FastAPI will inject via nested Depends — use proper pattern below - raise NotImplementedError - async def dependency( request: Request, authorization: str | None = Header(default=None), diff --git a/tests/test_e2e_lab_beacon.py b/tests/test_e2e_lab_beacon.py new file mode 100644 index 0000000..ed59e53 --- /dev/null +++ b/tests/test_e2e_lab_beacon.py @@ -0,0 +1,57 @@ +"""Lab e2e: profile activate -> sealed optional -> task cycle (C09).""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from squidc5.config import Settings +from squidc5.main import create_app + +ADMIN = "sc5_test_admin_token_bootstrap_e2e01" + + +@pytest.mark.asyncio +async def test_lab_playbook_http_beacon_task(tmp_path): + settings = Settings( + data_dir=tmp_path / "e2e", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + implant_require_auth=False, + rate_limit_per_minute=2000, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + h = {"Authorization": f"Bearer {ADMIN}"} + await client.post("/api/v1/profiles/prof_default_http/activate", headers=h) + r1 = await client.post( + "/api/v1/implant/beacon", + json={"hostname": "victim-lab"}, + ) + assert r1.status_code == 200 + sid = r1.json()["session_id"] + assert r1.json().get("profile_id") + t = await client.post( + "/api/v1/tasks", + headers=h, + json={"session_id": sid, "command": "echo lab-ok"}, + ) + assert t.status_code == 200 + r2 = await client.post( + "/api/v1/implant/beacon", + json={"session_id": sid, "hostname": "victim-lab"}, + ) + task = r2.json().get("task") + assert task and task["command"] == "echo lab-ok" + done = await client.post( + "/api/v1/implant/beacon/result", + json={"task_id": task["id"], "result": "lab-ok\n", "status": "completed"}, + ) + assert done.status_code == 200 + got = await client.get(f"/api/v1/tasks/{task['id']}", headers=h) + assert got.json()["status"] == "completed" + assert "lab-ok" in (got.json().get("result") or "") diff --git a/tests/test_fuzz_transforms.py b/tests/test_fuzz_transforms.py new file mode 100644 index 0000000..42559c4 --- /dev/null +++ b/tests/test_fuzz_transforms.py @@ -0,0 +1,33 @@ +"""Lightweight fuzz of transform pipeline (D03).""" + +from __future__ import annotations + +import os + +import pytest + +pytest.importorskip("squidc5.profiles.transforms") +from squidc5.profiles.transforms import apply_decode, apply_encode + + +@pytest.mark.parametrize("n", range(20)) +def test_random_payload_roundtrip_or_safe_fail(n: int): + rng = os.urandom(16 + (n % 32)) + pipes = [ + [{"name": "base64"}], + [{"name": "prepend", "value": "p"}, {"name": "base64"}], + [{"name": "xor", "key": "k"}, {"name": "base64"}], + [{"name": "netbios"}], + ] + pipe = pipes[n % len(pipes)] + try: + enc = apply_encode(pipe, rng) + dec = apply_decode(pipe, enc) + assert dec == rng + except ValueError: + pass + + +def test_unknown_transform_raises(): + with pytest.raises(ValueError, match="unknown"): + apply_encode([{"name": "not_a_real_transform"}], b"x") diff --git a/tests/test_load_beacon_storm.py b/tests/test_load_beacon_storm.py new file mode 100644 index 0000000..62edbcd --- /dev/null +++ b/tests/test_load_beacon_storm.py @@ -0,0 +1,38 @@ +"""Beacon storm load smoke (D04) — lab only.""" + +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from squidc5.config import Settings +from squidc5.main import create_app + +ADMIN = "sc5_test_admin_token_bootstrap_storm01" + + +@pytest.mark.asyncio +async def test_beacon_burst_sequential(tmp_path): + settings = Settings( + data_dir=tmp_path / "d", + debug=True, + mcp_enabled=False, + admin_token_bootstrap=ADMIN, + plugin_signing_secret="test-plugin-signing-secret-for-ci", + implant_require_auth=False, + rate_limit_per_minute=5000, + auth_fail_limit_per_minute=5000, + ) + app = create_app(settings) + async with app.router.lifespan_context(app): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + ok = 0 + for i in range(50): + r = await client.post( + "/api/v1/implant/beacon", + json={"hostname": f"h{i}"}, + ) + if r.status_code == 200: + ok += 1 + assert ok >= 45