2114ccd278
CI / Lint & Test (Python 3.13) (push) Failing after 2s
CI / Lint & Test (Python 3.14) (push) Failing after 1s
CI / Lint & Test (Python 3.12) (push) Failing after 2s
CI / DCO Check (push) Has been skipped
Scorecard supply-chain security / Scorecard analysis (push) Failing after 2s
434 lines
17 KiB
Markdown
434 lines
17 KiB
Markdown
# Multilingual Batch Scanner for SkillSpector
|
||
|
||
[]()
|
||
[]()
|
||
[](https://github.com/NVIDIA/SkillSpector)
|
||
[]()
|
||
|
||
SkillSpector is a static+LLM security analyzer for AI agent skill definitions.
|
||
This module extends it to scan **directories** of skills in parallel, with
|
||
automatic language detection and targeted LLM gap-fill for non-English skills.
|
||
Zero changes to upstream `src/skillspector/`.
|
||
|
||
**Contents:** [What it does](#what-it-does) · [Quickstart](#quickstart) · [All Commands](#all-commands) · [Running Tests](#running-tests) · [For PR Reviewers](#for-pr-reviewers)
|
||
|
||
## What it does
|
||
|
||
```
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 7
|
||
```
|
||
|
||
1. Finds all `SKILL.md`-containing directories under the input root
|
||
2. Detects language per skill (en / zh / ja / ko)
|
||
3. Runs the full SkillSpector graph pipeline per skill in parallel
|
||
4. For non-English skills, applies LLM gap-fill for 8 vulnerability rules
|
||
that English-keyword static patterns cannot detect
|
||
5. Produces an aggregated report sorted by risk score
|
||
|
||
## Quickstart
|
||
|
||
### Prerequisites
|
||
|
||
```bash
|
||
# Create and activate virtual environment
|
||
python3 -m venv .venv
|
||
source .venv/bin/activate
|
||
|
||
# Install SkillSpector in development mode
|
||
pip install -e .
|
||
|
||
# Copy and edit the environment template
|
||
cp contrib/batch_scan/.env.example .env
|
||
```
|
||
|
||
The `.env` file needs these keys (see `.env.example` for the full template):
|
||
|
||
| Variable | Required | Purpose |
|
||
|----------|----------|---------|
|
||
| `SKILLSPECTOR_PROVIDER` | Yes | `openai` for DeepSeek/OpenAI-compatible |
|
||
| `SKILLSPECTOR_MODEL` | Yes | e.g. `deepseek-v4-flash` |
|
||
| `OPENAI_API_KEY` | For single-key | Standard OpenAI-compatible key |
|
||
| `OPENAI_BASE_URL` | For single-key | e.g. `https://api.deepseek.com/v1` |
|
||
| `SKILLSPECTOR_API_KEYS` | For multi-key | Pipe-delimited: `key\|base_url\|model`, one per line |
|
||
|
||
> **⚠️ Parallel LLM scanning requires multiple API keys.** With `--workers 4`
|
||
> and 1 key, you hit rate limits immediately. Configure at least as many keys
|
||
> as workers — 10 keys for `--workers 8` is safe. The ApiKeyPool handles
|
||
> automatic failover when a key is rate-limited. If you only have 1 key, use
|
||
> `--workers 1` or `--no-llm`.
|
||
|
||
### Static-only (fast, no API keys needed)
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --no-llm
|
||
```
|
||
|
||
### Full LLM scan
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 7
|
||
```
|
||
|
||
### Test with built-in fixtures
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 8
|
||
```
|
||
|
||
23 skills designed to exercise every detection rule.
|
||
|
||
## Output formats
|
||
|
||
| Format | Flag | Use case |
|
||
|--------|------|----------|
|
||
| Terminal (Rich) | `-f terminal` (default) | Human review |
|
||
| JSON | `-f json -o report.json` | CI pipelines |
|
||
| Markdown | `-f markdown -o report.md` | PR comments |
|
||
|
||
### Example: terminal output (23 fixtures, 8 workers)
|
||
|
||
```
|
||
SkillSpector Batch Scan — 23 skill(s) in ./tests/fixtures (8 workers, 10 API keys)
|
||
|
||
[1/23] malicious_skill → 100/100 CRITICAL (14 issue(s))
|
||
[8/23] sdi/sdi1_mismatch → 97/100 CRITICAL (6 issue(s))
|
||
[11/23] sdi/sdi4_divergence → 100/100 CRITICAL (8 issue(s))
|
||
[19/23] ssd/ssd1_semantic_injection → 100/100 CRITICAL (4 issue(s))
|
||
[5/23] mcp_poisoned_tool → 100/100 CRITICAL (16 issue(s))
|
||
|
||
╭──────────────────────────────────────────────────────────────────╮
|
||
│ SkillSpector Batch Scan Report │
|
||
╰────────────────── v2.2.3 | Multilingual Enhanced ──────────────╯
|
||
|
||
Total: 23 skill(s) scanned
|
||
|
||
Skills by Risk Score (23 completed)
|
||
┏━━━━━━━━━━━━━━━━━━━━┳━━━━┳━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┳━━━━━━┓
|
||
┃ Skill ┃ LR ┃ Score ┃ Severity ┃ Issues ┃ Lang ┃
|
||
┡━━━━━━━━━━━━━━━━━━━━╇━━━━╇━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━╇━━━━━━┩
|
||
│ chef-assistant │ ✓ │ 100/100 │ CRITICAL │ 14 │ en │
|
||
│ reаd_data │ ✓ │ 100/100 │ CRITICAL │ 16 │ en │
|
||
│ ... │ │ │ │ │ │
|
||
│ safe-greeting │ ✓ │ 0/100 │ LOW │ 0 │ en │
|
||
│ code-reviewer │ ✓ │ 0/100 │ LOW │ 0 │ en │
|
||
└────────────────────┴────┴─────────┴──────────┴────────┴──────┘
|
||
|
||
15 skill(s) with HIGH or CRITICAL risk — review immediately
|
||
6 skill(s) with LOW risk — likely safe
|
||
```
|
||
|
||
**LR column:** Language Reliability. ✓ = English (full static + LLM coverage).
|
||
⚠ = non-English (gap-fill applied, 8 extra rules covered).
|
||
|
||
### Example: JSON output (excerpt)
|
||
|
||
```json
|
||
{
|
||
"batch": {
|
||
"scanned_at": "2026-06-19T01:20:00+00:00",
|
||
"total_skills": 23,
|
||
"scan_mode": "multilingual-enhanced",
|
||
"enhancements": {
|
||
"language_detection": "unicode-script-ratio",
|
||
"gap_fill_applied": 0,
|
||
"gap_fill_findings": 0
|
||
}
|
||
},
|
||
"skills": [
|
||
{
|
||
"skill": {
|
||
"name": "malicious_skill",
|
||
"source": "malicious_skill",
|
||
"source_group": ".",
|
||
"language": "en",
|
||
"scanned_at": "2026-06-19T01:20:05+00:00"
|
||
},
|
||
"risk_assessment": {
|
||
"score": 100,
|
||
"severity": "CRITICAL",
|
||
"recommendation": "DO NOT INSTALL"
|
||
},
|
||
"issues": [
|
||
{
|
||
"id": "E1",
|
||
"message": "Skill executes shell commands without user consent",
|
||
"severity": "CRITICAL",
|
||
"confidence": 1.0,
|
||
"language_compatible": true
|
||
}
|
||
],
|
||
"scan_mode": "multilingual-enhanced",
|
||
"enhancements": {
|
||
"gap_fill_applied": false,
|
||
"gap_fill_findings": 0,
|
||
"english_keyword_rules_skipped": 0
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### LLM vs static comparison (same 23 fixtures, 8 workers)
|
||
|
||
| Skill | `--no-llm` | LLM mode | What LLM caught |
|
||
|-------|-----------|----------|-----------------|
|
||
| `ssd1_semantic_injection` | 0/100 (0) | **100/100** (4) | Semantic injection invisible to static |
|
||
| `ssd2_novel_phrasing` | 0/100 (0) | **100/100** (3) | Novel phrasing bypasses keyword match |
|
||
| `ssd3_nl_exfiltration` | 0/100 (0) | **60/100** (3) | NL-veiled data exfiltration |
|
||
| `ssd4_narrative_deception` | 10/100 (1) | **100/100** (9) | Deceptive narrative framing |
|
||
| `sdi4_divergence` | 13/100 (2) | **100/100** (8) | Intent-behavior mismatch |
|
||
| `sdi1_mismatch` | 52/100 (4) | **97/100** (6) | +2 additional LLM findings |
|
||
| `sdi3_scope_creep` | 71/100 (3) | **100/100** (9) | Hidden scope expansion |
|
||
| `sqp2_missing_warnings` | 26/100 (2) | **58/100** (3) | Missing safety guardrails |
|
||
| `malicious_skill` | 100/100 (6) | 100/100 **(14)** | +8 additional LLM findings |
|
||
| `mcp_poisoned_tool` | 100/100 (8) | 100/100 **(16)** | +8 additional LLM findings |
|
||
| `safe_skill` | 0/100 (0) | **0/100** (0) | Clean stays clean ✓ |
|
||
| `ssd_clean` | 0/100 (0) | **0/100** (0) | Clean stays clean ✓ |
|
||
|
||
**Key insight:** LLM semantic analyzers (SSD/SDI/SQP) catch entire vulnerability
|
||
categories that English-keyword static patterns miss completely. Clean skills
|
||
remain clean — no false-positive inflation. For skills already flagged by
|
||
static rules, LLM finds 2–8 additional issues per skill.
|
||
|
||
### Quick comparison: upstream vs batch
|
||
|
||
```bash
|
||
# Upstream — scan one skill
|
||
skillspector scan ./tests/fixtures/malicious_skill/ -f json -o upstream.json
|
||
|
||
# Batch — scan all skills
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o batch.json
|
||
```
|
||
|
||
Key differences in batch output:
|
||
- `scan_mode: "multilingual-enhanced"` — provenance marker
|
||
- `enhancements.gap_fill_applied` — true if LLM gap-fill was used
|
||
- `enhancements.english_keyword_rules_skipped` — count of static rules bypassed
|
||
- `skill.language` — detected language tag
|
||
|
||
## All Commands
|
||
|
||
### Scan (LLM mode)
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 7 # default
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 1 # sequential, easy to read
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 20 # high throughput
|
||
```
|
||
|
||
### Scan (static-only, no API keys)
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --no-llm
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --no-require-llm --no-llm # skip LLM even for non-English
|
||
```
|
||
|
||
### Output formats
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal # default (Rich)
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o report.json
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f markdown -o report.md
|
||
```
|
||
|
||
### Fixture test (built-in 23 skills)
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 8
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f terminal --workers 8 --no-llm
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o report.json --workers 8
|
||
```
|
||
|
||
### Language override
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang auto --workers 4 # detect (default)
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang zh -f terminal --workers 4
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang ja -f terminal --workers 4
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang ko -f terminal --workers 4
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --lang en -f terminal --workers 4 # skip gap-fill
|
||
```
|
||
|
||
### Debugging
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --workers 1 -V # single worker + verbose
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --workers 4 -V
|
||
skillspector scan ./tests/fixtures/malicious_skill/ --no-llm # verify upstream works
|
||
```
|
||
|
||
### Compare upstream vs batch
|
||
|
||
```bash
|
||
skillspector scan ./tests/fixtures/malicious_skill/ -f json -o upstream.json
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o batch.json --workers 4
|
||
```
|
||
|
||
### CI
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o report.json --workers 8
|
||
if [ $? -eq 0 ]; then echo "All clean"; fi
|
||
```
|
||
|
||
## Tuning `--workers`
|
||
|
||
| Scenario | Workers | Peak concurrent LLM requests |
|
||
|----------|---------|------------------------------|
|
||
| Free-tier API key | 1 | 10–15 |
|
||
| Paid basic | 4 (default) | 25–40 |
|
||
| Enterprise / multi-key | 7–10 | 50–80 |
|
||
| Debugging | 1 + `-V` | Sequential, easy to read |
|
||
|
||
## Language options
|
||
|
||
```bash
|
||
--lang auto # Unicode script-ratio detection (default)
|
||
--lang zh # Force Chinese
|
||
--lang ja # Force Japanese
|
||
--lang ko # Force Korean
|
||
--lang en # Force English (skip gap-fill)
|
||
```
|
||
|
||
## Debugging
|
||
|
||
```bash
|
||
# Single worker + verbose output — easiest to read
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --workers 1 -V
|
||
|
||
# Verify upstream still works
|
||
skillspector scan ./tests/fixtures/malicious_skill/ --no-llm
|
||
```
|
||
|
||
## Edge cases
|
||
|
||
```bash
|
||
# Static-only + skip LLM requirement even for non-English skills
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ --no-require-llm --no-llm
|
||
```
|
||
|
||
## Exit codes
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| 0 | All safe (no HIGH/CRITICAL) |
|
||
| 1 | ≥1 skill has HIGH or CRITICAL risk |
|
||
| 2 | Scan errors occurred |
|
||
|
||
CI usage:
|
||
|
||
```bash
|
||
python -m contrib.batch_scan.batch_scan ./tests/fixtures/ -f json -o report.json
|
||
if [ $? -eq 0 ]; then
|
||
echo "All clean"
|
||
fi
|
||
```
|
||
|
||
## Troubleshooting
|
||
|
||
| Symptom | Fix |
|
||
|---------|-----|
|
||
| "No LLM API key configured" | Set up `.env` or use `--no-llm` |
|
||
| Connection errors / 429 | Reduce `--workers` |
|
||
| Skills timing out (90s) | Check network; the scanner skips and continues |
|
||
| "Event loop is closed" | Harmless, suppressed |
|
||
| model_info token limit warning | Harmless, 128K default used |
|
||
|
||
## Known Limitations
|
||
|
||
1. **No checkpoint/resume.** A failure at skill 847 of 1000 loses all progress.
|
||
2. **Language detection covers 4 scripts.** Arabic, Hindi, Cyrillic are
|
||
classified as English and lose gap-fill coverage.
|
||
3. **No SARIF output.** Upstream supports it; this contrib adds terminal/JSON/Markdown.
|
||
4. **Gap-fill quality not benchmarked for non-English.** No ground-truth comparison exists.
|
||
5. **`parse_response` JSON recovery is best-effort.** When the LLM returns
|
||
malformed JSON, the analyzer returns empty findings (no crash). This is a
|
||
graceful-degradation choice: a single malformed response won't block the
|
||
pipeline, but the user won't know which findings were lost.
|
||
|
||
See `DESIGN.md` for architecture details and `docs/archive/FUTURE_WORK.md` for suggested directions.
|
||
|
||
## Running Tests
|
||
|
||
```bash
|
||
# === All 164 tests ===
|
||
|
||
# Unit tests — random order (seed=42, 120 tests)
|
||
python contrib/batch_scan/tests/tests-pro/random_numbered.py
|
||
|
||
# Pool wiring smoke test (4 checks)
|
||
python contrib/batch_scan/tests/test_pool_wiring.py
|
||
|
||
# Monkey-patch invasiveness (14 tests)
|
||
python contrib/batch_scan/tests/test_monkeypatch_invasiveness.py
|
||
|
||
# Monkey-patch fragility (26 tests)
|
||
python contrib/batch_scan/tests/test_monkeypatch_fragility.py
|
||
|
||
# === Convenience ===
|
||
|
||
# All review-themed tests in one command
|
||
python -m unittest \
|
||
contrib.batch_scan.tests.test_monkeypatch_invasiveness \
|
||
contrib.batch_scan.tests.test_monkeypatch_fragility -v
|
||
python contrib/batch_scan/tests/test_pool_wiring.py
|
||
|
||
# Mutation test — 30 injected bugs across 4 risk areas
|
||
python contrib/batch_scan/tests/tests-pro/mutation_max.py
|
||
|
||
# Sequential pytest (if pytest installed)
|
||
pytest contrib/batch_scan/tests/tests-pro/ -v
|
||
```
|
||
|
||
## For PR Reviewers
|
||
|
||
> Since last review: pool is now fully wired (dual-patch closes `from-import` bypass),
|
||
> 44 new thematic tests answer Issues #1–#2 directly, and all 164 tests pass
|
||
> against upstream NVIDIA/SkillSpector@ab0431f (130+ commits, zero patch conflicts).
|
||
|
||
### What changed in production code (1 file)
|
||
|
||
[`runner.py#L70-L91`](../runner.py#L70-L91) — `set_api_pool()` now patches **both**
|
||
`llm_utils.get_chat_model` **and** `llm_analyzer_base.get_chat_model`. Previously only
|
||
the former was patched; `llm_analyzer_base`'s `from ... import` created a local
|
||
reference that bypassed the pool entirely. Graph analyzers (95% of LLM calls)
|
||
now go through `PooledChatModel`. `set_api_pool(None)` restores both modules.
|
||
|
||
### How each review concern was addressed
|
||
|
||
| Issue | Answer | Proof |
|
||
|-------|--------|-------|
|
||
| **#1 — Pool dead code** | `set_api_pool()` dual-patch | `test_pool_wiring.py`: 3 paths verified → PooledChatModel |
|
||
| **#2 — Patches invasive** | Context manager + explicit `setup_deepseek_compat()` | `test_monkeypatch_invasiveness.py`: 14 tests — import isolation, thread isolation, 50-instance concurrency |
|
||
| **#2 — Patches fragile** | `_verify_patch_targets()` guard before apply | `test_monkeypatch_fragility.py`: 26 tests — each of 7 patches individually verified, deep deps checked, atomicity proven |
|
||
| **#3 — Risky code untested** | 120 unit tests across 4 risk areas | `tests/tests-pro/` — pool (45), gap-fill (41), patches (24), annotation (10) |
|
||
|
||
Full response with before/after tables: [`REVIEW_RESPONSE.md`](REVIEW_RESPONSE.md)
|
||
|
||
### Test suite at a glance (164 total)
|
||
|
||
```
|
||
tests/
|
||
├── test_pool_wiring.py ← Issue #1: 4 smoke checks
|
||
├── test_monkeypatch_invasiveness.py ← Issue #2: 14 tests (thread isolation)
|
||
├── test_monkeypatch_fragility.py ← Issue #2: 26 tests (guard verification)
|
||
├── tests-pro/
|
||
│ ├── test_api_pool.py ← Issue #3: 45 tests (acquire/backoff)
|
||
│ ├── test_gap_fill.py ← Issue #3: 41 tests (JSON parsing)
|
||
│ ├── test_runner_patches.py ← Issue #3: 24 tests (context manager)
|
||
│ └── test_annotation.py ← Issue #3: 10 tests (language compat)
|
||
└── docs/
|
||
├── TEST_DESIGN.md ← WHY each suite was designed
|
||
├── TEST_GUIDE.md ← WHAT each file covers (run commands)
|
||
└── BUGS_FOUND.md ← 16 bugs found, 3 test bugs fixed
|
||
```
|
||
|
||
### Design context
|
||
- [`DESIGN.md`](DESIGN.md) — architecture, concurrency model, dual-patch mechanism
|
||
- [`archive/PITFALLS.md`](archive/PITFALLS.md) — thread safety, `from-import` pitfall, DeepSeek constraints
|
||
- [`archive/FUTURE_WORK.md`](archive/FUTURE_WORK.md) — future direction + code conventions
|
||
|
||
---
|
||
|
||
**Next:** [DESIGN.md](DESIGN.md) — architecture & concurrency model · [REVIEW_RESPONSE.md](REVIEW_RESPONSE.md) — PR #100 review response · [CONTRIBUTING.md](../CONTRIBUTING.md) — dev setup & code conventions
|