ONNX
security
malware-detection
Vigil / source /pkg /bundle /aggregate.go
turentomer's picture
Make published source self-contained
a2a3348 verified
Raw
History Blame Contribute Delete
9.73 kB
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
}