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 }