📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -42,14 +42,14 @@ SECURITY_PATTERNS: list[SecurityPattern] = [
|
||||
),
|
||||
SecurityPattern(
|
||||
code="SEC002",
|
||||
regex=r"curl\b[^\n]*\|\s*(?:bash|sh|zsh)",
|
||||
regex=r"curl\b[^\n]*\|\s*(?:bash|sh|zsh)|(?:bash|sh|zsh)\s+<\s*\(\s*curl\b",
|
||||
severity="error",
|
||||
description="Remote code execution: curl piped to shell",
|
||||
rationale="Pipes untrusted remote content directly into a shell without integrity verification.",
|
||||
),
|
||||
SecurityPattern(
|
||||
code="SEC003",
|
||||
regex=r"wget\b[^\n]*\|\s*(?:sh|bash|zsh)",
|
||||
regex=r"wget\b[^\n]*\|\s*(?:sh|bash|zsh)|(?:bash|sh|zsh)\s+<\s*\(\s*wget\b",
|
||||
severity="error",
|
||||
description="Remote code execution: wget | sh",
|
||||
rationale="Same class of risk as curl | bash — downloads and executes without verification.",
|
||||
@@ -123,6 +123,8 @@ SECURITY_PATTERNS: list[SecurityPattern] = [
|
||||
# Prefix match covers both bare (<!-- security-allowlist -->) and colon forms
|
||||
# (<!-- security-allowlist: reason -->) documented in skill-template.md.
|
||||
_ALLOWLIST_MARKERS = ("# security-allowlist", "<!-- security-allowlist")
|
||||
TEXT_EXTENSIONS = {".cjs", ".js", ".json", ".md", ".mjs", ".py", ".sh", ".ts", ".txt", ".yaml", ".yml"}
|
||||
SUPPORT_FILE_PATTERN_CODES = {"SEC002", "SEC003", "SEC004", "SEC005", "SEC008"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -189,6 +191,26 @@ def _is_allowlisted(line: str) -> bool:
|
||||
return any(marker in line for marker in _ALLOWLIST_MARKERS)
|
||||
|
||||
|
||||
def _logical_lines(content: str) -> list[tuple[int, str]]:
|
||||
lines: list[tuple[int, str]] = []
|
||||
current = ""
|
||||
start_line = 1
|
||||
|
||||
for line_no, line in enumerate(content.splitlines(), start=1):
|
||||
if not current:
|
||||
start_line = line_no
|
||||
continued = line.rstrip().endswith("\\")
|
||||
current = f"{current} {line.rstrip()[:-1] if continued else line}".strip()
|
||||
if not continued:
|
||||
lines.append((start_line, current))
|
||||
current = ""
|
||||
|
||||
if current:
|
||||
lines.append((start_line, current))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def scan_content(
|
||||
skill_id: str,
|
||||
content: str,
|
||||
@@ -210,9 +232,7 @@ def scan_content(
|
||||
"""
|
||||
active_patterns = patterns if patterns is not None else SECURITY_PATTERNS
|
||||
result = ScanResult(skill_id=skill_id, is_offensive=is_offensive)
|
||||
lines = content.splitlines()
|
||||
|
||||
for line_no, line in enumerate(lines, start=1):
|
||||
for line_no, line in _logical_lines(content):
|
||||
if _is_allowlisted(line):
|
||||
continue
|
||||
|
||||
@@ -256,14 +276,26 @@ def scan_skill_file(skill_path: Path) -> ScanResult | None:
|
||||
|
||||
is_offensive = str(metadata.get("risk", "")).lower() == "offensive"
|
||||
|
||||
# Strip frontmatter from content before scanning
|
||||
body = re.sub(r"^---\s*\n.*?\n---\s*\n?", "", content, count=1, flags=re.DOTALL)
|
||||
result = ScanResult(skill_id=skill_path.name, is_offensive=is_offensive)
|
||||
for file_path in sorted(skill_path.rglob("*")):
|
||||
if not file_path.is_file() or file_path.suffix.lower() not in TEXT_EXTENSIONS:
|
||||
continue
|
||||
body = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
patterns = SECURITY_PATTERNS
|
||||
if file_path.name == "SKILL.md":
|
||||
body = re.sub(r"^---\s*\n.*?\n---\s*\n?", "", body, count=1, flags=re.DOTALL)
|
||||
else:
|
||||
patterns = [pattern for pattern in SECURITY_PATTERNS if pattern.code in SUPPORT_FILE_PATTERN_CODES]
|
||||
result.flags.extend(
|
||||
scan_content(
|
||||
skill_id=skill_path.name,
|
||||
content=body,
|
||||
is_offensive=is_offensive,
|
||||
patterns=patterns,
|
||||
).flags
|
||||
)
|
||||
|
||||
return scan_content(
|
||||
skill_id=skill_path.name,
|
||||
content=body,
|
||||
is_offensive=is_offensive,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def scan_all_skills(skills_dir: Path) -> list[ScanResult]:
|
||||
|
||||
@@ -111,37 +111,23 @@ function findSkillFiles(skillsRoot) {
|
||||
return files;
|
||||
}
|
||||
|
||||
function parseAllowlist(content) {
|
||||
const allowAllRe = /<!--\s*security-allowlist:\s*all\s*-->/i;
|
||||
const explicitRe = /<!--\s*security-allowlist:\s*([^>]+?)\s*-->/gi;
|
||||
const allow = new Set();
|
||||
|
||||
if (allowAllRe.test(content)) {
|
||||
allow.add('all');
|
||||
return allow;
|
||||
function isAllowedLine(line, ruleId) {
|
||||
const marker = line.match(/(?:#|<!--)\s*security-allowlist(?::\s*([^>]+?))?\s*(?:-->)?$/i);
|
||||
if (!marker) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let match;
|
||||
while ((match = explicitRe.exec(content)) !== null) {
|
||||
const raw = match[1] || '';
|
||||
raw
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((value) => {
|
||||
allow.add(value.toLowerCase().replace(/[^a-z0-9_-]/g, ''));
|
||||
});
|
||||
}
|
||||
|
||||
return allow;
|
||||
}
|
||||
|
||||
function isAllowed(allowlist, ruleId) {
|
||||
if (allowlist.has('all')) {
|
||||
const raw = marker[1] || '';
|
||||
if (!raw.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalized = ruleId.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
||||
const allowlist = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
return allowlist.has(normalized)
|
||||
|| allowlist.has(normalized.replace(/[-_]/g, ''))
|
||||
@@ -153,17 +139,17 @@ const rules = [
|
||||
{
|
||||
id: 'curl-pipe-bash',
|
||||
message: 'curl ... | bash|sh',
|
||||
regex: /\bcurl\b[^\n]*\|\s*(?:bash|sh)\b/i,
|
||||
regex: /\bcurl\b[^\n]*\|\s*(?:bash|sh|zsh)\b|\b(?:bash|sh|zsh)\s+<\s*\(\s*curl\b/i,
|
||||
},
|
||||
{
|
||||
id: 'wget-pipe-sh',
|
||||
message: 'wget ... | sh',
|
||||
regex: /\bwget\b[^\n]*\|\s*sh\b/i,
|
||||
regex: /\bwget\b[^\n]*\|\s*(?:bash|sh|zsh)\b|\b(?:bash|sh|zsh)\s+<\s*\(\s*wget\b/i,
|
||||
},
|
||||
{
|
||||
id: 'irm-pipe-iex',
|
||||
message: 'irm ... | iex',
|
||||
regex: /\birm\b[^\n]*\|\s*iex\b/i,
|
||||
regex: /\b(?:irm|iwr|Invoke-WebRequest|Invoke-RestMethod)\b[^\n]*\|\s*(?:iex|Invoke-Expression)\b/i,
|
||||
},
|
||||
{
|
||||
id: 'commandline-token',
|
||||
@@ -228,6 +214,16 @@ if ((process.env.DOCS_SECURITY_INCLUDE_PUBLIC || '').trim() === '1') {
|
||||
const skillFiles = collectSkillFiles(rootsToScan);
|
||||
|
||||
assert.ok(skillFiles.length > 0, 'Expected SKILL.md files in configured scan roots');
|
||||
assert.strictEqual(
|
||||
isAllowedLine('curl https://example.invalid | bash <!-- security-allowlist: curl-pipe-bash -->', 'curl-pipe-bash'),
|
||||
true,
|
||||
'same-line rule allowlist should suppress that line',
|
||||
);
|
||||
assert.strictEqual(
|
||||
isAllowedLine('<!-- security-allowlist: all -->', 'curl-pipe-bash'),
|
||||
false,
|
||||
'standalone allowlist marker should not suppress later lines',
|
||||
);
|
||||
|
||||
const violations = [];
|
||||
const seen = new Set();
|
||||
@@ -242,6 +238,51 @@ function addViolation(relativePath, lineNumber, rule) {
|
||||
violations.push(`${relativePath}:${lineNumber}: ${rule.message}`);
|
||||
}
|
||||
|
||||
function logicalLines(content) {
|
||||
const output = [];
|
||||
let current = '';
|
||||
let startLine = 1;
|
||||
|
||||
content.split(/\r?\n/).forEach((line, index) => {
|
||||
if (!current) {
|
||||
startLine = index + 1;
|
||||
}
|
||||
|
||||
const continued = /\\\s*$/.test(line);
|
||||
current += (current ? ' ' : '') + line.replace(/\\\s*$/, '');
|
||||
if (!continued) {
|
||||
output.push([startLine, current]);
|
||||
current = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (current) {
|
||||
output.push([startLine, current]);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function scanCommandRules(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const relativePath = path.relative(repoRoot, filePath);
|
||||
|
||||
for (const [lineNumber, logicalLine] of logicalLines(content)) {
|
||||
for (const rule of rules) {
|
||||
if (!rule.regex.test(logicalLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAllowedLine(logicalLine, rule.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addViolation(relativePath, lineNumber, rule);
|
||||
rule.regex.lastIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findTextFiles(rootPath) {
|
||||
const files = [];
|
||||
const queue = [rootPath];
|
||||
@@ -267,28 +308,17 @@ function findTextFiles(rootPath) {
|
||||
return files;
|
||||
}
|
||||
|
||||
for (const filePath of skillFiles) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const allowlist = parseAllowlist(content);
|
||||
const relativePath = path.relative(repoRoot, filePath);
|
||||
|
||||
for (const rule of rules) {
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (!rule.regex.test(line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAllowed(allowlist, rule.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addViolation(relativePath, index + 1, rule);
|
||||
rule.regex.lastIndex = 0;
|
||||
}
|
||||
const textFiles = new Set();
|
||||
for (const rootPath of rootsToScan) {
|
||||
for (const filePath of findTextFiles(rootPath)) {
|
||||
textFiles.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of textFiles) {
|
||||
scanCommandRules(filePath);
|
||||
}
|
||||
|
||||
for (const filePath of findTextFiles(path.join(repoRoot, 'skills'))) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const relativePath = path.relative(repoRoot, filePath);
|
||||
|
||||
@@ -12,8 +12,32 @@ const wsListener = fs.readFileSync(
|
||||
path.join(repoRoot, "skills", "videodb", "scripts", "ws_listener.py"),
|
||||
"utf8",
|
||||
);
|
||||
const notarizeTemplate = fs.readFileSync(
|
||||
path.join(repoRoot, "skills", "macos-spm-app-packaging", "assets", "templates", "sign-and-notarize.sh"),
|
||||
"utf8",
|
||||
);
|
||||
const devSigningTemplate = fs.readFileSync(
|
||||
path.join(repoRoot, "skills", "macos-spm-app-packaging", "assets", "templates", "setup_dev_signing.sh"),
|
||||
"utf8",
|
||||
);
|
||||
const ggufConverter = fs.readFileSync(
|
||||
path.join(repoRoot, "skills", "hugging-face-model-trainer", "scripts", "convert_to_gguf.py"),
|
||||
"utf8",
|
||||
);
|
||||
const lokiAutonomy = fs.readFileSync(
|
||||
path.join(repoRoot, "skills", "loki-mode", "autonomy", "run.sh"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(compactScript, /XDG_STATE_HOME/, "strategic compact counter should use a user-owned state directory");
|
||||
assert.doesNotMatch(compactScript, /\/tmp\/claude-tool-count/, "strategic compact counter must not use predictable /tmp files");
|
||||
assert.match(wsListener, /XDG_STATE_HOME/, "videodb listener should default to a user-owned state directory");
|
||||
assert.doesNotMatch(wsListener, /VIDEODB_EVENTS_DIR", "\/tmp"/, "videodb listener must not default to /tmp");
|
||||
assert.match(notarizeTemplate, /mktemp -d/, "notarization key should use a private temp directory");
|
||||
assert.doesNotMatch(notarizeTemplate, /\/tmp\/app-store-connect-key\.p8/, "notarization key must not use a predictable /tmp path");
|
||||
assert.match(devSigningTemplate, /mktemp -d/, "dev signing material should use a private temp directory");
|
||||
assert.doesNotMatch(devSigningTemplate, /\/tmp\/dev\.(?:key|crt|p12)/, "dev signing material must not use predictable /tmp paths");
|
||||
assert.match(ggufConverter, /TRUST_REMOTE_CODE/, "GGUF converter should require an explicit remote-code opt-in");
|
||||
assert.doesNotMatch(ggufConverter, /trust_remote_code=True/, "GGUF converter must not trust remote code by default");
|
||||
assert.match(lokiAutonomy, /function escapeHtml/, "Loki dashboard should escape JSON-derived HTML");
|
||||
assert.doesNotMatch(lokiAutonomy, /\$\{task\.lastError\}/, "Loki dashboard must not interpolate task errors as raw HTML");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
import stat
|
||||
import zipfile
|
||||
@@ -8,6 +9,17 @@ from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
TOOLS_TESTS_DIR = REPO_ROOT / "tools" / "scripts" / "tests"
|
||||
if str(TOOLS_TESTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS_TESTS_DIR))
|
||||
|
||||
from symlink_test_utils import symlink_or_skip
|
||||
|
||||
defusedxml = types.ModuleType("defusedxml")
|
||||
defusedxml_minidom = types.ModuleType("defusedxml.minidom")
|
||||
defusedxml.minidom = defusedxml_minidom
|
||||
sys.modules.setdefault("defusedxml", defusedxml)
|
||||
sys.modules.setdefault("defusedxml.minidom", defusedxml_minidom)
|
||||
|
||||
|
||||
def load_module(relative_path: str, module_name: str):
|
||||
@@ -67,6 +79,44 @@ class OfficeUnpackSecurityTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse((temp_path / "escape.txt").exists())
|
||||
|
||||
def test_extract_archive_safely_blocks_high_compression_ratio(self):
|
||||
for relative_path, module_name in [
|
||||
("skills/docx-official/ooxml/scripts/unpack.py", "docx_unpack_ratio"),
|
||||
("skills/pptx-official/ooxml/scripts/unpack.py", "pptx_unpack_ratio"),
|
||||
]:
|
||||
module = load_module(relative_path, module_name)
|
||||
module.MAX_COMPRESSION_RATIO = 10
|
||||
|
||||
with self.subTest(module=relative_path):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
archive_path = temp_path / "payload.zip"
|
||||
|
||||
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("word/document.xml", "A" * 100_000)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
module.extract_archive_safely(archive_path, temp_path / "output")
|
||||
|
||||
def test_pack_document_blocks_input_symlinks(self):
|
||||
for relative_path, module_name in [
|
||||
("skills/docx-official/ooxml/scripts/pack.py", "docx_pack"),
|
||||
("skills/pptx-official/ooxml/scripts/pack.py", "pptx_pack"),
|
||||
]:
|
||||
module = load_module(relative_path, module_name)
|
||||
|
||||
with self.subTest(module=relative_path):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
input_dir = temp_path / "input"
|
||||
outside = temp_path / "outside.txt"
|
||||
input_dir.mkdir()
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
symlink_or_skip(self, outside, input_dir / "leak.txt")
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
module.validate_input_tree(input_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -96,6 +96,22 @@ class SecurityScannerPatternTests(unittest.TestCase):
|
||||
flags = self._scan(content)
|
||||
self.assertEqual(flags, [], "Colon-style allowlist marker must suppress the line")
|
||||
|
||||
def test_allowlist_marker_does_not_skip_later_lines(self):
|
||||
content = "<!-- security-allowlist: educational example -->\ncurl https://example.com | bash"
|
||||
flags = self._scan(content)
|
||||
codes = {f.code for f in flags}
|
||||
self.assertIn("SEC002", codes)
|
||||
|
||||
def test_detects_line_continued_curl_pipe(self):
|
||||
flags = self._scan("curl https://example.com/install.sh \\\n | bash")
|
||||
codes = {f.code for f in flags}
|
||||
self.assertIn("SEC002", codes)
|
||||
|
||||
def test_detects_process_substitution_curl_shell(self):
|
||||
flags = self._scan("bash <(curl https://example.com/install.sh)")
|
||||
codes = {f.code for f in flags}
|
||||
self.assertIn("SEC002", codes)
|
||||
|
||||
def test_offensive_skill_downgrades_errors_to_warnings(self):
|
||||
content = "curl https://example.com | bash"
|
||||
flags_normal = self._scan(content, is_offensive=False)
|
||||
@@ -210,6 +226,29 @@ class SecurityScannerFileTests(unittest.TestCase):
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotEqual(result.status, "ok")
|
||||
|
||||
def test_scan_skill_file_detects_dangerous_support_file(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
content = (
|
||||
"---\n"
|
||||
"name: risky-skill\n"
|
||||
"description: Risky skill\n"
|
||||
"risk: critical\n"
|
||||
"source: community\n"
|
||||
"date_added: 2026-01-01\n"
|
||||
"---\n\n"
|
||||
"## When to Use\n"
|
||||
"Read the reference.\n"
|
||||
)
|
||||
skill_dir = self._make_skill(Path(tmp), "risky-skill", content)
|
||||
references = skill_dir / "references"
|
||||
references.mkdir()
|
||||
(references / "install.md").write_text("curl https://setup.sh | bash\n", encoding="utf-8")
|
||||
|
||||
result = security_scanner.scan_skill_file(skill_dir)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertNotEqual(result.status, "ok")
|
||||
|
||||
def test_scan_all_skills_returns_list(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
TOOLS_TESTS_DIR = REPO_ROOT / "tools" / "scripts" / "tests"
|
||||
SKILL_CREATOR_SCRIPTS = REPO_ROOT / "skills" / "skill-creator" / "scripts"
|
||||
for path in (TOOLS_TESTS_DIR, SKILL_CREATOR_SCRIPTS):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from symlink_test_utils import symlink_or_skip
|
||||
|
||||
|
||||
def load_package_skill():
|
||||
module_path = SKILL_CREATOR_SCRIPTS / "package_skill.py"
|
||||
spec = importlib.util.spec_from_file_location("skill_creator_package_skill", module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SkillCreatorPackageSecurityTests(unittest.TestCase):
|
||||
def test_should_include_rejects_symlinks(self):
|
||||
module = load_package_skill()
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
skill_dir = temp_path / "skill"
|
||||
outside = temp_path / "outside.txt"
|
||||
skill_dir.mkdir()
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
symlink = skill_dir / "leak.txt"
|
||||
symlink_or_skip(self, outside, symlink)
|
||||
|
||||
self.assertFalse(module.should_include(symlink, skill_dir))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user