Skip to content

Commit ebf32d5

Browse files
authored
Merge branch 'main' into fix/backend-ci-split-tests
2 parents 210ea3a + 6541979 commit ebf32d5

24 files changed

Lines changed: 1899 additions & 19 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ jobs:
4343
git fetch origin "${{ github.base_ref }}" --depth=1
4444
git diff --check "origin/${{ github.base_ref }}"...HEAD
4545
46+
issue-template-label-validation:
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
- uses: actions/setup-python@v5
51+
with:
52+
python-version: "3.11"
53+
- name: Validate issue template labels
54+
run: python scripts/validate_issue_template_labels.py
55+
4656
backend-lint:
4757
needs: detect-changes
4858
if: needs.detect-changes.outputs.run_backend == 'true'

.github/workflows/smoke-test.yml

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
name: Onboarding Smoke Test
2+
3+
# Verifies the contributor setup path:
4+
# fresh clone → ./setup.sh → ./start.sh → services respond
5+
# Catches onboarding regressions before they hit contributors.
6+
7+
on:
8+
push:
9+
branches: [main]
10+
paths:
11+
- "setup.sh"
12+
- "start.sh"
13+
- "backend/**"
14+
- "frontend/**"
15+
- "backend/requirements.txt"
16+
- "backend/requirements-dev.txt"
17+
- ".github/workflows/smoke-test.yml"
18+
pull_request:
19+
branches: [main]
20+
paths:
21+
- "setup.sh"
22+
- "start.sh"
23+
- "backend/**"
24+
- "frontend/**"
25+
- "backend/requirements.txt"
26+
- "backend/requirements-dev.txt"
27+
- ".github/workflows/smoke-test.yml"
28+
workflow_dispatch:
29+
30+
jobs:
31+
smoke-test:
32+
name: Fresh-clone smoke test (Ubuntu)
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 15
35+
36+
steps:
37+
# ── 1. Checkout ────────────────────────────────────────────────────────
38+
- name: Checkout repository
39+
uses: actions/checkout@v4
40+
41+
# ── 2. Set up Python 3.11 ──────────────────────────────────────────────
42+
# setup.sh requires Python 3.11+. We pin it explicitly so the runner
43+
# never silently falls back to an older system Python.
44+
- name: Set up Python 3.11
45+
uses: actions/setup-python@v5
46+
with:
47+
python-version: "3.11"
48+
49+
# ── 3. Set up Node.js ──────────────────────────────────────────────────
50+
# setup.sh checks for node + npm; frontend uses Vite on port 5173.
51+
- name: Set up Node.js
52+
uses: actions/setup-node@v4
53+
with:
54+
node-version: "20"
55+
56+
# ── 4. Make scripts executable ─────────────────────────────────────────
57+
# Git on some clients strips execute bits — enforce them explicitly.
58+
- name: Make scripts executable
59+
run: chmod +x setup.sh start.sh
60+
61+
# ── 5. Run setup.sh ────────────────────────────────────────────────────
62+
# setup.sh: creates venv, pip installs backend/requirements.txt +
63+
# httpx[cli], npm installs frontend, writes .env, creates data/logs dirs.
64+
# lsof is used by start.sh; install it here so setup.sh can't fail on it.
65+
- name: Install system dependencies
66+
run: sudo apt-get install -y lsof libcairo2-dev pkg-config python3-dev
67+
68+
- name: Run setup.sh
69+
run: |
70+
echo "::group::setup.sh output"
71+
bash setup.sh
72+
echo "::endgroup::"
73+
74+
# ── 6. Verify setup produced expected artifacts ────────────────────────
75+
# Fails fast with a clear message if setup.sh silently skipped something.
76+
- name: Verify setup artifacts
77+
run: |
78+
echo "Checking venv..."
79+
test -f venv/bin/python3 || { echo "FAIL: venv/bin/python3 missing"; exit 1; }
80+
test -f venv/bin/activate || { echo "FAIL: venv/bin/activate missing"; exit 1; }
81+
82+
echo "Checking backend deps..."
83+
source venv/bin/activate
84+
python3 -c "import fastapi" || { echo "FAIL: fastapi not installed"; exit 1; }
85+
python3 -c "import uvicorn" || { echo "FAIL: uvicorn not installed"; exit 1; }
86+
python3 -c "import httpx" || { echo "FAIL: httpx not installed"; exit 1; }
87+
deactivate
88+
89+
echo "Checking frontend node_modules..."
90+
test -f frontend/node_modules/.bin/vite \
91+
|| { echo "FAIL: frontend/node_modules/.bin/vite missing"; exit 1; }
92+
93+
echo "Checking directories..."
94+
for d in data data/raw data/reports logs wordlists; do
95+
test -d "$d" || { echo "FAIL: directory '$d' missing"; exit 1; }
96+
done
97+
98+
echo "Checking .env..."
99+
test -f .env || { echo "FAIL: .env not created"; exit 1; }
100+
grep -q "SECUSCAN_BIND_PORT=8000" .env \
101+
|| { echo "FAIL: expected port config missing from .env"; exit 1; }
102+
103+
echo "All artifact checks passed."
104+
105+
# ── 7. Start services via start.sh ─────────────────────────────────────
106+
# start.sh launches uvicorn on 127.0.0.1:8000 and Vite on 127.0.0.1:5173.
107+
# We background the whole script and capture its PID for cleanup.
108+
- name: Start services via start.sh
109+
run: |
110+
bash start.sh &
111+
echo "START_SH_PID=$!" >> "$GITHUB_ENV"
112+
113+
# ── 8. Wait for backend (uvicorn on :8000) ─────────────────────────────
114+
# start.sh starts uvicorn on 127.0.0.1:8000.
115+
# /openapi.json is always present in FastAPI without any auth — safer
116+
# than /health which may not exist.
117+
- name: Wait for backend to be ready
118+
run: |
119+
MAX_WAIT=60
120+
INTERVAL=3
121+
ELAPSED=0
122+
URL="http://127.0.0.1:8000/openapi.json"
123+
124+
echo "Polling $URL ..."
125+
until curl --silent --fail --max-time 2 "$URL" > /dev/null 2>&1; do
126+
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
127+
echo "ERROR: Backend did not become ready within ${MAX_WAIT}s"
128+
echo "--- Active processes ---"
129+
ps aux | grep -E "uvicorn|python" || true
130+
echo "--- Port 8000 status ---"
131+
lsof -i :8000 || true
132+
exit 1
133+
fi
134+
echo " Not ready (${ELAPSED}s elapsed) — retrying in ${INTERVAL}s ..."
135+
sleep "$INTERVAL"
136+
ELAPSED=$((ELAPSED + INTERVAL))
137+
done
138+
echo "Backend ready after ${ELAPSED}s."
139+
140+
# ── 9. Wait for frontend (Vite on :5173) ──────────────────────────────
141+
- name: Wait for frontend to be ready
142+
run: |
143+
MAX_WAIT=60
144+
INTERVAL=3
145+
ELAPSED=0
146+
URL="http://127.0.0.1:5173"
147+
148+
echo "Polling $URL ..."
149+
until curl --silent --fail --max-time 2 "$URL" > /dev/null 2>&1; do
150+
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
151+
echo "ERROR: Frontend did not become ready within ${MAX_WAIT}s"
152+
echo "--- Active processes ---"
153+
ps aux | grep -E "vite|node" || true
154+
echo "--- Port 5173 status ---"
155+
lsof -i :5173 || true
156+
exit 1
157+
fi
158+
echo " Not ready (${ELAPSED}s elapsed) — retrying in ${INTERVAL}s ..."
159+
sleep "$INTERVAL"
160+
ELAPSED=$((ELAPSED + INTERVAL))
161+
done
162+
echo "Frontend ready after ${ELAPSED}s."
163+
164+
# ── 10. Smoke-check backend API ────────────────────────────────────────
165+
# /openapi.json must contain "openapi" and "SecuScan" (the app title).
166+
# /docs must return HTTP 200 (Swagger UI).
167+
# These require zero auth and prove the real app stack loaded correctly.
168+
- name: Smoke-check backend API
169+
run: |
170+
echo "--- GET /openapi.json ---"
171+
OAS=$(curl --silent --fail --max-time 5 "http://127.0.0.1:8000/openapi.json")
172+
echo "$OAS" | python3 -c "import sys, json; d=json.load(sys.stdin); assert 'openapi' in d, 'missing openapi key'" \
173+
|| { echo "FAIL: /openapi.json invalid JSON or missing openapi key"; exit 1; }
174+
echo "openapi.json OK"
175+
176+
echo "--- GET /docs ---"
177+
curl --silent --fail --max-time 5 "http://127.0.0.1:8000/docs" > /dev/null \
178+
|| { echo "FAIL: /docs did not return 200"; exit 1; }
179+
echo "/docs OK"
180+
181+
echo "Backend smoke checks passed."
182+
183+
# ── 11. Smoke-check frontend ───────────────────────────────────────────
184+
- name: Smoke-check frontend
185+
run: |
186+
echo "--- GET http://127.0.0.1:5173 ---"
187+
BODY=$(curl --silent --fail --max-time 5 "http://127.0.0.1:5173")
188+
echo "$BODY" | grep -qi "html" \
189+
|| { echo "FAIL: frontend did not return an HTML page"; exit 1; }
190+
echo "Frontend smoke check passed."
191+
192+
# ── 12. Teardown ───────────────────────────────────────────────────────
193+
- name: Stop services
194+
if: always()
195+
run: |
196+
[ -n "${START_SH_PID:-}" ] && kill "$START_SH_PID" 2>/dev/null || true
197+
pkill -f "uvicorn" 2>/dev/null || true
198+
pkill -f "vite" 2>/dev/null || true
199+
pkill -f "npm" 2>/dev/null || true
200+
echo "Teardown complete."
201+
202+
# ── 13. Upload logs on failure ─────────────────────────────────────────
203+
- name: Upload logs on failure
204+
if: failure()
205+
uses: actions/upload-artifact@v4
206+
with:
207+
name: smoke-test-logs
208+
path: |
209+
logs/
210+
**/*.log
211+
nohup.out
212+
if-no-files-found: ignore
213+
retention-days: 7

CONTRIBUTING.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ SecuScan is built for learning, defensive security workflows, and ethical testin
2222

2323
When issue labels are available, look for tags such as `good first issue`, `documentation`, `frontend`, `backend`, `plugin`, `help wanted`, or `gssoc`.
2424

25+
## Issue Template Label Maintenance
26+
27+
Issue templates in `.github/ISSUE_TEMPLATE/` must only reference labels from the active repository taxonomy.
28+
29+
When adding or updating issue template labels:
30+
31+
- Use active label groups such as `type:*`, `area:*`, `priority:*`, and `level:*`.
32+
- Avoid deprecated labels such as `bug`, `feature`, `documentation`, and `help wanted`.
33+
- Keep template labels aligned with the labels used by maintainers and CI.
34+
35+
Before opening a pull request that changes issue templates, run:
36+
37+
```bash
38+
python scripts/validate_issue_template_labels.py
39+
```
40+
41+
The CI workflow also runs this validation and will fail if an issue template references a label that is not included in the approved label taxonomy.
42+
2543
## Local Setup
2644

2745
### Prerequisites

backend/secuscan/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class Settings(BaseSettings):
6363
admin_api_key: Optional[str] = None
6464

6565
# Network Policy Configuration
66-
network_allowlist: List[str] = [] # IPs/networks to allow (CIDR)
66+
network_allowlist: List[str] = [] # IPs/networks to allow (CIDR); empty = deny all egress
6767
network_denylist: List[str] = [ # IPs/networks to deny (CIDR)
6868
"169.254.169.254/32", # AWS metadata
6969
"169.254.0.0/16", # Reserved/metadata

backend/secuscan/network_policy.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -398,11 +398,9 @@ def _init_default_policies(engine: NetworkPolicyEngine) -> None:
398398
except ValueError:
399399
logger.warning(f"Skipping invalid allowlist CIDR: {cidr}")
400400

401-
# Add system defaults (if allowlist is empty, add public internet)
401+
# Warn if allowlist is empty ? network policy defaults to deny-all egress
402402
if not settings.network_allowlist:
403403
logger.warning(
404-
"SECUSCAN_NETWORK_ALLOWLIST is empty. Allowing all public IPs. "
405-
"Configure this environment variable to restrict egress."
404+
"SECUSCAN_NETWORK_ALLOWLIST is empty. All external network egress is blocked. "
405+
"Configure this environment variable with CIDR ranges to allow outbound traffic."
406406
)
407-
engine.add_allow_rule("0.0.0.0/0", reason="Default allow all (configure SECUSCAN_NETWORK_ALLOWLIST)")
408-
engine.add_allow_rule("::/0", reason="Default allow all IPv6")

backend/secuscan/validation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ def wildcard_to_net(pattern: str) -> ipaddress.IPv4Network | None:
7070
for pattern in patterns:
7171
try:
7272
allowed_net = ipaddress.ip_network(pattern, strict=False)
73+
if net.version != allowed_net.version:
74+
continue
7375
if net.subnet_of(allowed_net) or net.overlaps(allowed_net):
7476
return True
7577
except ValueError:

backend/secuscan/workflows.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds:
7272
return elapsed >= schedule_seconds
7373
async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]]):
7474
logger.info("Running workflow %s with %d step(s)", workflow_id, len(steps))
75+
db = await get_db()
7576
for step in steps:
7677
plugin_id = step.get("plugin_id")
7778
inputs = step.get("inputs") or {}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React from 'react'
2+
import { render, screen } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
4+
import { describe, it, expect, beforeEach } from 'vitest'
5+
import ThemeToggle from '../../../src/components/ThemeToggle'
6+
import { ThemeProvider } from '../../../src/components/ThemeContext'
7+
8+
const STORAGE_KEY = 'secuscan-theme'
9+
10+
function renderWithTheme() {
11+
return render(
12+
<ThemeProvider>
13+
<ThemeToggle />
14+
</ThemeProvider>,
15+
)
16+
}
17+
18+
describe('ThemeToggle', () => {
19+
beforeEach(() => {
20+
localStorage.removeItem(STORAGE_KEY)
21+
document.documentElement.classList.remove('dark', 'theme-light')
22+
})
23+
24+
it('renders a button with an accessible label', () => {
25+
renderWithTheme()
26+
const button = screen.getByRole('button')
27+
expect(button).toHaveAttribute('aria-label')
28+
})
29+
30+
it('toggles from dark to light on click and persists to localStorage', async () => {
31+
localStorage.setItem(STORAGE_KEY, 'dark')
32+
const user = userEvent.setup()
33+
renderWithTheme()
34+
35+
const button = screen.getByRole('button')
36+
expect(button).toHaveAttribute('aria-pressed', 'true')
37+
38+
await user.click(button)
39+
40+
expect(localStorage.getItem(STORAGE_KEY)).toBe('light')
41+
expect(button).toHaveAttribute('aria-pressed', 'false')
42+
})
43+
44+
it('toggles from light to dark on click and persists to localStorage', async () => {
45+
localStorage.setItem(STORAGE_KEY, 'light')
46+
const user = userEvent.setup()
47+
renderWithTheme()
48+
49+
const button = screen.getByRole('button')
50+
expect(button).toHaveAttribute('aria-pressed', 'false')
51+
52+
await user.click(button)
53+
54+
expect(localStorage.getItem(STORAGE_KEY)).toBe('dark')
55+
expect(button).toHaveAttribute('aria-pressed', 'true')
56+
})
57+
58+
it('aria-label reflects the target theme, not the current one', () => {
59+
localStorage.setItem(STORAGE_KEY, 'dark')
60+
renderWithTheme()
61+
const button = screen.getByRole('button')
62+
expect(button).toHaveAttribute('aria-label', 'Toggle to light mode')
63+
})
64+
65+
it('shows dark_mode icon when theme is light', () => {
66+
localStorage.setItem(STORAGE_KEY, 'light')
67+
renderWithTheme()
68+
expect(screen.getByText('dark_mode')).toBeTruthy()
69+
})
70+
71+
it('shows light_mode icon when theme is dark', () => {
72+
localStorage.setItem(STORAGE_KEY, 'dark')
73+
renderWithTheme()
74+
expect(screen.getByText('light_mode')).toBeTruthy()
75+
})
76+
77+
it('stops click propagation', async () => {
78+
localStorage.setItem(STORAGE_KEY, 'dark')
79+
const user = userEvent.setup()
80+
const parentHandler = vi.fn()
81+
render(
82+
<div onClick={parentHandler}>
83+
<ThemeProvider>
84+
<ThemeToggle />
85+
</ThemeProvider>
86+
</div>,
87+
)
88+
await user.click(screen.getByRole('button'))
89+
expect(parentHandler).not.toHaveBeenCalled()
90+
})
91+
92+
it('applies sm size classes when size prop is sm', () => {
93+
render(
94+
<ThemeProvider>
95+
<ThemeToggle size="sm" />
96+
</ThemeProvider>,
97+
)
98+
const button = screen.getByRole('button')
99+
expect(button.className).toContain('w-9')
100+
expect(button.className).toContain('h-9')
101+
})
102+
})

0 commit comments

Comments
 (0)