File size: 14,161 Bytes
d2507b5 a2a3348 d2507b5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 | // 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 ""
}
|