Skip to content

Commit 2d60eb3

Browse files
norandomclaude
andcommitted
ci(security): add ClamAV malware gate
Add a `malware` Dagger function that scans the repo (and unpacked .skill/.mcpb/.app bundles) with ClamAV using a pinned image's baked signature DB plus best-effort freshclam. Wire it as a parallel blocking job in security.yml and release.yml; release now needs [gate, clamav]. Free and self-hosted, no VirusTotal. Update README + relabel badge to cover both gates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 759c207 commit 2d60eb3

4 files changed

Lines changed: 79 additions & 12 deletions

File tree

.dagger/src/skills/main.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@
6464
fi
6565
"""
6666

67+
# Runs inside the ClamAV container. Refreshes signatures (best-effort; the
68+
# pinned image ships a baked database) then recursively scans the repo. clamscan
69+
# exits 0 = clean, 1 = malware found, 2 = error -> `set -e` fails the gate.
70+
_CLAMAV = r"""
71+
set -eu
72+
clamscan --version
73+
echo "Refreshing signatures (best-effort; image ships a baked database)..."
74+
freshclam --quiet || echo "freshclam update failed; using the baked database"
75+
echo "Scanning /src ..."
76+
clamscan -r -i --alert-broken --detect-pua=yes --exclude-dir='\.git' /src
77+
echo "MALWARE SCAN PASSED -- no signatures matched"
78+
"""
79+
6780
# Runs inside the build container (cwd = mounted repo). Produces /dist.
6881
_BUILD = r"""
6982
set -euo pipefail
@@ -187,6 +200,32 @@ async def run_one(name: str):
187200
report.append(f"SECURITY GATE PASSED -- {len(skills)} skills clean")
188201
return "\n".join(report)
189202

203+
@function
204+
async def malware(
205+
self,
206+
source: Annotated[dagger.Directory, DefaultPath(".")],
207+
) -> str:
208+
"""Malware-scan the repo with ClamAV (free, offline signature engine).
209+
210+
Recursively scans every file with the bundled ClamAV database (refreshed
211+
via freshclam when reachable), unpacking archives so the built
212+
.skill / .mcpb / .app bundles are covered too. Fails (non-zero) if any
213+
signature matches, which gates the release.
214+
215+
Run locally: dagger call malware
216+
"""
217+
ctr = (
218+
dag.container()
219+
.from_("clamav/clamav:1.4")
220+
.with_mounted_directory("/src", source)
221+
.with_exec(["sh", "-c", _CLAMAV])
222+
)
223+
try:
224+
return await ctr.stdout()
225+
except dagger.ExecError as exc:
226+
# Surface the FOUND lines as the gate failure message.
227+
raise RuntimeError((exc.stdout or "") + (exc.stderr or "")) from exc
228+
190229
@function
191230
def dist(
192231
self,

.github/workflows/release.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,22 @@ jobs:
3131
# Empty when the secret is unset -> the gate falls back to --no-llm.
3232
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
3333

34+
# Malware gate: ClamAV must pass before anything is built or published.
35+
clamav:
36+
runs-on: ubuntu-latest
37+
steps:
38+
- name: Checkout
39+
uses: actions/checkout@v5
40+
41+
- name: ClamAV malware scan
42+
uses: dagger/dagger-for-github@v8.4.1
43+
with:
44+
version: "0.21.3"
45+
verb: call
46+
args: malware
47+
3448
release:
35-
needs: gate
49+
needs: [gate, clamav]
3650
runs-on: ubuntu-latest
3751
steps:
3852
- name: Checkout

.github/workflows/security.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,16 @@ jobs:
2727
env:
2828
# Empty when the secret is unset -> the gate falls back to --no-llm.
2929
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
30+
31+
clamav:
32+
runs-on: ubuntu-latest
33+
steps:
34+
- name: Checkout
35+
uses: actions/checkout@v5
36+
37+
- name: ClamAV malware scan
38+
uses: dagger/dagger-for-github@v8.4.1
39+
with:
40+
version: "0.21.3"
41+
verb: call
42+
args: malware

README.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Skills
22

3-
[![SkillSpector](https://img.shields.io/github/actions/workflow/status/norandom/Skills/security.yml?branch=main&label=SkillSpector)](https://github.com/norandom/Skills/actions/workflows/security.yml)
3+
[![security](https://img.shields.io/github/actions/workflow/status/norandom/Skills/security.yml?branch=main&label=security%20gates)](https://github.com/norandom/Skills/actions/workflows/security.yml)
44

55
Skills I use primarily with [opencode](https://opencode.ai) and Claude Code, plus a few other compatible tools (Hermes, DeepSeek TUI, Antigravity CLI). Each one is a folder with a `SKILL.md` and a version. For tools that prefer a single bundle, run `./build.sh` to pack each into a `<name>.skill` zip, or download the prebuilt bundles from a [GitHub Release](https://github.com/norandom/Skills/releases) — they are not committed to the repo. Install whichever you want; more will land here over time.
66

@@ -220,20 +220,21 @@ The module lives in `.dagger/` (Python SDK, pinned in `dagger.json`). Generated
220220

221221
> Note: Dagger 0.21+ auto-loads `.env` files from the working directory upward. If an ancestor directory holds a `.env` with `export`-style lines, Dagger aborts with a parse error. Drop a local `.env` containing `DUMMY=dummy` in the repo root to shield it — Dagger loads the nearest file and stops walking up. This file is git-ignored.
222222
223-
## Security gate
223+
## Security gates
224224

225-
Every skill is scanned with [NVIDIA SkillSpector](https://github.com/NVIDIA/skillspector) before it ships. SkillSpector checks each `SKILL.md` against 64 vulnerability patterns — prompt injection, data exfiltration, excessive agency, supply-chain risks, MCP issues, and more — and assigns a risk score. The gate **fails the build** if any skill scores above 50 (HIGH/CRITICAL), so a flagged skill is never published.
226-
227-
The gate is a Dagger function, so it runs identically in CI and locally:
225+
Two free, self-hosted scanners run before anything ships, both as Dagger functions so they behave identically in CI and locally:
228226

229227
```bash
230-
dagger call scan # static-only (no key)
231-
dagger call scan --openai-api-key=env:OPENAI_API_KEY # + LLM validation pass
228+
dagger call scan # SkillSpector, static-only
229+
dagger call scan --openai-api-key=env:OPENAI_API_KEY # SkillSpector + LLM validation
230+
dagger call malware # ClamAV
232231
```
233232

234-
Two workflows enforce it:
233+
Two workflows enforce them as **parallel, blocking jobs**:
234+
235+
- **`security.yml`** runs both on every pull request and push to `main`.
236+
- **`release.yml`** runs both as jobs `release` depends on — if either fails, nothing is published.
235237

236-
- **`security.yml`** runs on every pull request and push to `main`.
237-
- **`release.yml`** runs it as a `gate` job that `release` depends on — no gate, no release.
238+
**SkillSpector (skill vulnerabilities).** [NVIDIA SkillSpector](https://github.com/NVIDIA/skillspector) checks each `SKILL.md` against 64 vulnerability patterns — prompt injection, data exfiltration, excessive agency, supply-chain risks, MCP issues, and more — and assigns a risk score. The gate fails if any skill scores above 50 (HIGH/CRITICAL). Skills scan in parallel (one container each, shared install layer), so wall-clock is the slowest single skill. With an LLM key the scan runs SkillSpector's second-stage validation (provider `openai`) to cut false positives — without it, benign content like draw.io XML comments can trip the static "hidden instructions" heuristic. Without a key it falls back to static-only (`--no-llm`). To enable the LLM pass in CI, add a repository secret named **`OPENAI_API_KEY`**. If a validation call drops mid-scan the gate retries the skill and fails closed rather than trusting the degraded static result. The scanner is pinned to a commit in `.dagger/src/skills/main.py` (`_SKILLSPECTOR_REF`) for reproducibility; bump it to re-pin.
238239

239-
With an LLM key the scan runs SkillSpector's second-stage validation (provider `openai`) to cut false positives — without it, benign content like draw.io XML comments can trip the static "hidden instructions" heuristic. Without a key it falls back to static-only (`--no-llm`). To enable the LLM pass in CI, add a repository secret named **`OPENAI_API_KEY`**. If a validation call drops mid-scan the gate retries the skill and fails closed rather than trusting the degraded static result. The scanner is pinned to a SkillSpector commit in `.dagger/src/skills/main.py` (`_SKILLSPECTOR_REF`) for reproducibility; bump it to re-pin.
240+
**ClamAV (malware).** [ClamAV](https://www.clamav.net) recursively scans the repo with its signature database, unpacking archives so the built `.skill` / `.mcpb` / `.app` bundles are covered too. The gate fails if any signature matches. It needs no key or account; the pinned `clamav/clamav:1.4` image ships a baked database that `freshclam` refreshes when the mirror is reachable.

0 commit comments

Comments
 (0)