ONNX
security
malware-detection
File size: 10,845 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
package bundle

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"

	"huggingface.co/turenlabs/Vigil/source/pkg/parser"
)

const (
	// maxSniffBytes is the per-file read cap (1 MiB). Large enough that ordinary
	// scripts read fully (closing the front-padding tail-hiding hole); anything
	// larger sets File.Truncated so analyzers can FLAG ON TRUNCATE rather than
	// silently scanning only the prefix.
	maxSniffBytes = 1048576

	// maxBundleFiles bounds the number of members walked, guarding against a
	// pathological directory (or a symlink loop that the inode set somehow misses)
	// turning the scan into an unbounded crawl.
	maxBundleFiles = 4096
)

// skillMdName is the canonical entry-point filename (matched case-insensitively).
const skillMdName = "skill.md"

// IsBundlePath reports whether path should be scanned as a bundle: true if path
// is a directory, or a SKILL.md (case-insensitive) file inside a directory.
// A plain .md file that is not named SKILL.md is NOT a bundle (preserving
// byte-for-byte single-file behavior).
func IsBundlePath(path string) bool {
	info, err := os.Stat(path)
	if err != nil {
		return false
	}
	if info.IsDir() {
		return true
	}
	return strings.EqualFold(filepath.Base(path), skillMdName)
}

// ScanPath is the backward-compatible entry point.
//
//   - A directory                  -> full Bundle of that directory.
//   - A SKILL.md path              -> full Bundle of its parent directory.
//   - Any other regular file (.md) -> degenerate single-file Bundle with no
//     siblings, so legacy single-file scanning is unchanged.
func ScanPath(path string) (*Bundle, error) {
	info, err := os.Stat(path)
	if err != nil {
		return nil, fmt.Errorf("stat %s: %w", path, err)
	}

	if info.IsDir() {
		return Scan(path)
	}

	if strings.EqualFold(filepath.Base(path), skillMdName) {
		return Scan(filepath.Dir(path))
	}

	// Degenerate single-file bundle: parse only this file, no siblings.
	return scanSingleFile(path)
}

// scanSingleFile builds a degenerate Bundle from one markdown file (legacy
// single-file mode). No directory walk, no siblings.
func scanSingleFile(path string) (*Bundle, error) {
	abs, err := filepath.Abs(path)
	if err != nil {
		abs = path
	}
	b := &Bundle{
		Dir:         filepath.Dir(abs),
		SkillMdPath: abs,
		Files:       nil,
	}
	skill, perr := parser.Parse(path)
	if perr != nil {
		b.ParseErr = perr
	} else {
		b.Skill = skill
		b.AllowedTools = parseAllowedTools(skill.RawContent)
	}
	if info, serr := os.Stat(path); serr == nil {
		b.SkillMdBytes = info.Size()
	}
	return b, nil
}

// Scan walks the WHOLE skill directory (hidden/dot files included), locates
// SKILL.md case-insensitively, parses it via parser.Parse (unchanged), and
// populates Bundle.Files with magic-sniffed kinds, capped sniff prefixes,
// truncation/newline-ratio flags, and resolved symlink targets. It never panics
// on unreadable or looping entries — such cases append a Note and continue.
func Scan(dir string) (*Bundle, error) {
	absDir, err := filepath.Abs(dir)
	if err != nil {
		absDir = dir
	}

	b := &Bundle{Dir: absDir}

	// Locate SKILL.md (case-insensitive) at the top level of the directory.
	skillMdAbs := findSkillMd(absDir)
	b.SkillMdPath = skillMdAbs

	visited := make(map[uint64]bool) // inode set for symlink-loop safety
	fileCount := 0

	walkErr := filepath.WalkDir(absDir, func(path string, d os.DirEntry, walkErr error) error {
		if walkErr != nil {
			// Record and skip an unreadable entry; never abort the whole walk.
			b.Notes = append(b.Notes, fmt.Sprintf("walk error at %s: %v", relOrPath(absDir, path), walkErr))
			if d != nil && d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		if path == absDir {
			return nil // the root directory itself
		}

		if fileCount >= maxBundleFiles {
			b.Notes = append(b.Notes, fmt.Sprintf("file cap %d reached; remaining entries skipped", maxBundleFiles))
			return filepath.SkipAll
		}

		rel := relOrPath(absDir, path)

		if d.IsDir() {
			// Loop-safety for directory symlinks is handled below in the symlink
			// branch; a plain directory is just descended into.
			return nil
		}

		// SKILL.md itself is held on the Bundle, not in Files.
		if skillMdAbs != "" && sameAbs(path, skillMdAbs) {
			return nil
		}

		f := &File{
			RelPath: rel,
			AbsPath: path,
			Hidden:  pathHasHiddenSegment(rel),
		}

		// Detect symlinks via the entry type (WalkDir does NOT follow them, so a
		// symlinked directory arrives here as a non-dir entry — we never crawl
		// into it, only resolve and record its target).
		isSymlink := d.Type()&os.ModeSymlink != 0
		f.IsSymlink = isSymlink

		// readPath is the path we sniff bytes from: the resolved target for a
		// symlink (so a symlinked executable reference is first-class), else the
		// path itself.
		readPath := path

		if isSymlink {
			target, escapes, isLoop := resolveSymlink(path, absDir)
			f.SymlinkTarget = target
			f.SymlinkEscapes = escapes
			if isLoop {
				b.Notes = append(b.Notes, fmt.Sprintf("symlink loop broken at %s", rel))
				// Still record the entry (kind unknown) so a looping symlinked
				// reference is never silently dropped.
				b.Files = append(b.Files, f)
				fileCount++
				return nil
			}
			if target != "" {
				readPath = target
			}
			// Inode-dedupe the resolved target so two symlinks to the same file,
			// or a symlink pointing back at a real sibling, do not double-count and
			// cannot drive an unbounded loop.
			if ino, ok := inodeOf(readPath); ok {
				if visited[ino] {
					b.Notes = append(b.Notes, fmt.Sprintf("symlink target already visited at %s", rel))
				} else {
					visited[ino] = true
				}
			}
		} else if ino, ok := inodeOf(path); ok {
			visited[ino] = true
		}

		// Size from the resolved target (Lstat-size of a symlink is just the link
		// length, which is useless for payload accounting).
		if info, serr := os.Stat(readPath); serr == nil {
			f.SizeBytes = info.Size()
		}

		sniff, _, truncated, newlineRatio, rerr := readSniff(readPath)
		if rerr != nil {
			b.Notes = append(b.Notes, fmt.Sprintf("read error at %s: %v", rel, rerr))
		}
		f.Sniff = sniff
		f.Truncated = truncated
		f.NewlineRatio = newlineRatio

		f.Kind = classifyKind(filepath.Base(path), sniff)
		f.ScriptLang = scriptLangFor(filepath.Base(path), sniff)

		b.Files = append(b.Files, f)
		fileCount++
		b.PayloadBytes += f.SizeBytes
		return nil
	})
	if walkErr != nil {
		b.Notes = append(b.Notes, fmt.Sprintf("walk terminated: %v", walkErr))
	}

	// Parse SKILL.md via the unchanged parser.
	if skillMdAbs != "" {
		skill, perr := parser.Parse(skillMdAbs)
		if perr != nil {
			b.ParseErr = perr
		} else {
			b.Skill = skill
			b.AllowedTools = parseAllowedTools(skill.RawContent)
		}
		if info, serr := os.Stat(skillMdAbs); serr == nil {
			b.SkillMdBytes = info.Size()
		}
	} else {
		b.Notes = append(b.Notes, "no SKILL.md found in bundle directory")
	}

	resolveReferences(b)
	return b, nil
}

// findSkillMd returns the absolute path of the top-level SKILL.md
// (case-insensitive) in dir, or "" if none exists. Only the immediate directory
// is consulted; a SKILL.md nested in a subdirectory is treated as a sibling
// markdown payload, not the entry point.
func findSkillMd(dir string) string {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return ""
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		if strings.EqualFold(e.Name(), skillMdName) {
			return filepath.Join(dir, e.Name())
		}
	}
	return ""
}

// readSniff reads up to maxSniffBytes from absPath. It reports the full file
// size, whether the file exceeded the cap (Truncated => the tail is NOT in
// data, the flag-on-truncate signal), and the newline ratio of the read prefix
// (newline bytes / len, used for padding-evasion detection). It never reads
// unbounded content, so a symlink to a huge file is safe.
func readSniff(absPath string) (data []byte, size int64, truncated bool, newlineRatio float64, err error) {
	f, oerr := os.Open(absPath) // #nosec G304 -- absPath is a bundle member discovered by the walk; reads are capped at maxSniffBytes and never executed. batou:ignore injection -- defensive scanner reads attacker-controlled skill files by design; bounded read, no exec.
	if oerr != nil {
		return nil, 0, false, 0, oerr
	}
	defer func() { _ = f.Close() }()

	if info, serr := f.Stat(); serr == nil {
		size = info.Size()
	}

	// Read one byte past the cap to detect truncation deterministically even when
	// Stat size is unavailable or lies (e.g. a growing/proc-like file).
	buf := make([]byte, maxSniffBytes+1)
	n, rerr := io.ReadFull(f, buf)
	if rerr != nil && rerr != io.EOF && rerr != io.ErrUnexpectedEOF {
		return nil, size, false, 0, rerr
	}

	if n > maxSniffBytes {
		truncated = true
		n = maxSniffBytes
	}
	data = buf[:n]

	if n > 0 {
		newlines := 0
		for _, b := range data {
			if b == '\n' {
				newlines++
			}
		}
		newlineRatio = float64(newlines) / float64(n)
	}

	return data, size, truncated, newlineRatio, nil
}

// resolveSymlink resolves a symlink at absPath. It returns the fully-resolved
// target (via EvalSymlinks), whether that target lies OUTSIDE bundleRoot
// (SymlinkEscapes), and whether resolution failed in a way consistent with a
// loop (isLoop). A loop or unresolvable target never causes a panic; the caller
// records a Note and keeps the entry so a symlinked executable reference is
// never silently dropped.
func resolveSymlink(absPath, bundleRoot string) (target string, escapes bool, isLoop bool) {
	resolved, err := filepath.EvalSymlinks(absPath)
	if err != nil {
		// EvalSymlinks fails on a loop ("too many links") and on a dangling
		// target. Fall back to a single os.Readlink so we can still record where
		// the link points (its declared target) without following it.
		if link, lerr := os.Readlink(absPath); lerr == nil {
			t := link
			if !filepath.IsAbs(t) {
				t = filepath.Join(filepath.Dir(absPath), t)
			}
			return filepath.Clean(t), !withinRoot(filepath.Clean(t), bundleRoot), true
		}
		return "", false, true
	}
	return resolved, !withinRoot(resolved, bundleRoot), false
}

// withinRoot reports whether target is inside root (or equal to it).
func withinRoot(target, root string) bool {
	rel, err := filepath.Rel(root, target)
	if err != nil {
		return false
	}
	return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
}

// relOrPath returns path relative to base, falling back to the absolute path if
// the relative form cannot be computed.
func relOrPath(base, path string) string {
	if rel, err := filepath.Rel(base, path); err == nil {
		return rel
	}
	return path
}

// sameAbs reports whether two paths refer to the same absolute location.
func sameAbs(a, b string) bool {
	ca, _ := filepath.Abs(a)
	cb, _ := filepath.Abs(b)
	return filepath.Clean(ca) == filepath.Clean(cb)
}