Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,34 @@ Optional state keys: `mode`, `model_config`, `output_format`, `use_llm`. The res
- **Commands**: `make test`, `make test-cov`.
- **Key tests**: [test_graph.py](../tests/integration/test_graph.py) invokes the graph and asserts `findings`, `sarif_report`, `risk_score`, `report_body`; [test_input_handler.py](../tests/unit/test_input_handler.py) covers directory, zip, and single-file resolution; [test_resolve_input.py](../tests/nodes/test_resolve_input.py) covers the resolve_input node; [test_build_context.py](../tests/nodes/test_build_context.py) asserts `component_metadata` and `has_executable_scripts`.

### CI coverage: public GitHub and internal GitLab

SkillSpector uses its public GitHub Actions workflow as the contributor-facing
quality gate and runs an additional validation pipeline in NVIDIA's internal
GitLab. The two pipelines intentionally share the core checks, while each also
has checks suited to its environment.

| Check | Public GitHub CI | Internal GitLab CI |
|-------|------------------|--------------------|
| Trigger | Pull requests to `main` and pushes to `main` | Merge requests targeting `main` and pushes to the default branch |
| Runtime | Python 3.12 with `uv` on GitHub-hosted Ubuntu runners | Python 3.12 with `uv` in a container on internal Kubernetes runners |
| Lint and formatting | Ruff lint and format checks | The same Ruff lint and format checks |
| Unit tests | Non-integration, non-provider tests with coverage | The same unit-test set with Cobertura coverage artifacts |
| Integration tests | Not run | Full-graph integration suite; these tests may call configured LLM providers |
| Live provider tests | Not run | Optional manual tests against OpenAI, Anthropic, and NVIDIA Build using masked CI credentials |
| Docker smoke test | Runs when Docker- or application-related files change and uploads smoke reports | Runs for the same categories of changes with Docker-in-Docker and preserves smoke reports |
| Static analysis | OpenSSF Scorecard runs in a separate public workflow | SonarQube runs after unit tests and is currently non-blocking |
| Contribution policy | DCO sign-off check on pull requests | No separate DCO job |
| Automated review | No review bot job is defined in the workflow | CodeRabbit is connected through an external integration/webhook, not a runner job |

The internal pipeline therefore adds coverage for the full application flow,
live provider connectivity, and SonarQube analysis. Its default-branch pipeline
rechecks the exact commit that landed after a merge. Live provider testing is
manual so it only sends requests when a maintainer chooses to run it; missing
credentials produce a warning, while invalid credentials or provider failures
fail the corresponding test. SonarQube is informational today and does not
block a merge request.

---

## 8. Data models
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "skillspector"
version = "2.3.12"
version = "2.3.13"
description = "SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring."
readme = "README.md"
license = "Apache-2.0"
Expand Down
71 changes: 30 additions & 41 deletions src/skillspector/nodes/analyzers/static_yara.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import binascii
import hashlib
from pathlib import Path
from tempfile import TemporaryDirectory

import yara

Expand Down Expand Up @@ -94,63 +93,56 @@ def _rule_namespace(rule_file: Path) -> str:
return rule_file.stem


def _materialize_rule_file(
rule_file: Path, temp_dir: Path | None = None, namespace: str | None = None
) -> Path:
"""Return a compile-ready rule path, decoding embedded sources when needed."""
def _read_rule_source(rule_file: Path) -> str:
"""Read a YARA rule source, decoding embedded packaged rules when needed."""
if not rule_file.name.endswith(_ENCODED_RULE_SUFFIXES):
return rule_file
if temp_dir is None:
raise ValueError("temp_dir is required for encoded rule files")
return rule_file.read_text(encoding="utf-8")

encoded_source = rule_file.read_text(encoding="utf-8")
decoded_source = base64.b64decode("".join(encoded_source.split())).decode("utf-8")
temp_name = (namespace or _rule_namespace(rule_file)).replace("/", "__")
temp_file = temp_dir / f"{temp_name}.yar"
temp_file.write_text(decoded_source, encoding="utf-8")
return temp_file
return base64.b64decode("".join(encoded_source.split())).decode("utf-8")


def _build_namespace_map(
rule_files: list[Path], temp_dir: Path | None = None
) -> tuple[dict[str, str], int]:
"""Build a {namespace: filepath} dict and count malformed encoded files."""
filepaths: dict[str, str] = {}
"""Build a {namespace: source} dict and count malformed rule files."""
del temp_dir
sources: dict[str, str] = {}
skipped = 0
for rf in rule_files:
ns = _rule_namespace(rf)
if ns in filepaths:
if ns in sources:
ns = f"{rf.parent.name}/{ns}"
try:
filepaths[ns] = str(_materialize_rule_file(rf, temp_dir, ns))
sources[ns] = _read_rule_source(rf)
except (binascii.Error, UnicodeDecodeError, ValueError) as exc:
skipped += 1
logger.debug("%s: skipping malformed encoded rule %s: %s", ANALYZER_ID, rf, exc)
return filepaths, skipped
return sources, skipped


def _compile_rules(filepaths: dict[str, str]) -> tuple[yara.Rules | None, int]:
"""Compile YARA rules from a namespace map. Falls back to per-file compilation on error.
def _compile_rules(sources: dict[str, str]) -> tuple[yara.Rules | None, int]:
"""Compile YARA rules from a namespace map. Falls back to per-source compilation on error.

Returns (compiled_rules, skipped_count).
"""
try:
return yara.compile(filepaths=filepaths), 0
return yara.compile(sources=sources), 0
except yara.SyntaxError:
pass

logger.debug("%s: bulk compile failed, falling back to per-file compilation", ANALYZER_ID)
logger.debug("%s: bulk compile failed, falling back to per-source compilation", ANALYZER_ID)
good: dict[str, str] = {}
skipped = 0
for ns, fp in filepaths.items():
for ns, source in sources.items():
try:
yara.compile(filepath=fp)
good[ns] = fp
yara.compile(source=source)
good[ns] = source
except (yara.SyntaxError, yara.Error) as exc:
skipped += 1
logger.debug("%s: skipping %s: %s", ANALYZER_ID, fp, exc)
logger.debug("%s: skipping %s: %s", ANALYZER_ID, ns, exc)

compiled = yara.compile(filepaths=good) if good else None
compiled = yara.compile(sources=good) if good else None
return compiled, skipped


Expand All @@ -176,22 +168,19 @@ def _load_rules(extra_dir: Path | None = None) -> yara.Rules | None:
if _compiled_rules is not None and _rules_hash == current_hash:
return _compiled_rules

with TemporaryDirectory() as temp_dir_name:
temp_dir = Path(temp_dir_name)
filepaths, materialize_skipped = _build_namespace_map(rule_files, temp_dir)
sources, materialize_skipped = _build_namespace_map(rule_files)
compiled, compile_skipped = _compile_rules(sources)
skipped = materialize_skipped + compile_skipped

compiled, compile_skipped = _compile_rules(filepaths)
skipped = materialize_skipped + compile_skipped

if compiled is None:
logger.warning("%s: failed to compile any YARA rules", ANALYZER_ID)
return None
if compiled is None:
logger.warning("%s: failed to compile any YARA rules", ANALYZER_ID)
return None

_compiled_rules = compiled
_rules_hash = current_hash
loaded = len(filepaths) - compile_skipped
logger.info("%s: compiled %d YARA rule file(s) (%d skipped)", ANALYZER_ID, loaded, skipped)
return compiled
_compiled_rules = compiled
_rules_hash = current_hash
loaded = len(sources) - compile_skipped
logger.info("%s: compiled %d YARA rule file(s) (%d skipped)", ANALYZER_ID, loaded, skipped)
return compiled


def _extract_match_strings(match: yara.Match) -> tuple[int, str | None]:
Expand Down
11 changes: 4 additions & 7 deletions tests/nodes/analyzers/test_static_yara.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,8 +462,7 @@ def test_build_namespace_map_decodes_encoded_rules(self, tmp_path):
encoded_file = tmp_path / "encoded.yar.b64"
encoded_file.write_text(encoded_source)
ns_map, skipped = static_yara._build_namespace_map([encoded_file], tmp_path)
decoded_path = Path(ns_map["encoded"])
assert decoded_path.read_text() == "rule encoded { condition: false }"
assert ns_map["encoded"] == "rule encoded { condition: false }"
assert skipped == 0

def test_build_namespace_map_keeps_encoded_namespace_collisions_apart(self, tmp_path):
Expand All @@ -482,11 +481,9 @@ def test_build_namespace_map_keeps_encoded_namespace_collisions_apart(self, tmp_
[first_file, second_file], materialized_dir
)

first_path = Path(ns_map["malware"])
second_path = Path(ns_map["extra/malware"])
assert first_path != second_path
assert first_path.read_text() == "rule first { condition: false }"
assert second_path.read_text() == "rule second { condition: false }"
assert set(ns_map) == {"malware", "extra/malware"}
assert ns_map["malware"] == "rule first { condition: false }"
assert ns_map["extra/malware"] == "rule second { condition: false }"
assert skipped == 0

def test_build_namespace_map_skips_malformed_encoded_rules(self, tmp_path):
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_llm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ def test_run_async_without_running_loop(self) -> None:

def test_run_async_with_running_loop(self) -> None:
"""Test run_async works correctly even when there is already a running event loop.

This regression test covers the scenario where SkillSpector is invoked from
environments like Jupyter Notebooks, FastAPI, or LangGraph Studio that already
have an active event loop.
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading