feat: add LLM semantic security review for SKILL.md scanner#39
feat: add LLM semantic security review for SKILL.md scanner#39Benkapner wants to merge 4 commits into
Conversation
Add a SecurityGate implementation that scans SKILL.md files for security risks. Plugs into the gate registry alongside the existing Cisco gate. Patterns ported from harness-eval-lab (setup-eval). Checks: prompt injection (17 patterns), credential access (22 patterns), data exfiltration (8), reverse shells (10), obfuscation (8). Context-aware severity demotion for patterns inside code blocks or examples. Also refactors SecurityGate base class to extract shared JSON-reading and scoring logic, removing duplication between Cisco and the new gate.
- Wire skillmd-security-scan into the test phase as step 3 - Remove exit 1 from Tekton task; let aggregate_scorecard.py decide pass/fail via gate policy (matches Cisco pattern) - Narrow markdown image pattern to exclude docs/github/imgur domains - Narrow translate evasion pattern to exclude known languages - Add directory excludes (.git, node_modules, vendor, __pycache__) - Add false-positive tests for narrowed patterns and dir excludes
These are shell/code patterns (curl, wget, netcat, bash -i) that target Python and shell files. The Cisco scanner already covers these in .py files. In markdown files they almost exclusively appear inside code blocks, where they get demoted to LOW severity and add noise without signal.
Add 3 LLM-based security checks adapted from harness-eval-lab rubric: - Anti-jailbreak: self-declared safety claims, evaluation bypass attempts - Semantic attacks: polite reframings, gradual escalation, split-instruction - Description-behavior mismatch: stated purpose vs actual instruction LLM review runs by default (matching Cisco's use-llm=true convention). Disable with --no-llm flag. Findings appended to same JSON with source=llm.
|
Correction: This PR is tracked under QEMETRICS-2415 (not QEMETRICS-2414). The branch name references QEMETRICS-2414 because it was created before the separate ticket existed. Depends on PR #38 being merged first (the LLM review adds to the files that PR creates). |
GuyZivRH
left a comment
There was a problem hiding this comment.
Consolidated Review: LLM Semantic Security Review
Good work on extending the SKILL.md scanner with LLM-based semantic review. The design is sound — deterministic scan runs first, LLM review appends findings with source: "llm", and failures preserve deterministic results. A few issues need addressing before merge.
Must Fix (Blocking)
1. Rebase on latest main
The PR has conflicts with the lint cleanup (#41) that was merged. Please rebase:
git fetch origin
git rebase origin/main
git push --force-with-lease2. LLM JSON parsing drops findings wrapped in markdown fences
File: abevalflow/security/skillmd_scanner.py (llm_security_review)
LLMs commonly return JSON wrapped in markdown fences like:
```json
[{"check": "anti_jailbreak", ...}]
```
The code calls json.loads(response) directly, which fails on fenced output. All semantic findings are silently dropped.
Fix: Add fence stripping before parsing:
def _extract_json(response: str) -> str:
"""Strip markdown fences if present."""
response = response.strip()
if response.startswith("```"):
lines = response.split("\n")
lines = [l for l in lines if not l.startswith("```")]
response = "\n".join(lines).strip()
return response
# Then use:
llm_findings = json.loads(_extract_json(response))3. Normalize severity values to prevent block bypass
File: abevalflow/gates/security/base.py (evaluate_scan_json)
Severity from LLM output isn't normalized. Values like "critical " (trailing space) are treated as invalid and downgraded to info, causing block mode to incorrectly pass.
Fix: Add .strip() when parsing severity:
sev_str = f.get("severity", "info").lower().strip()4. Add tests for the LLM security review path
File: tests/test_skillmd_scanner.py
The LLM review is enabled by default but has zero test coverage. Please add:
class TestLLMSecurityReview:
def test_valid_findings(self, tmp_path, mocker):
"""LLM returns valid JSON findings."""
mock_response = '[{"check": "anti_jailbreak", "severity": "high", "message": "test", "file_path": "SKILL.md"}]'
mocker.patch("abevalflow.security.skillmd_scanner.llm_client.chat_completion", return_value=mock_response)
(tmp_path / "SKILL.md").write_text("content")
findings = llm_security_review(tmp_path)
assert len(findings) == 1
assert findings[0]["source"] == "llm"
def test_fenced_json_parsed(self, tmp_path, mocker):
"""LLM returns JSON wrapped in markdown fences."""
mock_response = '```json\n[{"check": "semantic_attack", "severity": "critical", "message": "test", "file_path": "SKILL.md"}]\n```'
mocker.patch("abevalflow.security.skillmd_scanner.llm_client.chat_completion", return_value=mock_response)
(tmp_path / "SKILL.md").write_text("content")
findings = llm_security_review(tmp_path)
assert len(findings) == 1 # Should parse successfully
def test_llm_failure_returns_empty(self, tmp_path, mocker):
"""LLM call fails, deterministic results preserved."""
mocker.patch("abevalflow.security.skillmd_scanner.llm_client.chat_completion", side_effect=Exception("API error"))
(tmp_path / "SKILL.md").write_text("content")
findings = llm_security_review(tmp_path)
assert findings == []
def test_invalid_json_returns_empty(self, tmp_path, mocker):
"""LLM returns invalid JSON."""
mocker.patch("abevalflow.security.skillmd_scanner.llm_client.chat_completion", return_value="not json")
(tmp_path / "SKILL.md").write_text("content")
findings = llm_security_review(tmp_path)
assert findings == []Should Fix (Can Be Follow-up)
5. Add content size limit for large submissions
All markdown files are concatenated into one message. Large submissions can exceed the model's context window, causing the LLM review to silently fail.
Suggested fix:
MAX_TOTAL_CHARS = 40_000 # ~10k tokens
total_chars = 0
for md_file in md_files:
content = md_file.read_text(...)
if total_chars + len(content) > MAX_TOTAL_CHARS:
logger.warning("Truncating LLM review input due to size")
break
file_contents.append(...)
total_chars += len(content)6. Expand pattern allowlists or demote severity
The translate evasion pattern still flags legitimate uses like "Translate this to Italian/Arabic/Hindi". The markdown image pattern still flags shields.io badges and raw.githubusercontent URLs.
Options:
- Expand the allowlists
- Demote to MEDIUM severity (informational rather than blocking)
- Remove
translate evasionentirely (the LLM check covers semantic attacks better)
Testing Pipeline Changes
After fixing and rebasing, verify the integration using our test script:
./scripts/misc/trigger_test_runs.sh QEMETRICS-2414/security-llm-reviewThis triggers 6 PipelineRuns (Harbor, ASE, A2A + their CI variants) against your branch. Monitor results with:
oc get pipelineruns -n ab-eval-flow --sort-by=.metadata.creationTimestamp | tail -10What's Working Well
- Clean separation: deterministic scan always runs, LLM is additive
- Graceful degradation: LLM failure preserves deterministic results
- Consistent conventions:
--no-llmmatches Cisco'suse-llm=truepattern - Well-designed prompt: 3-check rubric targets attacks regex cannot detect
- Base class refactoring significantly reduces duplication
- Commit messages are clean (no AI attribution violations)
Let me know when the fixes are ready and I'll re-review!
Summary
Adds LLM-based semantic security review to the SKILL.md scanner, complementing the deterministic regex checks from PR #38. Adapted from harness-eval-lab's security review rubric.
Depends on PR #38 (deterministic security gate) being merged first.
What the LLM checks
Three checks that catch attacks regex cannot detect:
How it works
source: "llm"llm_client.chat_completion()(same as quality review)use-llm=trueconvention--no-llmflagChanges
abevalflow/security/skillmd_scanner.pySECURITY_REVIEW_PROMPTandllm_security_review()functionscripts/skillmd_security_scan.py--no-llmflag, calls LLM review by defaultpipeline/tasks/phases/test.yamlTest plan
Ref: QEMETRICS-2414">