feat(skill): broaden script security scan + GBK zip-import fallback

This commit is contained in:
matevip 2026-05-23 22:55:10 +08:00
parent 4e01c98fc1
commit 5cd6e841a4
4 changed files with 222 additions and 3 deletions

View File

@ -5,8 +5,11 @@ import org.springframework.web.multipart.MultipartFile;
import vip.mate.skill.installer.model.SkillBundle;
import vip.mate.skill.runtime.SkillFrontmatterParser;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
@ -135,12 +138,55 @@ public class ZipSkillFetcher {
* instead of being silently dropped.
*/
public static ExtractedSkill extract(InputStream zipStream) throws IOException {
return extract(zipStream.readAllBytes());
}
/** Fallback charset for archives authored on Chinese Windows (entry names / content in GBK). */
private static final Charset GBK = Charset.isSupported("GBK") ? Charset.forName("GBK") : null;
/**
* Decompress raw ZIP bytes, trying UTF-8 first and falling back to GBK when
* an entry name fails to decode as UTF-8 the common failure mode for zips
* packaged on Chinese Windows, where filenames are GBK and UTF-8 decoding
* throws a {@link CharacterCodingException}. Buffering the bytes (rather than
* a one-shot stream) is what makes the retry possible.
*/
public static ExtractedSkill extract(byte[] zipBytes) throws IOException {
try {
return extract(zipBytes, StandardCharsets.UTF_8);
} catch (IOException | RuntimeException e) {
if (GBK != null && isCharsetError(e)) {
log.warn("[ZipSkillFetcher] UTF-8 entry decode failed, retrying with GBK (Windows-authored archive?)");
return extract(zipBytes, GBK);
}
throw e;
}
}
/** True if {@code t} (or any cause) is a charset-decode failure, vs a genuine "no SKILL.md" error. */
private static boolean isCharsetError(Throwable t) {
for (Throwable c = t; c != null; c = c.getCause()) {
if (c instanceof CharacterCodingException) {
return true;
}
String m = c.getMessage();
if (m != null && m.toLowerCase().contains("malformed")) {
return true;
}
if (c.getCause() == c) {
break;
}
}
return false;
}
private static ExtractedSkill extract(byte[] zipBytes, Charset charset) throws IOException {
List<RawEntry> raws = new ArrayList<>();
String skillMdContent = null;
String skillMdPrefix = "";
long totalSize = 0;
try (ZipInputStream zis = new ZipInputStream(zipStream, StandardCharsets.UTF_8)) {
try (ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(zipBytes), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
if (entry.isDirectory()) {
@ -150,6 +196,13 @@ public class ZipSkillFetcher {
String entryName = entry.getName();
// Skip macOS archive cruft so it doesn't surface as "ignored" noise.
if (entryName.startsWith("__MACOSX/") || entryName.equals(".DS_Store")
|| entryName.endsWith("/.DS_Store")) {
zis.closeEntry();
continue;
}
// Zip Slip guard: normalize and reject absolute / traversal entries.
Path entryPath = Path.of(entryName).normalize();
if (entryPath.isAbsolute() || entryName.contains("..")) {
@ -176,7 +229,7 @@ public class ZipSkillFetcher {
throw new IOException("Total extracted size exceeds 50MB limit");
}
String content = new String(bytes, StandardCharsets.UTF_8);
String content = new String(bytes, charset);
String normalizedName = entryPath.toString().replace('\\', '/');
String fileName = entryPath.getFileName().toString();

View File

@ -121,7 +121,87 @@ public class SkillSecurityService {
"(?i)import\\s+(os|subprocess|shutil)",
"Python system module import",
"Importing os/subprocess/shutil enables system-level operations",
"Ensure system operations are necessary and scoped appropriately")
"Ensure system operations are necessary and scoped appropriately"),
// ===== Python不可信反序列化 / 沙箱逃逸 =====
rule("PICKLE_DESERIALIZE", "DESERIALIZATION", SkillValidationResult.Severity.HIGH,
"(?i)\\b(c?pickle|dill)\\.loads?\\s*\\(",
"Untrusted deserialization (pickle)",
"pickle/dill load executes arbitrary code embedded in the payload",
"Use json or a vetted serializer; never unpickle untrusted data"),
rule("MARSHAL_LOADS", "DESERIALIZATION", SkillValidationResult.Severity.HIGH,
"(?i)\\bmarshal\\.loads?\\s*\\(",
"Untrusted deserialization (marshal)",
"marshal can execute crafted bytecode",
"Avoid marshal for external data"),
// MEDIUM (warn, not block): yaml.load is only unsafe WITHOUT SafeLoader,
// and the line-by-line scan can't see a SafeLoader argument that wraps
// onto the next line so blocking here would false-positive legit
// multi-line safe calls. Surface it for review instead of blocking.
rule("YAML_UNSAFE_LOAD", "DESERIALIZATION", SkillValidationResult.Severity.MEDIUM,
"(?i)\\byaml\\.load\\s*\\((?![^)]*(?i:safe))",
"Possibly unsafe yaml.load",
"yaml.load without SafeLoader can instantiate arbitrary Python objects",
"Use yaml.safe_load or Loader=yaml.SafeLoader"),
rule("PY_SANDBOX_ESCAPE", "SANDBOX_ESCAPE", SkillValidationResult.Severity.HIGH,
"(__subclasses__|__mro__|__builtins__|__globals__)",
"Python sandbox-escape primitive",
"Introspection attributes commonly used to break out of restricted execution",
"Remove reflection into builtins / class hierarchies"),
rule("PY_CTYPES", "CODE_EXECUTION", SkillValidationResult.Severity.HIGH,
"(?i)\\bctypes\\.(cdll|windll)\\b",
"Native library loading via ctypes",
"ctypes can load and call arbitrary native code",
"Avoid ctypes; use safe Python APIs"),
rule("PY_DYNAMIC_IMPORT", "CODE_EXECUTION", SkillValidationResult.Severity.LOW,
"(?i)(\\b__import__\\s*\\(|\\bimportlib\\.import_module\\s*\\()",
"Dynamic module import",
"Dynamic imports can load attacker-controlled modules",
"Import modules statically by name where possible"),
// ===== Node.js危险 API =====
rule("NODE_CHILD_PROCESS", "CODE_EXECUTION", SkillValidationResult.Severity.MEDIUM,
"(?i)(require\\s*\\(\\s*['\"]child_process['\"]\\s*\\)|child_process\\.(exec|execSync|spawn|spawnSync|fork))",
"Node child_process execution",
"child_process can run arbitrary system commands",
"Confirm subprocess use is necessary and arguments are not attacker-controlled"),
rule("NODE_NEW_FUNCTION", "CODE_EXECUTION", SkillValidationResult.Severity.HIGH,
"(?i)\\bnew\\s+Function\\s*\\(",
"Dynamic code execution (new Function)",
"new Function compiles strings into executable code, like eval",
"Use structured logic instead of constructing functions from strings"),
rule("NODE_VM_MODULE", "CODE_EXECUTION", SkillValidationResult.Severity.MEDIUM,
"(?i)require\\s*\\(\\s*['\"](vm|vm2)['\"]\\s*\\)",
"Node vm/vm2 module",
"vm/vm2 are frequently used (and escaped) for sandboxed eval",
"Avoid the vm module for untrusted code"),
rule("PROTOTYPE_POLLUTION", "SANDBOX_ESCAPE", SkillValidationResult.Severity.MEDIUM,
"(\\[\\s*['\"]__proto__['\"]\\s*\\]|\\.__proto__\\s*=)",
"Prototype pollution pattern",
"Writing __proto__ can corrupt object prototypes platform-wide",
"Validate keys before dynamic property assignment"),
// ===== 混淆 / 资源耗尽 / 持久化 / 凭据读取 =====
rule("BASE64_PIPE_EXEC", "OBFUSCATION", SkillValidationResult.Severity.HIGH,
"(?i)base64\\s+(-d|--decode)\\b[^\\n|]*\\|\\s*(sh|bash|zsh|python|perl|node)\\b",
"Obfuscated execution (base64 decode piped to interpreter)",
"Decoding then piping to a shell hides the real command from review",
"Ship the command in clear text"),
rule("FORK_BOMB", "RESOURCE_EXHAUSTION", SkillValidationResult.Severity.CRITICAL,
":\\s*\\(\\s*\\)\\s*\\{\\s*:\\s*\\|\\s*:\\s*&\\s*\\}\\s*;\\s*:",
"Fork bomb",
"Self-replicating process that exhausts system resources",
"Remove the fork bomb"),
rule("PERSISTENCE", "PERSISTENCE", SkillValidationResult.Severity.MEDIUM,
"(?i)(crontab\\s+-|/etc/cron|authorized_keys|/etc/rc\\.local|/etc/profile\\.d/|>>\\s*~?/?\\.?(bashrc|zshrc|profile))",
"Persistence mechanism",
"Modifies startup files / cron / SSH keys to persist across sessions",
"Skills should not install persistence hooks"),
rule("SECRET_FILE_READ", "DATA_EXFILTRATION", SkillValidationResult.Severity.HIGH,
"(?i)(/etc/shadow|\\.ssh/id_(rsa|ed25519|ecdsa)|\\.aws/credentials|\\.kube/config)",
"Sensitive credential file access",
"References well-known secret files (private keys, cloud credentials)",
"Skills must not read system or user credential files")
);
/**

View File

@ -204,4 +204,29 @@ class ZipSkillFetcherTest {
assertEquals(1, ex.scripts().size());
assertEquals("#!/bin/sh\n", ex.scripts().get("setup.sh"));
}
@Test
@DisplayName("GBK-encoded entry names (Windows-authored zip) fall back from UTF-8 to GBK")
void extractsGbkEncodedNames() throws IOException {
org.junit.jupiter.api.Assumptions.assumeTrue(
java.nio.charset.Charset.isSupported("GBK"), "GBK charset not available on this JVM");
java.nio.charset.Charset gbk = java.nio.charset.Charset.forName("GBK");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(baos, gbk)) {
zos.putNextEntry(new ZipEntry("SKILL.md"));
zos.write(SKILL_MD.getBytes(gbk));
zos.closeEntry();
// Chinese filename whose GBK bytes are invalid UTF-8 forces the fallback.
zos.putNextEntry(new ZipEntry("references/中文说明.md"));
zos.write("# 中文内容\n".getBytes(gbk));
zos.closeEntry();
}
ZipSkillFetcher.ExtractedSkill ex = ZipSkillFetcher.extract(baos.toByteArray());
assertNotNull(ex.skillMdContent());
assertEquals(1, ex.references().size(), "GBK-named reference should survive the charset fallback");
assertEquals("# 中文内容\n", ex.references().get("中文说明.md"));
}
}

View File

@ -214,4 +214,65 @@ class SkillSecurityServiceTest {
assertEquals(3, sudoFinding.getLineNumber());
assertNotNull(sudoFinding.getSnippet());
}
// ===== 扩充规则反序列化 / 沙箱逃逸 / 混淆 / 资源耗尽 / 凭据 =====
@Test
@DisplayName("检测 pickle.loads 不可信反序列化 → HIGH → blocked")
void shouldBlockPickleDeserialize(@TempDir Path tempDir) throws IOException {
Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test");
Path scripts = Files.createDirectory(tempDir.resolve("scripts"));
Files.writeString(scripts.resolve("load.py"), "import pickle\nobj = pickle.loads(payload)\n");
SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill");
assertTrue(result.isBlocked());
assertTrue(result.getFindings().stream()
.anyMatch(f -> f.getRuleId().equals("PICKLE_DESERIALIZE")));
}
@Test
@DisplayName("检测 fork bomb → CRITICAL → blocked")
void shouldBlockForkBomb(@TempDir Path tempDir) throws IOException {
Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test");
Path scripts = Files.createDirectory(tempDir.resolve("scripts"));
Files.writeString(scripts.resolve("bomb.sh"), "#!/bin/bash\n:(){ :|:& };:\n");
SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill");
assertTrue(result.isBlocked());
assertEquals(SkillValidationResult.Severity.CRITICAL, result.getMaxSeverity());
assertTrue(result.getFindings().stream()
.anyMatch(f -> f.getRuleId().equals("FORK_BOMB")));
}
@Test
@DisplayName("检测读取 SSH 私钥 → HIGH → blocked")
void shouldBlockSecretFileRead(@TempDir Path tempDir) throws IOException {
Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test");
Path scripts = Files.createDirectory(tempDir.resolve("scripts"));
Files.writeString(scripts.resolve("steal.sh"), "cat ~/.ssh/id_rsa\n");
SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill");
assertTrue(result.isBlocked());
assertTrue(result.getFindings().stream()
.anyMatch(f -> f.getRuleId().equals("SECRET_FILE_READ")));
}
@Test
@DisplayName("Node child_process → MEDIUM → 警告但不阻断")
void shouldWarnNodeChildProcess(@TempDir Path tempDir) throws IOException {
Files.writeString(tempDir.resolve("SKILL.md"), "---\nname: test\n---\n# Test");
Path scripts = Files.createDirectory(tempDir.resolve("scripts"));
// spawn (not exec) so the existing EVAL_EXEC HIGH rule doesn't also fire
Files.writeString(scripts.resolve("run.js"), "const cp = require('child_process');\ncp.spawn('ls');\n");
SkillValidationResult result = securityService.scanDirectory(tempDir, "test_skill");
assertTrue(result.isPassed(), "MEDIUM should pass");
assertFalse(result.isBlocked(), "MEDIUM should not block");
assertTrue(result.getFindings().stream()
.anyMatch(f -> f.getRuleId().equals("NODE_CHILD_PROCESS")));
}
}