Merge pull request #49 from SquidSec/feature/d-ci-e2e-polish

ci: coverage, mypy, SBOM, e2e/fuzz/load tests
This commit is contained in:
☣️ Mr. The Plague ☣️
2026-08-01 12:32:01 -05:00
committed by GitHub
9 changed files with 195 additions and 22 deletions
+18
View File
@@ -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)
+11
View File
@@ -0,0 +1,11 @@
---
name: Feature request
about: Propose an enhancement for authorized red-team operations
labels: enhancement
---
**Problem**
**Proposed solution**
**Security / OPSEC considerations**
+18 -2
View File
@@ -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"
+5 -1
View File
@@ -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
+15 -15
View File
@@ -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
-4
View File
@@ -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),
+57
View File
@@ -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 "")
+33
View File
@@ -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")
+38
View File
@@ -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