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