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

import (
	"fmt"
	"path/filepath"
	"sort"

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

// BundleResult is the raw cross-file analysis output for a bundle: every
// per-file/cross-file Finding plus the aggregated BundleSignals derived from
// them. It is independent of the SKILL.md scorer Verdict.
type BundleResult struct {
	Findings []Finding     `json:"findings"`
	Signals  BundleSignals `json:"signals"`
}

// FileVerdict is the per-sibling view surfaced in bundle output: which file,
// its sniffed kind, whether SKILL.md referenced it, whether it is hidden, and
// the findings raised against it.
type FileVerdict struct {
	File       string    `json:"file"`
	Kind       string    `json:"kind"`
	Referenced bool      `json:"referenced"`
	Hidden     bool      `json:"hidden"`
	Findings   []Finding `json:"findings,omitempty"`
}

// BundleVerdict is the authoritative result for a whole skill directory. The
// Verdict field is the unchanged SKILL.md sub-verdict produced by the existing
// scorer; Label is the AUTHORITATIVE final bundle label, which is the stronger
// of the scorer sub-verdict and the precision-aware bundleEscalates decision.
type BundleVerdict struct {
	Dir         string         `json:"dir"`
	SkillMd     string         `json:"skill_md"`
	Verdict     *types.Verdict `json:"verdict"`
	Files       []FileVerdict  `json:"files"`
	Bundle      BundleResult   `json:"bundle"`
	Label       string         `json:"label"`
	EscalatedBy string         `json:"escalated_by,omitempty"`

	// Capability/risk surfacing layer (host-agnostic). These AUGMENT the Label:
	// the label is still derived as malicious iff RiskTier==REVIEW, and REVIEW is
	// pinned to the exact (scorerMalicious || bundleEscalates) decision that sets
	// the label today — so the label is byte-identical to the pre-capability era.
	Capabilities []Capability `json:"capabilities,omitempty"`
	BigNasties   []BigNasty   `json:"big_nasties,omitempty"`
	RiskTier     string       `json:"risk_tier"`
}

// Analyze runs the full analyzer pipeline over a bundle and aggregates the
// resulting cross-file signals.
//
// Dispatch is two-phase by design. Phase 1 runs every sibling File through the
// file-content analyzers (Shell, Python, ScriptOther, Data, Pyc, Archive,
// Binary, Image). Phase 2 then runs the SKILL.md-level analyzers
// (IndirectionAnalyzer, NLDirectiveAnalyzer), which must see the full set of
// sibling findings already collected so they can decide whether a structural
// "delegates-to-X" signal is corroborated by a behavioral finding on the
// target. AnalyzeFile handles the per-File analyzer selection via Handles().
func Analyze(b *Bundle) BundleResult {
	var findings []Finding
	if b == nil {
		return BundleResult{Signals: AggregateSignals(nil, nil)}
	}

	// Phase 1: every sibling file through its matching content analyzers.
	for _, f := range b.Files {
		if f == nil {
			continue
		}
		findings = append(findings, AnalyzeFile(f, b)...)
	}

	// Phase 2: SKILL.md-level analyzers (indirection / NL-directive). These
	// Handle KindSkillMd; the parsed SKILL.md is not part of b.Files (which is
	// siblings only), so we synthesize a File entry pointing at it. Running
	// these last means any sibling finding from phase 1 is already on b for
	// the analyzers' corroboration checks.
	if skillFile := skillMdFile(b); skillFile != nil {
		findings = append(findings, AnalyzeFile(skillFile, b)...)
	}

	findings = dedupeFindings(findings)

	return BundleResult{
		Findings: findings,
		Signals:  AggregateSignals(b, findings),
	}
}

// skillMdFile builds a synthetic *File for the bundle's SKILL.md so the
// SKILL.md-level analyzers (which Handle KindSkillMd) receive a File argument
// with the right Kind. Returns nil when the bundle has no SKILL.md.
func skillMdFile(b *Bundle) *File {
	if b == nil || b.SkillMdPath == "" {
		return nil
	}
	rel := b.SkillMdPath
	if b.Skill != nil && b.Skill.FilePath != "" {
		rel = b.Skill.FilePath
	}
	return &File{
		RelPath: filepath.Base(rel),
		AbsPath: b.SkillMdPath,
		Kind:    KindSkillMd,
	}
}

// MakeBundleVerdict combines the unchanged SKILL.md scorer Verdict with the
// cross-file analysis result into the authoritative BundleVerdict. The final
// Label is the stronger of the scorer sub-verdict and bundleEscalates: a
// SevHigh+ corroborated (or critical) sibling finding makes the whole bundle
// malicious regardless of any benign early-return inside the scorer (defeating
// SKILL.md name-spoofing). When the scorer already said malicious, that stands.
func MakeBundleVerdict(b *Bundle, skillVerdict *types.Verdict, res BundleResult) *BundleVerdict {
	bv := &BundleVerdict{
		Verdict: skillVerdict,
		Bundle:  res,
		Files:   fileVerdicts(b, res.Findings),
	}
	if b != nil {
		bv.Dir = b.Dir
		bv.SkillMd = b.SkillMdPath
	}

	scorerSaysMalicious := skillVerdict != nil && skillVerdict.Label == "malicious"
	escalates, reason := bundleEscalates(res)

	// Build the host-agnostic capability profile from the already-extracted
	// findings (pure; NO new detection). The tier PINS REVIEW to the exact
	// (scorerMalicious || escalates) decision used below, so the derived label is
	// byte-identical to today's. The combo taxonomy only ranks the non-escalating
	// remainder into ELEVATED/INFO/CLEAN; it is never a new escalation source.
	escalatedOrMalicious := scorerSaysMalicious || escalates
	prof := BuildCapabilityProfile(res.Findings)
	tier := ComputeTier(escalatedOrMalicious, prof)
	bv.Capabilities = prof.Capabilities
	bv.BigNasties = prof.BigNasties
	bv.RiskTier = string(tier)

	// Label is derived from the tier: malicious iff REVIEW. This equals the prior
	// switch (malicious iff scorerMalicious||escalates) because ComputeTier returns
	// REVIEW iff escalatedOrMalicious is true.
	bv.Label = DeriveLabel(tier)

	// EscalatedBy still names the cross-file evidence whenever the bundle
	// independently escalates, exactly as before (set even when the scorer led).
	if escalates {
		bv.EscalatedBy = reason
	}

	return bv
}

// bundleEscalates is the AUTHORITATIVE, precision-aware escalation decision for
// a bundle. It returns true (with a human-readable reason naming the offending
// signal/sibling) when the cross-file evidence is strong enough to mark the
// whole bundle malicious, independent of the SKILL.md scorer.
//
// Escalation fires when ANY of:
//   - a Finding at SevCritical or above (e.g. exfil host inside a native binary)
//   - a Finding at SevHigh that is behaviorally Corroborated OR not Structural
//     (a real behavior was observed, not merely a shape)
//   - Signals.CorroboratedHighRisk (the aggregated precision gate)
//
// A bare Structural SevHigh finding (ships-opaque-executable, or
// delegates-to-bundled-script with no corroborating finding on the target) does
// NOT escalate on its own. This is the precision lever that prevents the bundle
// scanner from becoming a false-positive cannon on skills that legitimately
// ship a .so or merely mention a filename.
func bundleEscalates(res BundleResult) (escalates bool, reason string) {
	const highWeight = 0.85 // SevHigh; single source of truth via severityToWeight

	// Prefer the strongest, most specific finding as the reported reason.
	// Evaluate criticals first, then corroborated/behavioral highs.
	var critical *Finding
	var corroboratedHigh *Finding
	var behavioralHigh *Finding

	for i := range res.Findings {
		f := &res.Findings[i]
		w := severityToWeight(f.Severity)
		if w >= severityToWeight(SevCritical) {
			if critical == nil {
				critical = f
			}
			continue
		}
		if w >= highWeight {
			if f.Corroborated && corroboratedHigh == nil {
				corroboratedHigh = f
			}
			if !f.Structural && behavioralHigh == nil {
				behavioralHigh = f
			}
		}
	}

	switch {
	case critical != nil:
		return true, findingReason(critical)
	case corroboratedHigh != nil:
		return true, findingReason(corroboratedHigh)
	case behavioralHigh != nil:
		return true, findingReason(behavioralHigh)
	case res.Signals.CorroboratedHighRisk:
		return true, "corroborated-high-risk"
	default:
		return false, ""
	}
}

// findingReason renders a stable, compact escalation reason from a finding,
// naming the originating sibling so operators can locate the payload.
func findingReason(f *Finding) string {
	if f == nil {
		return ""
	}
	if f.File != "" {
		return fmt.Sprintf("%s (%s)", f.Signal, f.File)
	}
	return f.Signal
}

// fileVerdicts groups findings by their originating sibling and emits a
// FileVerdict per file in the bundle. Files with no findings are still listed
// (so reviewers see the full inventory, including unreferenced/hidden ones).
func fileVerdicts(b *Bundle, findings []Finding) []FileVerdict {
	if b == nil {
		return nil
	}

	byFile := make(map[string][]Finding, len(findings))
	for _, f := range findings {
		byFile[f.File] = append(byFile[f.File], f)
	}

	out := make([]FileVerdict, 0, len(b.Files))
	for _, f := range b.Files {
		if f == nil {
			continue
		}
		fv := FileVerdict{
			File:       f.RelPath,
			Kind:       f.Kind.String(),
			Referenced: f.Referenced,
			Hidden:     f.Hidden,
			Findings:   byFile[f.RelPath],
		}
		delete(byFile, f.RelPath)
		out = append(out, fv)
	}

	// Findings whose File did not match any sibling RelPath (e.g. SKILL.md-level
	// findings, or archive-member paths) are surfaced under their own synthetic
	// entries so nothing is silently dropped from output.
	leftover := make([]string, 0, len(byFile))
	for file := range byFile {
		leftover = append(leftover, file)
	}
	sort.Strings(leftover)
	for _, file := range leftover {
		out = append(out, FileVerdict{
			File:     file,
			Kind:     KindUnknown.String(),
			Findings: byFile[file],
		})
	}

	return out
}