Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3051348
fix: recognize current ngrok tunnel domains and add bore.pub/serveo.n…
federicoroncallo-hub Jul 2, 2026
0b01f29
feat(static): flag unpinned dependencies in scanned skill packages
federicoroncallo-hub Jul 2, 2026
7b24c8a
feat(static): classify URLs in config files via shared url_classifier
federicoroncallo-hub Jul 2, 2026
4ebaaf1
refactor(static): scan config files via raw URL extraction
federicoroncallo-hub Jul 2, 2026
d7d4790
feat(static): scan more manifest formats for unpinned dependencies
federicoroncallo-hub Jul 2, 2026
e70193f
fix(llm): allow Vertex AI to use ambient Application Default Credentials
gyrospectre Jul 24, 2026
cbf9fce
Bumps to resolve security findings
gyrospectre Jul 25, 2026
adfbc89
fix(llm): don't leak Vertex ADC credential path into GEMINI_API_KEY
gyrospectre Aug 3, 2026
48ba248
revert unrelated cli-command-reference.md regeneration
gyrospectre Aug 3, 2026
ecc0720
fix(static): classify suspicious URLs by hostname
vineethsai7 Aug 3, 2026
93ba6f0
fix(static): tolerate malformed setup.py input
vineethsai7 Aug 3, 2026
6994a42
fix(static): harden URL classification and reporting
vineethsai7 Aug 3, 2026
a419477
Merge remote-tracking branch 'origin/main' into HEAD
vineethsai7 Aug 3, 2026
4c5c18c
Merge commit 'a4194779' into HEAD
vineethsai7 Aug 3, 2026
8b50b49
Merge commit 'a4194779' into HEAD
vineethsai7 Aug 3, 2026
a71e8d8
Merge commit '4c5c18cc' into HEAD
vineethsai7 Aug 3, 2026
961588c
fix(static): validate suspicious URL schemes
vineethsai7 Aug 3, 2026
d4c7654
docs(vertex): clarify conditional ADC configuration
vineethsai7 Aug 3, 2026
6973751
Merge commit '961588c1' into HEAD
vineethsai7 Aug 3, 2026
62c10be
Merge commit '6973751c' into HEAD
vineethsai7 Aug 3, 2026
ea9eb7e
Merge main into feat/pr2-unpinned-deps
vineethsai7 Aug 3, 2026
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
5 changes: 4 additions & 1 deletion docs/architecture/analyzers/static-analyzer.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ flowchart TD
A["Manifest validation"] --> B["Instruction body scanning"]
B --> C["Script/code scanning"]
C --> D["Consistency checks"]
D --> E["Referenced file scanning"]
D --> D2["Dependency pinning checks"]
D2 --> E["Referenced file scanning"]
E --> F["Binary file checks"]
F --> G["Hidden file checks"]
G --> H["File inventory analysis"]
Expand All @@ -41,6 +42,7 @@ Each pass targets a different aspect of the skill package:
| Instruction body | `_scan_instruction_body()` | SKILL.md content against signature rules |
| Script scanning | `_scan_scripts()` | Python/bash/other scripts against signatures |
| Consistency | `_check_consistency()` | Mismatch between manifest claims and actual behavior |
| Dependency pinning | `_check_dependency_pinning()` | Unpinned dependencies in `requirements*.txt`, `pyproject.toml`, `setup.cfg`, `setup.py`, `Pipfile`, and manifest metadata |
| Referenced files | `_scan_referenced_files()` | Files mentioned in SKILL.md instructions |
| Binary files | `_check_binary_files()` | Extension/magic mismatch, archive detection, unknown binaries |
| Hidden files | `_check_hidden_files()` | Dotfiles, `__pycache__`, policy-allowed exceptions |
Expand Down Expand Up @@ -97,6 +99,7 @@ The pack manifest registers all rule sources and metadata for the core detection
- Hardcoded credentials and secrets
- Archive/binary risks
- Tool mismatch and manifest consistency
- Supply-chain risk from unpinned dependencies
- Hidden file and dotfile risks
- Document-embedded threats (PDF, Office macros)
- Unicode homoglyph attacks
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/threat-taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Skill Scanner currently uses a subset of those codes for agent-skill risk catego
| Code Execution | `AITech-9.1` | `AISubtech-9.1.1` | Unsafe execution primitives |
| Obfuscation | `AITech-9.2` | `AISubtech-9.2.1` | Detection-evasion obfuscation patterns |
| ASCII Smuggling | `AITech-9.2` | `AISubtech-9.2.1` | Unicode Tag Block (U+E0000–U+E007F) used to hide prompt-injection payloads inside skill files; invisible in editors but decoded by LLMs |
| Supply Chain Attack | `AITech-9.3` | `AISubtech-9.3.1` | Malicious package/tool injection |
| Supply Chain Attack | `AITech-9.3` | `AISubtech-9.3.1` | Malicious package/tool injection; unpinned dependency versions |
| Unauthorized Tool Use | `AITech-12.1` | `AISubtech-12.1.3` | Unsafe/undeclared tool execution |
| Tool Poisoning | `AITech-12.1` | `AISubtech-12.1.2` | Tampering with tool behavior/data |
| Tool Shadowing | `AITech-12.1` | `AISubtech-12.1.4` | Malicious lookalike/replacement tools |
Expand Down
252 changes: 251 additions & 1 deletion skill_scanner/core/analyzers/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
Static pattern analyzer for detecting security vulnerabilities.
"""

import ast
import configparser
import hashlib
import logging
import re
Expand All @@ -32,6 +34,11 @@
from ...threats.threats import ThreatMapping
from .base import BaseAnalyzer

try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
tomllib = None

logger = logging.getLogger(__name__)

# Pre-compiled regex patterns for file operation checks
Expand Down Expand Up @@ -292,7 +299,8 @@ def analyze(self, skill: Skill) -> list[Finding]:
2. Instruction body scanning (SKILL.md)
3. Script/code scanning
4. Consistency checks
5. Reference file scanning
5. Dependency pinning checks
6. Reference file scanning

Args:
skill: Skill to analyze
Expand All @@ -307,6 +315,7 @@ def analyze(self, skill: Skill) -> list[Finding]:
findings.extend(self._scan_instruction_body(skill))
findings.extend(self._scan_scripts(skill))
findings.extend(self._check_consistency(skill))
findings.extend(self._check_dependency_pinning(skill))
findings.extend(self._scan_referenced_files(skill))
findings.extend(self._check_binary_files(skill))
findings.extend(self._check_hidden_files(skill))
Expand Down Expand Up @@ -583,6 +592,247 @@ def _check_consistency(self, skill: Skill) -> list[Finding]:

return findings

# Lockfiles whose presence means dependency versions are already resolved/frozen.
_LOCKFILE_NAMES = {"uv.lock", "poetry.lock", "pipfile.lock", "requirements.lock"}

# name[extras] followed by an optional version specifier.
_REQUIREMENT_RE = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(.*)$")
_SPECIFIER_RE = re.compile(r"^(===|==|~=|!=|<=|>=|<|>)\s*(.+)$")

@staticmethod
def _classify_requirement(raw: str) -> tuple[str, str] | None:
"""Classify a single requirement line.

Returns ``(package_name, status)`` where ``status`` is one of
``"pinned"`` (has an exact ``==`` version), ``"wildcard"`` (``==1.*``
style range pin), or ``"unpinned"`` (bare name or open range such as
``>=``). Returns ``None`` for lines that are not package requirements
(blank, comments, pip options like ``-r``/``--hash``, or direct
URL/VCS references which are already pinned to a specific artifact).
"""
line = raw.split("#", 1)[0].strip()
if not line or line.startswith("-"):
return None
# Drop PEP 508 environment markers (e.g. "; python_version < '3.11'").
line = line.split(";", 1)[0].strip()
# Direct URL / VCS / local-file references are pinned to an artifact.
if "://" in line or line.startswith("git+") or " @ " in line:
return None

match = StaticAnalyzer._REQUIREMENT_RE.match(line)
if not match:
return None
name = match.group(1)
spec = match.group(2).strip()
if not spec:
return (name, "unpinned")

has_exact = False
has_wildcard_pin = False
for part in (p.strip() for p in spec.split(",") if p.strip()):
op_match = StaticAnalyzer._SPECIFIER_RE.match(part)
if not op_match:
continue
operator, version = op_match.group(1), op_match.group(2).strip()
if operator in ("==", "==="):
if "*" in version:
has_wildcard_pin = True
else:
has_exact = True
if has_exact:
return (name, "pinned")
if has_wildcard_pin:
return (name, "wildcard")
return (name, "unpinned")

@staticmethod
def _first_line_containing(content: str, needle: str) -> int | None:
"""Best-effort 1-based line number of the first line containing ``needle``."""
if not needle:
return None
for index, line in enumerate(content.splitlines(), start=1):
if needle in line:
return index
return None

@staticmethod
def _safe_toml(content: str) -> dict | None:
"""Parse TOML, returning None when unavailable (py<3.11) or malformed."""
if tomllib is None:
return None
try:
return tomllib.loads(content)
except Exception: # noqa: BLE001 - malformed manifest, treat as no data
return None

def _entries_from_pyproject(self, path: str, content: str) -> list[tuple[str, int | None, str]]:
"""PEP 621 ``[project]`` dependencies and optional-dependencies."""
data = self._safe_toml(content)
project = data.get("project") if isinstance(data, dict) else None
if not isinstance(project, dict):
return []
specs: list[str] = []
deps = project.get("dependencies")
if isinstance(deps, list):
specs.extend(str(dep) for dep in deps)
optional = project.get("optional-dependencies")
if isinstance(optional, dict):
for group in optional.values():
if isinstance(group, list):
specs.extend(str(dep) for dep in group)
return [(path, self._first_line_containing(content, spec), spec) for spec in specs]

def _entries_from_setup_cfg(self, path: str, content: str) -> list[tuple[str, int | None, str]]:
"""``[options] install_requires`` and ``[options.extras_require]``."""
parser = configparser.ConfigParser()
try:
parser.read_string(content)
except configparser.Error:
return []
blocks: list[str] = []
if parser.has_option("options", "install_requires"):
blocks.append(parser.get("options", "install_requires"))
if parser.has_section("options.extras_require"):
blocks.extend(value for _, value in parser.items("options.extras_require"))

entries: list[tuple[str, int | None, str]] = []
for block in blocks:
for piece in block.replace(",", "\n").splitlines():
spec = piece.strip()
if spec:
entries.append((path, self._first_line_containing(content, spec), spec))
return entries

def _entries_from_setup_py(self, path: str, content: str) -> list[tuple[str, int | None, str]]:
"""String literals inside ``install_requires=[...]`` in setup.py."""
try:
tree = ast.parse(content)
except SyntaxError:
return []
entries: list[tuple[str, int | None, str]] = []
for node in ast.walk(tree):
if not (isinstance(node, ast.keyword) and node.arg == "install_requires"):
continue
for literal in ast.walk(node.value):
if isinstance(literal, ast.Constant) and isinstance(literal.value, str):
line_number = getattr(literal, "lineno", None)
entries.append((path, line_number, literal.value))
return entries
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@staticmethod
def _pipfile_requirement(name: str, spec: Any) -> str | None:
"""Convert a Pipfile entry into a requirement string, or None to skip."""
if isinstance(spec, str):
version = spec.strip()
return name if version in ("", "*") else f"{name}{version}"
if isinstance(spec, dict):
# git/path/url references are pinned to a specific artifact.
if any(key in spec for key in ("git", "path", "file", "url")):
return None
version = str(spec.get("version", "")).strip()
return name if version in ("", "*") else f"{name}{version}"
return None

def _entries_from_pipfile(self, path: str, content: str) -> list[tuple[str, int | None, str]]:
"""``[packages]`` and ``[dev-packages]`` sections of a Pipfile (TOML)."""
data = self._safe_toml(content)
if not isinstance(data, dict):
return []
entries: list[tuple[str, int | None, str]] = []
for section in ("packages", "dev-packages"):
packages = data.get(section)
if not isinstance(packages, dict):
continue
for name, spec in packages.items():
requirement = self._pipfile_requirement(name, spec)
if requirement is not None:
entries.append((path, self._first_line_containing(content, name), requirement))
return entries

def _collect_requirement_entries(self, skill: Skill) -> list[tuple[str, int | None, str]]:
"""Gather ``(source_path, line_number, requirement_string)`` from every
dependency-declaring file in the skill plus manifest metadata."""
entries: list[tuple[str, int | None, str]] = []
for skill_file in skill.files:
file_name = Path(skill_file.relative_path).name.lower()
path = skill_file.relative_path
if file_name.startswith("requirements") and file_name.endswith(".txt"):
for line_number, raw in enumerate(skill_file.read_content().splitlines(), start=1):
entries.append((path, line_number, raw))
elif file_name == "pyproject.toml":
entries.extend(self._entries_from_pyproject(path, skill_file.read_content()))
elif file_name == "setup.cfg":
entries.extend(self._entries_from_setup_cfg(path, skill_file.read_content()))
elif file_name == "setup.py":
entries.extend(self._entries_from_setup_py(path, skill_file.read_content()))
elif file_name == "pipfile":
entries.extend(self._entries_from_pipfile(path, skill_file.read_content()))

metadata = skill.manifest.metadata
if isinstance(metadata, dict):
declared = metadata.get("dependencies")
if isinstance(declared, list):
for declared_dep in declared:
entries.append((str(skill.skill_md_path), None, str(declared_dep)))
return entries

def _check_dependency_pinning(self, skill: Skill) -> list[Finding]:
"""Flag dependencies declared without an exact pinned version.

Skill packages are end-user applications, so unpinned dependencies
(``requests>=2`` or a bare ``requests``) let a later, potentially
compromised release be pulled in at install time -- a supply-chain
risk. This differs from library pinning policy: libraries
intentionally use ranges, so if a lockfile is present the versions are
already frozen and we do not flag.

Sources checked: ``requirements*.txt``, ``pyproject.toml``
(``[project]`` dependencies and optional-dependencies), ``setup.cfg``,
``setup.py`` (``install_requires``), ``Pipfile``, and a
``dependencies`` list under manifest ``metadata``.
"""
findings: list[Finding] = []

# A lockfile freezes the resolved versions, so ranges are intentional.
if any(Path(f.relative_path).name.lower() in self._LOCKFILE_NAMES for f in skill.files):
return findings

for source_label, line_number, raw in self._collect_requirement_entries(skill):
classified = self._classify_requirement(raw)
if classified is None:
continue
package_name, status = classified
if status == "pinned":
continue

severity = Severity.LOW if status == "wildcard" else Severity.MEDIUM
if status == "wildcard":
detail = f"'{package_name}' is pinned to a wildcard version range"
else:
detail = f"'{package_name}' has no pinned (==) version"
findings.append(
Finding(
id=self._generate_finding_id(
"SUPPLY_CHAIN_UNPINNED_DEPENDENCY", f"{source_label}:{line_number}:{package_name}"
),
rule_id="SUPPLY_CHAIN_UNPINNED_DEPENDENCY",
category=ThreatCategory.SUPPLY_CHAIN_ATTACK,
severity=severity,
title="Unpinned dependency",
description=(
f"Dependency {detail}. Unpinned dependencies in a skill package allow a later, "
f"potentially malicious release to be installed automatically (supply-chain risk)."
),
file_path=source_label,
line_number=line_number,
snippet=raw.strip() or None,
remediation="Pin the dependency to an exact version (e.g. 'package==1.2.3').",
analyzer="static",
)
)

return findings

def _scan_referenced_files(self, skill: Skill) -> list[Finding]:
"""Scan files referenced in instruction body with recursive scanning."""
max_depth = self.policy.file_limits.max_reference_depth
Expand Down
7 changes: 7 additions & 0 deletions skill_scanner/data/packs/core/pack.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,13 @@ rules:
enabled: true
description: "Description does not match actual behavior"

SUPPLY_CHAIN_UNPINNED_DEPENDENCY:
source: python
analyzer: static
knobs:
enabled: true
description: "Skill declares a dependency without a pinned (==) version"

LAZY_LOAD_DEEP_NESTING:
source: python
analyzer: static
Expand Down
Loading