| 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 | |
| } | |