Skip to content

Commit 450d1fd

Browse files
SecAI-Hubclaude
andcommitted
Harden CI quality gates + fix bootstrap trust docs
1. Bootstrap instructions (README, bare-metal.md): - Default quickstart now shows signed rebase only - Unverified bootstrap moved to labeled first-time section - Explicit risk text and verification-first language throughout 2. Enforce vulnerability scanning in CI: - Bandit fails on HIGH severity + HIGH confidence findings - govulncheck and pip-audit fail on unwaived vulnerabilities - Add .github/vuln-waivers.json waiver mechanism with expiry dates 3. Add mypy type checking + pinned Python CI deps: - mypy gate for security-sensitive services (common, agent, quarantine, ui) - Fix 11 type errors across 9 source files - Pin all CI Python deps in requirements-ci.txt for reproducibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c580508 commit 450d1fd

13 files changed

Lines changed: 168 additions & 55 deletions

File tree

.github/vuln-waivers.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"_comment": "Vulnerability waivers for CI dependency-audit job. Each entry documents a reviewed finding that is temporarily accepted. Waivers MUST include: id, reason, reviewer, expires (YYYY-MM-DD). Expired waivers are ignored and the finding will fail CI again.",
3+
"go": [],
4+
"python": []
5+
}

.github/workflows/ci.yml

Lines changed: 91 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ jobs:
5353
with:
5454
python-version: "3.12"
5555

56-
- name: Install dependencies
57-
run: pip install pyyaml flask requests pytest ruff bandit
56+
- name: Install dependencies (pinned)
57+
run: pip install -r requirements-ci.txt
5858

5959
- name: Lint (syntax check)
6060
run: |
@@ -77,10 +77,35 @@ jobs:
7777

7878
- name: Bandit security scan
7979
run: |
80-
bandit -r services/ -ll --skip B101,B404,B603 -f txt || {
81-
echo "::warning::Bandit found potential security issues (see above)"
82-
true
83-
}
80+
# Fail on high-severity + high-confidence findings.
81+
# Medium/low findings are reported as warnings.
82+
bandit -r services/ -ll --skip B101,B404,B603 -f json -o /tmp/bandit.json || true
83+
python3 -c "
84+
import json, sys
85+
with open('/tmp/bandit.json') as f:
86+
data = json.load(f)
87+
high = [r for r in data.get('results', [])
88+
if r['issue_severity'] == 'HIGH' and r['issue_confidence'] == 'HIGH']
89+
for r in data.get('results', []):
90+
sev = r['issue_severity']
91+
msg = f\"{r['filename']}:{r['line_number']}: [{sev}] {r['issue_text']}\"
92+
if sev == 'HIGH':
93+
print(f'::error ::{msg}')
94+
else:
95+
print(f'::warning ::{msg}')
96+
if high:
97+
print(f'FAIL: {len(high)} high-severity/high-confidence finding(s)')
98+
sys.exit(1)
99+
print('OK: no high-severity/high-confidence findings')
100+
"
101+
102+
- name: Mypy type check (security-sensitive services)
103+
run: |
104+
mypy --ignore-missing-imports \
105+
services/common/ \
106+
services/agent/agent/ \
107+
services/quarantine/quarantine/ \
108+
services/ui/ui/
84109
85110
- name: Test (unit + integration)
86111
env:
@@ -233,7 +258,7 @@ jobs:
233258
go-version: "1.23"
234259

235260
- name: Install Python dependencies
236-
run: pip install pyyaml flask requests pytest
261+
run: pip install -r requirements-ci.txt
237262

238263
- name: Run adversarial Python tests
239264
run: python -m pytest tests/test_adversarial.py -v --tb=short
@@ -267,7 +292,7 @@ jobs:
267292
python-version: "3.12"
268293

269294
- name: Install Python dependencies
270-
run: pip install pyyaml flask requests pytest
295+
run: pip install -r requirements-ci.txt
271296

272297
- name: Check test counts for drift
273298
run: bash .github/scripts/check-test-counts.sh
@@ -291,26 +316,77 @@ jobs:
291316
- name: Install govulncheck
292317
run: go install golang.org/x/vuln/cmd/govulncheck@latest
293318

294-
- name: Go vulnerability scan
319+
- name: Go vulnerability scan (enforced)
295320
run: |
296321
echo "=== Go Dependency Vulnerability Scan ==="
297322
VULN_ERRORS=0
298323
for svc in airlock registry tool-firewall gpu-integrity-watch mcp-firewall \
299324
policy-engine runtime-attestor integrity-monitor incident-recorder; do
300325
echo "--- ${svc} ---"
301326
cd "services/${svc}"
302-
govulncheck ./... || VULN_ERRORS=$((VULN_ERRORS + 1))
327+
if ! govulncheck ./... 2>&1; then
328+
VULN_ERRORS=$((VULN_ERRORS + 1))
329+
echo "::error::${svc}: govulncheck found vulnerabilities"
330+
fi
303331
cd ../..
304332
done
305-
if [ $VULN_ERRORS -gt 0 ]; then
306-
echo "WARNING: $VULN_ERRORS service(s) have known vulnerabilities"
333+
# Check waivers
334+
WAIVED=$(python3 -c "
335+
import json, datetime
336+
with open('.github/vuln-waivers.json') as f:
337+
data = json.load(f)
338+
today = datetime.date.today().isoformat()
339+
active = [w for w in data.get('go', []) if w.get('expires', '') >= today]
340+
print(len(active))
341+
")
342+
EFFECTIVE=$((VULN_ERRORS - WAIVED))
343+
if [ "$EFFECTIVE" -gt 0 ]; then
344+
echo "FAIL: $VULN_ERRORS service(s) have vulnerabilities ($WAIVED waived, $EFFECTIVE unwaived)"
345+
echo "To waive a reviewed finding, add it to .github/vuln-waivers.json"
346+
exit 1
307347
fi
348+
echo "OK: Go vulnerability scan passed ($WAIVED waiver(s) active)"
308349
309-
- name: Python dependency audit
350+
- name: Python dependency audit (enforced)
310351
run: |
311-
pip install pip-audit pyyaml flask requests
352+
pip install -r requirements-ci.txt
312353
echo "=== Python Dependency Audit ==="
313-
pip-audit --strict --desc || echo "WARNING: Python dependencies have known vulnerabilities"
354+
# Run pip-audit, capture output
355+
pip-audit --strict --desc -f json -o /tmp/pip-audit.json 2>/dev/null || true
356+
python3 -c "
357+
import json, sys, datetime
358+
# Load audit results
359+
try:
360+
with open('/tmp/pip-audit.json') as f:
361+
data = json.load(f)
362+
except (FileNotFoundError, json.JSONDecodeError):
363+
print('OK: pip-audit produced no findings')
364+
sys.exit(0)
365+
vulns = data if isinstance(data, list) else data.get('dependencies', [])
366+
findings = [d for d in vulns if d.get('vulns')]
367+
if not findings:
368+
print('OK: no Python dependency vulnerabilities')
369+
sys.exit(0)
370+
# Load waivers
371+
with open('.github/vuln-waivers.json') as f:
372+
waivers = json.load(f)
373+
today = datetime.date.today().isoformat()
374+
waived_ids = {w['id'] for w in waivers.get('python', []) if w.get('expires', '') >= today}
375+
unwaived = 0
376+
for dep in findings:
377+
for v in dep.get('vulns', []):
378+
vid = v.get('id', '')
379+
if vid in waived_ids:
380+
print(f'WAIVED: {dep[\"name\"]} {vid}')
381+
else:
382+
print(f'::error::{dep[\"name\"]}: {vid} — {v.get(\"description\", \"\")}')
383+
unwaived += 1
384+
if unwaived > 0:
385+
print(f'FAIL: {unwaived} unwaived Python vulnerability finding(s)')
386+
print('To waive a reviewed finding, add it to .github/vuln-waivers.json')
387+
sys.exit(1)
388+
print(f'OK: all Python findings waived ({len(waived_ids)} waiver(s) active)')
389+
"
314390
315391
docs-validation:
316392
name: Documentation Validation

README.md

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,25 +49,22 @@ Built on [uBlue](https://universal-blue.org/) (Fedora Atomic / Silverblue). All
4949
### Install (Fedora Atomic)
5050

5151
```bash
52-
# 1. Verify image signature before installing (requires cosign)
52+
# 1. Verify image signature BEFORE installing (mandatory)
5353
cosign verify --key cosign.pub ghcr.io/sec_ai/secai_os:latest
5454

55-
# 2. Bootstrap rebase (one-time unverified pull, see install docs for rationale)
56-
sudo rpm-ostree rebase ostree-unverified-registry:ghcr.io/sec_ai/secai_os:latest
57-
sudo systemctl reboot
58-
59-
# 3. Switch to signed transport (all future updates verified automatically)
55+
# 2. Rebase to the signed image
6056
sudo rpm-ostree rebase ostree-image-signed:docker://ghcr.io/sec_ai/secai_os:latest
6157
sudo systemctl reboot
6258

63-
# 4. Set up encrypted vault
59+
# 3. Set up encrypted vault
6460
sudo /usr/libexec/secure-ai/setup-vault.sh /dev/sdX
6561
```
6662

67-
> **Why the two-step rebase?** The local ostree store doesn't have the signing policy
68-
> until the first boot. Step 1 provides out-of-band signature verification via cosign
69-
> before the unverified pull. Step 3 enables automatic verification for all future updates.
70-
> See [docs/install/bare-metal.md](docs/install/bare-metal.md) for full details.
63+
> **First install on a fresh Fedora Silverblue?** The signed transport requires that
64+
> the signing policy is already configured. On a fresh install, see
65+
> [docs/install/bare-metal.md](docs/install/bare-metal.md) for the one-time bootstrap
66+
> procedure (uses cosign verification + a single unverified pull, then locks to signed
67+
> transport permanently).
7168
7269
See [docs/install/](docs/install/) for detailed guides: [bare metal](docs/install/bare-metal.md) | [virtual machine](docs/install/vm.md) | [development](docs/install/dev.md)
7370

docs/install/bare-metal.md

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,11 @@ Replace `/dev/sdX` or `/dev/rdiskN` with your actual USB device. Double-check th
6767

6868
After booting into the fresh Fedora Silverblue installation, open a terminal.
6969

70-
### 4a. Verify image signature (before rebasing)
70+
### 4a. Verify image signature (mandatory)
7171

72-
Before installing the image, verify its authenticity using cosign:
72+
Before installing the image, verify its authenticity using cosign.
73+
**Do not skip this step — it is the cryptographic attestation that the
74+
image you are about to install was built by the SecAI project.**
7375

7476
```bash
7577
# Install cosign (if not already present)
@@ -78,40 +80,58 @@ sudo dnf install -y cosign
7880
# Fetch the project's public key
7981
curl -sSfL https://raw.githubusercontent.com/SecAI-Hub/SecAI_OS/main/cosign.pub -o /tmp/cosign.pub
8082

81-
# Verify the image signature
83+
# Verify the image signature — STOP if this fails
8284
cosign verify --key /tmp/cosign.pub ghcr.io/sec_ai/secai_os:latest
8385
```
8486

85-
You should see `The following checks were performed on each of these signatures: ...`
87+
You must see `The following checks were performed on each of these signatures: ...`
8688
with a successful verification result. **Do not proceed if verification fails.**
8789

88-
### 4b. Bootstrap rebase
90+
### 4b. First-time bootstrap (fresh Fedora Silverblue only)
8991

90-
> **Note on the bootstrap trust gap:** The first rebase must use
91-
> `ostree-unverified-registry:` because the local ostree store does not yet
92-
> have the SecAI signing policy configured. This is a one-time bootstrapping
93-
> step — the cosign verification above provides out-of-band attestation
94-
> before the unverified pull. After the first boot, all subsequent updates
95-
> use `ostree-image-signed:` and are verified automatically.
92+
> **Why an unverified pull?** A fresh Fedora Silverblue installation does
93+
> not yet have the SecAI signing policy in its local ostree store. The very
94+
> first rebase therefore uses `ostree-unverified-registry:` as a one-time
95+
> bootstrapping step. This is safe because you verified the image signature
96+
> out-of-band in step 4a above. After this single unverified pull, all
97+
> future updates use the signed transport and are verified automatically
98+
> by rpm-ostree.
99+
>
100+
> **Risk acknowledgment:** If you skipped step 4a, this unverified pull
101+
> has no integrity guarantee. Go back and run the cosign verification first.
96102
97103
```bash
98-
# Initial rebase (signature verified out-of-band above)
104+
# One-time unverified pull (safe ONLY because you verified the signature in 4a)
99105
sudo rpm-ostree rebase ostree-unverified-registry:ghcr.io/sec_ai/secai_os:latest
100106
sudo systemctl reboot
101107
```
102108

103-
### 4c. Switch to signed updates
109+
### 4c. Lock to signed transport (mandatory)
104110

105-
After the first reboot, switch to the signed image transport so that all
106-
future updates are cryptographically verified by rpm-ostree:
111+
Immediately after the first reboot, switch to the signed image transport.
112+
**This step is not optional** — it ensures all future updates are
113+
cryptographically verified by rpm-ostree before they are applied.
107114

108115
```bash
109-
# Switch to the signed transport (all future updates verified automatically)
116+
# Lock to signed transport all future updates verified automatically
110117
sudo rpm-ostree rebase ostree-image-signed:docker://ghcr.io/sec_ai/secai_os:latest
111118
sudo systemctl reboot
112119
```
113120

114-
After this reboot, the system is running SecAI OS with full signature verification enabled.
121+
After this reboot, the system is running SecAI OS with full signature
122+
verification enabled. All subsequent `rpm-ostree upgrade` commands will
123+
reject unsigned or tampered images.
124+
125+
### Returning users / existing SecAI OS installs
126+
127+
If you are upgrading an existing SecAI OS installation (already on the
128+
signed transport), simply run:
129+
130+
```bash
131+
cosign verify --key /path/to/cosign.pub ghcr.io/sec_ai/secai_os:latest
132+
sudo rpm-ostree upgrade
133+
sudo systemctl reboot
134+
```
115135

116136
---
117137

requirements-ci.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Pinned CI dependencies for reproducible builds.
2+
# Update by running: pip install --upgrade <pkg> && pip freeze | grep <pkg>
3+
# All versions must be reviewed before bumping.
4+
PyYAML==6.0.2
5+
Flask==3.1.1
6+
requests==2.32.5
7+
pytest==8.3.5
8+
ruff==0.11.6
9+
bandit==1.9.0
10+
mypy==1.15.0
11+
pip-audit==2.9.0
12+
types-PyYAML==6.0.12.20250402
13+
types-requests==2.32.0.20250328

services/agent/agent/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,8 @@ def _execute_task(task: Task):
474474
})
475475
break
476476

477-
# Execute step
477+
# Execute step (capability guaranteed non-None by expiry check above)
478+
assert task.capability is not None
478479
_executor.execute(step, task.capability, task.budgets)
479480

480481
_audit_log("step_executed", {

services/agent/agent/keystore.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ def create_provider(config: dict[str, Any] | None = None) -> KeyProvider:
379379
backend = cfg.get("backend", "auto")
380380

381381
# Explicit PKCS#11
382+
provider: SoftwareKeyProvider | TPM2KeyProvider | PKCS11KeyProvider
382383
if backend == "pkcs11":
383384
pkcs_cfg = cfg.get("pkcs11", {})
384385
provider = PKCS11KeyProvider(

services/agent/agent/storage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ def list_files(
174174
if not real.is_dir():
175175
return {"ok": False, "error": f"not a directory: {norm}"}
176176

177-
files = []
177+
files: list[dict[str, object]] = []
178178
try:
179179
for entry in sorted(real.iterdir()):
180180
if len(files) >= max_results:

services/common/audit_chain.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def __init__(self, log_path: str, max_size_mb: int = 50):
7171
except (json.JSONDecodeError, OSError) as e:
7272
log.warning("could not resume chain from %s: %s", self._path, e)
7373

74-
def append(self, event: str, data: dict = None) -> str:
74+
def append(self, event: str, data: dict | None = None) -> str:
7575
"""Append a hash-chained entry. Returns the entry hash."""
7676
if data is None:
7777
data = {}

services/common/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
DEFAULT_ESCALATION_THRESHOLD = 15
3636

3737

38-
def hash_passphrase(passphrase: str, salt: bytes = None) -> dict:
38+
def hash_passphrase(passphrase: str, salt: bytes | None = None) -> dict:
3939
"""Hash a passphrase using scrypt. Returns {salt, hash} as hex strings."""
4040
if salt is None:
4141
salt = secrets.token_bytes(32)
@@ -81,7 +81,7 @@ def __init__(self, data_dir: str, session_timeout: int = DEFAULT_SESSION_TIMEOUT
8181
self._lock = threading.Lock()
8282

8383
# In-memory state
84-
self._sessions = {} # token -> {"created": timestamp, "last_active": timestamp}
84+
self._sessions: dict[str, dict[str, float]] = {} # token -> {"created": ts, "last_active": ts}
8585
self._failed_attempts = 0
8686
self._last_failed = 0.0
8787
self._lockout_until = 0.0

0 commit comments

Comments
 (0)