ONNX
security
malware-detection
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 ""
}