ONNX
security
malware-detection
Vigil / source /pkg /bundle /bundle.go
turentomer's picture
Make published source self-contained
a2a3348 verified
Raw
History Blame Contribute Delete
14.2 kB
// Package bundle implements bundle-aware scanning of Claude Code skills.
//
// A skill is a DIRECTORY ("Bundle"): a SKILL.md plus sibling files it
// references (scripts, compiled artifacts, archives, binaries, images, data).
// The malicious payload frequently lives in the sibling files, not in the
// SKILL.md itself. This package walks the WHOLE directory (hidden/dot files
// included), classifies every sibling by SNIFFED MAGIC (not extension alone),
// resolves symlink targets, and resolves which siblings the SKILL.md
// references.
//
// bundle.go holds the pure data model (Bundle/File + FileKind) and pure helper
// functions with NO I/O, so the per-kind Analyzers (in sibling files) and the
// Scan walker can import it cycle-free.
package bundle
import (
"path/filepath"
"strings"
"huggingface.co/turenlabs/Vigil/source/pkg/types"
)
// FileKind classifies a bundle member by what it actually is (sniffed magic
// first, then extension, then by-name special files).
type FileKind int
const (
// KindSkillMd is the SKILL.md entry point itself.
KindSkillMd FileKind = iota
// KindMarkdown is a non-SKILL.md markdown file.
KindMarkdown
// KindShell is a .sh/.bash script or a shebang-detected shell script.
KindShell
// KindPythonSource is a .py source file.
KindPythonSource
// KindScriptOther covers .mjs/.cjs/.ts/.rb/.ps1/.bat/.lua/.pl and by-name
// Makefile/Dockerfile/justfile: executable text the threat model says
// scanners ignore.
KindScriptOther
// KindPyc is compiled Python bytecode (.pyc).
KindPyc
// KindArchive is a zip-family container (.zip/.docx/.xlsx/.pptx or any
// PK-sniffed file).
KindArchive
// KindNativeBinary is an ELF/Mach-O object or .so/.dylib/.node.
KindNativeBinary
// KindWasm is a WebAssembly module (\0asm magic).
KindWasm
// KindImage is a raster image (.png/.jpg/.jpeg/.gif/.webp).
KindImage
// KindData is structured data: json/yaml/csv/.ipynb.
KindData
// KindText is plain text not otherwise classified.
KindText
// KindUnknown is a member whose kind could not be determined.
KindUnknown
)
// String returns the stable lowercase token for a FileKind. These tokens are
// part of the manifest schema (FileKind.String() set) consumed by the trainer,
// so they must not change without a corpus migration.
func (k FileKind) String() string {
switch k {
case KindSkillMd:
return "skill_md"
case KindMarkdown:
return "markdown"
case KindShell:
return "shell"
case KindPythonSource:
return "python"
case KindScriptOther:
return "script_other"
case KindPyc:
return "pyc"
case KindArchive:
return "archive"
case KindNativeBinary:
return "native_binary"
case KindWasm:
return "wasm"
case KindImage:
return "image"
case KindData:
return "data"
case KindText:
return "text"
default:
return "unknown"
}
}
// File is a single member of a Bundle (a sibling of SKILL.md). SKILL.md itself
// is held on Bundle.Skill/SkillMdPath, not in Bundle.Files.
type File struct {
RelPath string // path relative to Bundle.Dir, e.g. ".hidden/sync.sh"
AbsPath string // absolute path on disk
Kind FileKind // sniffed-magic-first classification
SizeBytes int64 // full on-disk size (not the capped Sniff len)
Hidden bool // any path segment begins with '.'
Referenced bool // SKILL.md names this file
RefSources []string // "body"|"frontmatter"|"allowed-tools"|"markdown-link"|"code-span"
Sniff []byte // first <= maxSniffBytes bytes, for analyzers
Truncated bool // file exceeded maxSniffBytes; tail not in Sniff (flag-on-truncate)
NewlineRatio float64 // newline bytes / Sniff len, for padding-evasion detection
IsSymlink bool // entry is a symlink
SymlinkTarget string // EvalSymlinks result (resolved), empty if not a symlink
SymlinkEscapes bool // resolved target lies outside Bundle.Dir
ScriptLang string // "sh"|"python"|"node"|"ruby"|"powershell"|"make"|... when a script kind
}
// Bundle is a parsed skill directory: the SKILL.md plus every sibling.
type Bundle struct {
Dir string
SkillMdPath string
Skill *types.SkillFile // parsed via parser.Parse (unchanged); nil if ParseErr != nil
Files []*File // every sibling incl. hidden + symlinks, excludes SKILL.md itself
SkillMdBytes int64
PayloadBytes int64 // sum of non-SKILL.md file sizes
AllowedTools []string // parsed from raw frontmatter (parser does not capture this)
ParseErr error
Notes []string // skipped-loop/unreadable notes; never a panic
}
// isHiddenName reports whether a single path segment is a hidden/dot file.
// The root "." or ".." segments are never themselves payload files.
func isHiddenName(name string) bool {
return len(name) > 1 && name[0] == '.' && name != ".." && name != "."
}
// pathHasHiddenSegment reports whether any segment of a relative path is hidden.
func pathHasHiddenSegment(relPath string) bool {
for _, seg := range strings.Split(filepath.ToSlash(relPath), "/") {
if isHiddenName(seg) {
return true
}
}
return false
}
// sniffMagicKind classifies a file by leading magic bytes ALONE. Magic is
// authoritative over extension (a notes.txt that is really an ELF must classify
// as KindNativeBinary; an image1.png that is really a script must not classify
// as KindImage). The second return is false when no magic matched, leaving the
// caller to fall back to extension / by-name classification.
//
// Recognized magic:
// - PK\x03\x04 / PK\x05\x06 / PK\x07\x08 -> zip family (KindArchive)
// - \x7fELF -> ELF (KindNativeBinary)
// - Mach-O (fat + thin, both endians) -> Mach-O (KindNativeBinary)
// - \0asm -> wasm (KindWasm)
// - PNG / JPEG / GIF / WEBP(RIFF....WEBP) -> image (KindImage)
// - CPython .pyc magic (low 2 bytes + \r\n at [2:4]) -> KindPyc
func sniffMagicKind(sniff []byte) (FileKind, bool) {
if len(sniff) < 4 {
return KindUnknown, false
}
// Zip family (also docx/xlsx/pptx, which are zips).
if hasPrefix(sniff, []byte("PK\x03\x04")) ||
hasPrefix(sniff, []byte("PK\x05\x06")) ||
hasPrefix(sniff, []byte("PK\x07\x08")) {
return KindArchive, true
}
// ELF.
if hasPrefix(sniff, []byte("\x7fELF")) {
return KindNativeBinary, true
}
// Mach-O: thin (0xFEEDFACE / 0xFEEDFACF, both byte orders) and fat
// (0xCAFEBABE / 0xBEBAFECA). 0xCAFEBABE also collides with Java .class, but
// .class is not in this threat model and is still opaque native-ish code, so
// classifying it as KindNativeBinary is the safe (non-skip) choice.
switch {
case hasPrefix(sniff, []byte{0xFE, 0xED, 0xFA, 0xCE}),
hasPrefix(sniff, []byte{0xFE, 0xED, 0xFA, 0xCF}),
hasPrefix(sniff, []byte{0xCE, 0xFA, 0xED, 0xFE}),
hasPrefix(sniff, []byte{0xCF, 0xFA, 0xED, 0xFE}),
hasPrefix(sniff, []byte{0xCA, 0xFE, 0xBA, 0xBE}),
hasPrefix(sniff, []byte{0xBE, 0xBA, 0xFE, 0xCA}):
return KindNativeBinary, true
}
// WebAssembly.
if hasPrefix(sniff, []byte{0x00, 0x61, 0x73, 0x6D}) { // \0asm
return KindWasm, true
}
// Images.
if hasPrefix(sniff, []byte{0x89, 0x50, 0x4E, 0x47}) { // PNG \x89PNG
return KindImage, true
}
if hasPrefix(sniff, []byte{0xFF, 0xD8, 0xFF}) { // JPEG
return KindImage, true
}
if hasPrefix(sniff, []byte("GIF87a")) || hasPrefix(sniff, []byte("GIF89a")) {
return KindImage, true
}
if len(sniff) >= 12 && hasPrefix(sniff, []byte("RIFF")) &&
string(sniff[8:12]) == "WEBP" {
return KindImage, true
}
// CPython .pyc: 4-byte magic where bytes [2:4] are \r\n (0x0D 0x0A). The low
// two bytes are a version-specific number that changes per release, so we key
// on the stable \r\n pair plus a non-zero first byte (avoids matching a file
// that merely starts with \0\0\r\n).
if len(sniff) >= 4 && sniff[2] == 0x0D && sniff[3] == 0x0A &&
!(sniff[0] == 0x00 && sniff[1] == 0x00) {
return KindPyc, true
}
return KindUnknown, false
}
// hasPrefix reports whether data begins with prefix.
func hasPrefix(data, prefix []byte) bool {
if len(data) < len(prefix) {
return false
}
for i := range prefix {
if data[i] != prefix[i] {
return false
}
}
return true
}
// hasShebang reports whether sniff begins with a "#!" shebang line.
func hasShebang(sniff []byte) bool {
return len(sniff) >= 2 && sniff[0] == '#' && sniff[1] == '!'
}
// firstLine returns the first line of sniff (without the trailing newline),
// capped at 256 bytes so a pathological no-newline blob cannot be scanned whole.
func firstLine(sniff []byte) string {
limit := len(sniff)
if limit > 256 {
limit = 256
}
for i := 0; i < limit; i++ {
if sniff[i] == '\n' {
return string(sniff[:i])
}
}
return string(sniff[:limit])
}
// classifyKind classifies a file by SNIFFED MAGIC FIRST, then by extension,
// then by-name special files (Makefile/Dockerfile/justfile). name is the
// basename; sniff is the leading bytes (may be empty for unreadable files).
func classifyKind(name string, sniff []byte) FileKind {
base := filepath.Base(name)
lower := strings.ToLower(base)
// 1. Magic sniff is authoritative.
if k, ok := sniffMagicKind(sniff); ok {
// SKILL.md is decided by the walker, never by magic; but a markdown file
// that happens to begin with bytes resembling magic is vanishingly rare
// and would already be handled below by extension. Magic wins for the
// concrete binary/archive/image/pyc kinds returned by sniffMagicKind.
return k
}
// 2. Shebang beats a misleading EXTENSION. A file with no binary/archive/image
// magic that nonetheless begins with "#!" is an executable script regardless
// of how its name is dressed (the threat model's "image1.png that is really a
// script" case). This must run BEFORE extension classification so a .png/.txt
// disguise cannot demote a real script to image/text. (A genuine PNG carries
// PNG magic and was already returned in step 1, so this never mis-tags a real
// image.)
if hasShebang(sniff) {
return shebangKind(sniff)
}
// 3. By-name special files (no extension, but executable-by-convention).
switch {
case lower == "makefile" || lower == "gnumakefile" || strings.HasPrefix(lower, "makefile."):
return KindScriptOther
case lower == "dockerfile" || strings.HasPrefix(lower, "dockerfile.") || strings.HasSuffix(lower, ".dockerfile"):
return KindScriptOther
case lower == "justfile" || lower == ".justfile":
return KindScriptOther
}
// 4. Extension-based classification.
ext := strings.ToLower(filepath.Ext(base))
switch ext {
case ".md", ".markdown":
return KindMarkdown
case ".sh", ".bash", ".zsh", ".ksh":
return KindShell
case ".py", ".pyw":
return KindPythonSource
case ".pyc", ".pyo":
return KindPyc
case ".mjs", ".cjs", ".js", ".ts", ".rb", ".ps1", ".psm1", ".bat", ".cmd", ".lua", ".pl", ".pm":
return KindScriptOther
case ".zip", ".docx", ".xlsx", ".pptx", ".jar", ".odt", ".ods", ".odp":
return KindArchive
case ".so", ".dylib", ".node", ".o", ".a":
return KindNativeBinary
case ".wasm":
return KindWasm
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff":
return KindImage
case ".json", ".yaml", ".yml", ".csv", ".tsv", ".ipynb", ".toml":
return KindData
case ".txt", ".text", ".rst", ".log", ".cfg", ".ini", ".env":
return KindText
}
// 5. Printable text with no other signal -> KindText; otherwise KindUnknown.
// (A shebang was already handled in step 2, so any remaining script-shaped
// file here is plain text.)
if looksTextual(sniff) {
return KindText
}
return KindUnknown
}
// shebangKind maps a "#!..." interpreter line to a script FileKind.
func shebangKind(sniff []byte) FileKind {
line := firstLine(sniff)
switch {
case strings.Contains(line, "python"):
return KindPythonSource
case strings.Contains(line, "node"):
return KindScriptOther
case strings.Contains(line, "ruby"):
return KindScriptOther
case strings.Contains(line, "perl"):
return KindScriptOther
case strings.Contains(line, "sh"): // bash, sh, dash, zsh, ksh
return KindShell
default:
return KindScriptOther
}
}
// looksTextual reports whether a sniff prefix is mostly printable text. Empty
// input is treated as non-textual (unknown), so an unreadable file does not get
// misclassified as text.
func looksTextual(sniff []byte) bool {
if len(sniff) == 0 {
return false
}
probe := sniff
if len(probe) > 512 {
probe = probe[:512]
}
nonPrintable := 0
for _, b := range probe {
if b == 0 {
return false // NUL byte => binary
}
if b < 0x09 || (b > 0x0D && b < 0x20) {
nonPrintable++
}
}
return nonPrintable*100/len(probe) < 10
}
// scriptLangFor returns a coarse language tag for a script-bearing kind, used by
// analyzers to pick the right indicator vocab. Empty string for non-script
// kinds. Resolution order matches classifyKind: by-name, then extension, then
// shebang.
func scriptLangFor(name string, sniff []byte) string {
base := filepath.Base(name)
lower := strings.ToLower(base)
switch {
case lower == "makefile" || lower == "gnumakefile" || strings.HasPrefix(lower, "makefile."):
return "make"
case lower == "dockerfile" || strings.HasPrefix(lower, "dockerfile.") || strings.HasSuffix(lower, ".dockerfile"):
return "docker"
case lower == "justfile" || lower == ".justfile":
return "just"
}
switch strings.ToLower(filepath.Ext(base)) {
case ".sh", ".bash", ".zsh", ".ksh":
return "sh"
case ".py", ".pyw":
return "python"
case ".pyc", ".pyo":
return "python"
case ".mjs", ".cjs", ".js", ".ts":
return "node"
case ".rb":
return "ruby"
case ".ps1", ".psm1":
return "powershell"
case ".bat", ".cmd":
return "batch"
case ".lua":
return "lua"
case ".pl", ".pm":
return "perl"
}
if hasShebang(sniff) {
line := strings.ToLower(firstLine(sniff))
switch {
case strings.Contains(line, "python"):
return "python"
case strings.Contains(line, "node"):
return "node"
case strings.Contains(line, "ruby"):
return "ruby"
case strings.Contains(line, "perl"):
return "perl"
case strings.Contains(line, "sh"):
return "sh"
default:
return "sh"
}
}
return ""
}