Skip to content

Commit 76f4beb

Browse files
ajlennoncursoragentrng1995
authored
fix(analyzers): HIGH SC8 when skill ships __pycache__ or .pyc (#357)
* fix(analyzers): HIGH SC8 when skill ships __pycache__ or .pyc Close the silent bytecode skip described in #356: discovery excludes __pycache__ and treats .pyc as binary, so presence alone must fail the score even before full disassembly exists. Signed-off-by: Alex J Lennon <ajlennon@dynamicdevices.co.uk> Co-authored-by: Cursor <cursoragent@cursor.com> * fix(sc8): enforce fail-closed bytecode verdict Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com> --------- Signed-off-by: Alex J Lennon <ajlennon@dynamicdevices.co.uk> Signed-off-by: Narendran Raghavan <nraghavan@nvidia.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Narendran Raghavan <nraghavan@nvidia.com>
1 parent 1dbfef0 commit 76f4beb

6 files changed

Lines changed: 189 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ SkillSpector is part of the [NVIDIA Verified Skills pipeline](https://docs.nvidi
2222
## Features
2323

2424
- **Multi-format input**: Scan Git repos, URLs, zip files, directories, or single files
25-
- **68 vulnerability patterns** across 17 categories: prompt injection, data exfiltration, privilege escalation, supply chain, excessive agency, output handling, system prompt leakage, memory poisoning, tool misuse, rogue agent, anti-refusal, trigger abuse, dangerous code (AST), taint tracking, YARA signatures, MCP least privilege, and MCP tool poisoning
25+
- **69 vulnerability patterns** across 17 categories: prompt injection, data exfiltration, privilege escalation, supply chain, excessive agency, output handling, system prompt leakage, memory poisoning, tool misuse, rogue agent, anti-refusal, trigger abuse, dangerous code (AST), taint tracking, YARA signatures, MCP least privilege, and MCP tool poisoning
2626
- **Two-stage analysis**: Fast static analysis + optional LLM semantic evaluation
2727
- **Live vulnerability lookups**: SC4 queries [OSV.dev](https://osv.dev) for real-time CVE data with automatic offline fallback
2828
- **Multiple output formats**: Terminal, JSON, Markdown, and SARIF reports
@@ -352,7 +352,7 @@ claude mcp add skillspector -- skillspector mcp
352352
353353
## Vulnerability Patterns
354354

355-
SkillSpector detects **68 vulnerability patterns** across 17 categories:
355+
SkillSpector detects **69 vulnerability patterns** across 17 categories:
356356

357357
### Prompt Injection (5 patterns)
358358

@@ -389,7 +389,7 @@ SkillSpector detects **68 vulnerability patterns** across 17 categories:
389389
| PE2 | Sudo/Root Execution | MEDIUM | Invoking elevated system privileges |
390390
| PE3 | Credential Access | HIGH | Reading SSH keys, tokens, passwords |
391391

392-
### Supply Chain (6 patterns)
392+
### Supply Chain (7+ patterns)
393393

394394
| ID | Pattern | Severity | Description |
395395
|----|---------|----------|-------------|
@@ -399,6 +399,7 @@ SkillSpector detects **68 vulnerability patterns** across 17 categories:
399399
| SC4 | Known Vulnerable Dependencies | HIGH | Dependencies with known CVEs (live OSV.dev lookup) |
400400
| SC5 | Abandoned Dependencies | MEDIUM | Unmaintained packages without security updates |
401401
| SC6 | Typosquatting | HIGH | Package names similar to popular packages |
402+
| SC8 | Shipped Python Bytecode | HIGH | `__pycache__` / `.pyc` present (discovery skips; malicious bytecode bypass) |
402403

403404
### Excessive Agency (4 patterns)
404405

‎src/skillspector/nodes/analyzers/pattern_defaults.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ class PatternCategory(StrEnum):
9191
"SC5": "Dependency appears abandoned or unmaintained. Abandoned packages no longer receive security patches, leaving known and future vulnerabilities unaddressed.",
9292
"SC6": "Package name closely resembles a popular package, suggesting possible typosquatting. Attackers publish malicious packages with similar names to trick developers into installing them.",
9393
"SC7": "Code pulls a container image with signature or registry verification disabled (--disable-content-trust, DOCKER_CONTENT_TRUST=0, --insecure-registry). This accepts tampered or unverified images and is a container supply-chain risk.",
94+
"SC8": "Skill ships Python bytecode (__pycache__/ or .pyc/.pyo). Discovery skips these paths, so malicious bytecode can score SAFE while decoy sources look clean.",
9495
# Trigger Abuse
9596
"TR1": "Skill uses overly broad trigger patterns that match common words or phrases, causing it to activate in unintended contexts and potentially shadow other skills.",
9697
"TR2": "Skill trigger shadows a common built-in command or another skill's trigger, potentially intercepting requests meant for trusted functionality.",
@@ -181,6 +182,7 @@ class PatternCategory(StrEnum):
181182
"SC5": PatternCategory.SUPPLY_CHAIN.value,
182183
"SC6": PatternCategory.SUPPLY_CHAIN.value,
183184
"SC7": PatternCategory.SUPPLY_CHAIN.value,
185+
"SC8": PatternCategory.SUPPLY_CHAIN.value,
184186
"TR1": PatternCategory.TRIGGER_ABUSE.value,
185187
"TR2": PatternCategory.TRIGGER_ABUSE.value,
186188
"TR3": PatternCategory.TRIGGER_ABUSE.value,
@@ -259,6 +261,7 @@ class PatternCategory(StrEnum):
259261
"SC5": "Abandoned Dependency",
260262
"SC6": "Typosquatting Dependency",
261263
"SC7": "Untrusted Container Image",
264+
"SC8": "Shipped Python Bytecode",
262265
"TR1": "Overly Broad Trigger",
263266
"TR2": "Shadow Command Trigger",
264267
"TR3": "Keyword Baiting Trigger",
@@ -344,6 +347,7 @@ class PatternCategory(StrEnum):
344347
"SC5": "Replace the abandoned dependency with an actively maintained alternative. Check the package's repository for last commit date and open issues.",
345348
"SC6": "Verify the package name is correct and not a typosquatting variant. Compare against the official package name on PyPI or npm.",
346349
"SC7": "Keep image signature verification (Docker Content Trust / cosign) and registry TLS enabled. Pull only signed images from trusted registries; never disable content-trust or use insecure registries in skill code.",
350+
"SC8": "Do not ship __pycache__/ or .pyc/.pyo in skills. Delete bytecode before packaging; if presence is intentional for a lab fixture, quarantine it outside the skill install path.",
347351
# Trigger Abuse
348352
"TR1": "Use specific, narrow trigger patterns that match only the skill's intended use case. Avoid single-word or common-phrase triggers.",
349353
"TR2": "Choose triggers that do not conflict with built-in commands or other skills. Prefix with a unique namespace if necessary.",

‎src/skillspector/nodes/analyzers/static_patterns_supply_chain.py‎

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,26 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515

16-
"""Static patterns: supply chain (SC1–SC7) and trigger analysis (TR1–TR3).
16+
"""Static patterns: supply chain (SC1–SC8) and trigger analysis (TR1–TR3).
1717
1818
SC1–SC3: regex-based pattern matching (original implementation).
1919
SC4: Known vulnerable dependencies — live OSV.dev lookup with static fallback.
2020
SC5: Abandoned dependencies — flags known-abandoned or archived packages.
2121
SC6: Typosquatting — flags package names similar to popular packages.
2222
SC7: Untrusted container image — flags image signature / registry-verification bypass.
23+
SC8: Shipped Python bytecode — flags __pycache__/ and *.pyc/*.pyo that discovery skips.
2324
TR1–TR3: Trigger analysis — flags overly broad, shadowing, or baiting triggers.
2425
2526
Node and analyze() in one module.
2627
"""
2728

2829
from __future__ import annotations
2930

31+
import os
3032
import re
3133
import sys
3234
import tomllib
35+
from pathlib import Path
3336
from urllib.parse import urlparse
3437

3538
from packaging.requirements import InvalidRequirement, Requirement
@@ -1185,13 +1188,85 @@ def _analyze_triggers(manifest: dict[str, object], skill_path: str) -> list[Find
11851188
return findings
11861189

11871190

1191+
# ---------------------------------------------------------------------------
1192+
# SC8: Shipped Python bytecode (closes silent __pycache__ / .pyc skip)
1193+
# ---------------------------------------------------------------------------
1194+
1195+
# Still skip heavy/vendor trees for SC8, but *do* descend into __pycache__.
1196+
_SC8_SKIP_DIRS = frozenset({".git", "node_modules", ".venv", "venv", ".tox", ".pytest_cache"})
1197+
_SC8_BYTECODE_SUFFIXES = (".pyc", ".pyo")
1198+
1199+
1200+
def _analyze_shipped_bytecode(skill_path: str) -> list[Finding]:
1201+
"""Emit SC8 when a skill ships __pycache__ dirs or .pyc/.pyo files.
1202+
1203+
``build_context`` excludes ``__pycache__`` from inventory and
1204+
``static_runner`` treats ``.pyc`` as binary, so malicious bytecode can
1205+
otherwise score SAFE. Presence alone is a HIGH supply-chain signal;
1206+
full disassembly can come later.
1207+
"""
1208+
findings: list[Finding] = []
1209+
if not skill_path or not isinstance(skill_path, str):
1210+
return findings
1211+
root = Path(skill_path)
1212+
if not root.is_dir():
1213+
return findings
1214+
1215+
for dirpath, dirnames, filenames in os.walk(root):
1216+
dirnames[:] = sorted(name for name in dirnames if name not in _SC8_SKIP_DIRS)
1217+
rel_dir = Path(dirpath).relative_to(root).as_posix()
1218+
if rel_dir == ".":
1219+
rel_dir = ""
1220+
1221+
for dirname in list(dirnames):
1222+
if dirname != "__pycache__":
1223+
continue
1224+
rel = f"{rel_dir}/{dirname}/" if rel_dir else f"{dirname}/"
1225+
af = AnalyzerFinding(
1226+
rule_id="SC8",
1227+
message="Skill ships a __pycache__ directory that normal discovery skips",
1228+
severity=Severity.HIGH,
1229+
location=Location(file=rel, start_line=1),
1230+
confidence=0.95,
1231+
tags=[PatternCategory.SUPPLY_CHAIN.value],
1232+
matched_text=rel,
1233+
context=(
1234+
"Python may load .pyc from this directory even when decoy "
1235+
".py sources look clean (PEP 552 UNCHECKED_HASH)."
1236+
),
1237+
)
1238+
findings.append(analyzer_finding_to_finding(af))
1239+
1240+
for filename in sorted(filenames):
1241+
lower = filename.lower()
1242+
if not lower.endswith(_SC8_BYTECODE_SUFFIXES):
1243+
continue
1244+
rel = f"{rel_dir}/{filename}" if rel_dir else filename
1245+
af = AnalyzerFinding(
1246+
rule_id="SC8",
1247+
message="Skill ships Python bytecode (.pyc/.pyo) that normal analysis skips",
1248+
severity=Severity.HIGH,
1249+
location=Location(file=rel, start_line=1),
1250+
confidence=0.95,
1251+
tags=[PatternCategory.SUPPLY_CHAIN.value],
1252+
matched_text=filename,
1253+
context=(
1254+
"Bytecode is excluded from content analysis; a malicious "
1255+
".pyc can execute while source decoys remain clean."
1256+
),
1257+
)
1258+
findings.append(analyzer_finding_to_finding(af))
1259+
1260+
return findings
1261+
1262+
11881263
# ---------------------------------------------------------------------------
11891264
# Graph node
11901265
# ---------------------------------------------------------------------------
11911266

11921267

11931268
def node(state: SkillspectorState) -> AnalyzerNodeResponse:
1194-
"""Run supply_chain patterns (SC1–SC6) and trigger analysis (TR1–TR3)."""
1269+
"""Run supply_chain patterns (SC1–SC8) and trigger analysis (TR1–TR3)."""
11951270
# SC1–SC3 via static_runner
11961271
response = static_runner.run_static_patterns_with_ledger(state, [sys.modules[__name__]])
11971272
findings = response["findings"]
@@ -1201,7 +1276,7 @@ def record_extra_findings(
12011276
extra_findings: list[Finding],
12021277
fallback_analyzer_id: str,
12031278
) -> None:
1204-
"""Attach dependency/manifest findings to the matching completed work item."""
1279+
"""Attach supplemental findings to the matching completed work item."""
12051280
if not extra_findings:
12061281
return
12071282
finding_ids = [finding.finding_id for finding in extra_findings]
@@ -1263,6 +1338,22 @@ def record_extra_findings(
12631338
f"{ANALYZER_ID}_triggers",
12641339
)
12651340

1341+
# SC8: shipped bytecode / __pycache__ (discovery otherwise skips these)
1342+
skill_path = state.get("skill_path") or ""
1343+
if isinstance(skill_path, str) and skill_path.strip():
1344+
bytecode_findings = _analyze_shipped_bytecode(skill_path)
1345+
findings.extend(bytecode_findings)
1346+
for finding_path in sorted({finding.file.rstrip("/") for finding in bytecode_findings}):
1347+
record_extra_findings(
1348+
finding_path,
1349+
[
1350+
finding
1351+
for finding in bytecode_findings
1352+
if finding.file.rstrip("/") == finding_path
1353+
],
1354+
f"{ANALYZER_ID}_bytecode",
1355+
)
1356+
12661357
logger.info("%s: %d findings", ANALYZER_ID, len(findings))
12671358
response["analyzer_status_events"] = [
12681359
analyzer_status_for_events(ANALYZER_ID, response["inspection_ledger"])

‎src/skillspector/nodes/report.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@ def _severity_to_sarif_level(severity: str) -> Literal["error", "warning", "note
156156
_MAX_OCCURRENCES_PER_RULE = 3
157157
_DIMINISHING_WEIGHTS = (1.0, 0.5, 0.25)
158158

159+
# Some findings describe artifacts whose unanalyzed contents can execute. Their
160+
# presence must block installation even when ordinary confidence-weighted,
161+
# per-rule scoring would otherwise keep the aggregate below the CLI threshold.
162+
_RISK_SCORE_FLOORS_BY_RULE_ID = {"SC8": 51}
163+
159164

160165
def _compute_risk_score(
161166
findings: list[Finding],
@@ -220,7 +225,15 @@ def _compute_risk_score(
220225

221226
score += contribution
222227

223-
final_score = min(100, max(0, int(score)))
228+
score_floor = max(
229+
(
230+
_RISK_SCORE_FLOORS_BY_RULE_ID.get(f.rule_id, 0)
231+
for f in sorted_findings
232+
if max(0.0, min(1.0, f.confidence)) > 0.0
233+
),
234+
default=0,
235+
)
236+
final_score = min(100, max(score_floor, int(score)))
224237

225238
severity_band = "LOW"
226239
for threshold, band in _RISK_SEVERITY_BANDS:
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import json
17+
from pathlib import Path
18+
19+
from typer.testing import CliRunner
20+
21+
from skillspector.cli import app
22+
from skillspector.nodes.analyzers import static_patterns_supply_chain as supply_chain
23+
24+
25+
def test_sc8_flags_pycache_and_pyc(tmp_path: Path) -> None:
26+
cache = tmp_path / "scripts" / "__pycache__"
27+
cache.mkdir(parents=True)
28+
(cache / "evil.cpython-312.pyc").write_bytes(b"\x00")
29+
(tmp_path / "orphan.pyc").write_bytes(b"\x00")
30+
(tmp_path / "clean.py").write_text("print('ok')\n", encoding="utf-8")
31+
32+
findings = supply_chain._analyze_shipped_bytecode(str(tmp_path))
33+
rule_ids = {f.rule_id for f in findings}
34+
assert rule_ids == {"SC8"}
35+
paths = {f.file for f in findings}
36+
assert "scripts/__pycache__/" in paths
37+
assert "scripts/__pycache__/evil.cpython-312.pyc" in paths
38+
assert "orphan.pyc" in paths
39+
assert all(f.severity == "HIGH" for f in findings)
40+
41+
42+
def test_sc8_clean_tree_has_no_findings(tmp_path: Path) -> None:
43+
(tmp_path / "SKILL.md").write_text("# demo\n", encoding="utf-8")
44+
(tmp_path / "main.py").write_text("x = 1\n", encoding="utf-8")
45+
assert supply_chain._analyze_shipped_bytecode(str(tmp_path)) == []
46+
47+
48+
def test_sc8_single_pyc_blocks_install_and_cli_exit(tmp_path: Path) -> None:
49+
(tmp_path / "SKILL.md").write_text(
50+
"---\nname: shipped-bytecode\n---\n# Shipped bytecode\n", encoding="utf-8"
51+
)
52+
(tmp_path / "payload.pyc").write_bytes(b"\x00")
53+
54+
result = CliRunner().invoke(
55+
app,
56+
["scan", str(tmp_path), "--format", "json", "--no-llm"],
57+
)
58+
59+
assert result.exit_code == 1, result.output
60+
report = json.loads(result.output)
61+
assert report["risk_assessment"] == {
62+
"score": 51,
63+
"severity": "HIGH",
64+
"recommendation": "DO_NOT_INSTALL",
65+
}
66+
assert any(issue["id"] == "SC8" for issue in report["issues"])

‎tests/nodes/test_report.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ def test_single_finding_partial_confidence_scales_score(self) -> None:
8484
score, _, _ = _compute_risk_score(findings, False)
8585
assert score == 12 # 25 * 1.0 * 0.5 = 12.5 -> int(12.5) = 12
8686

87+
def test_shipped_bytecode_enforces_blocking_risk_floor(self) -> None:
88+
findings = [_finding("SC8", "HIGH", confidence=0.95, file="payload.pyc")]
89+
score, band, recommendation = _compute_risk_score(findings, False)
90+
assert score == 51
91+
assert band == "HIGH"
92+
assert recommendation == "DO_NOT_INSTALL"
93+
8794
def test_unknown_severity_defaults_to_low_points(self) -> None:
8895
f = _finding("R1", "LOW")
8996
f.severity = ""

0 commit comments

Comments
 (0)