File size: 6,196 Bytes
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 | package bundle
import (
"regexp"
"strings"
)
// NLDirectiveAnalyzer runs against SKILL.md and covers the documented strongest
// evasion class ("NL-exfil > shell-exfil"): malice with NO sibling file at all.
// When AllowedTools grants Bash/network AND the body contains imperative
// install/exfil/registry-rewrite directives — package-manager mirror URLs to a
// non-default host, env-token export to a host, curl|sh, novel "send your
// environment variables to <url>" phrasings — it emits 'allowed-tools-nl-
// directive' (SevHigh+Corroborated), broadening past the few hardcoded scorer
// phrasings. Allowlist-aware: a corporate mirror on the known-benign host list
// downgrades to SevLow.
type NLDirectiveAnalyzer struct{}
func (NLDirectiveAnalyzer) Name() string { return "nl-directive" }
func (NLDirectiveAnalyzer) Handles(kind FileKind) bool { return kind == KindSkillMd }
// nlExfilPhrasings are natural-language exfil/install directives in varied
// wording (not a fixed 3-phrase list). Each is checked case-insensitively.
var nlExfilPhrasings = []string{
"send your environment", "send the environment", "send all environment",
"send environment variables", "post your environment", "upload your environment",
"export your environment", "forward your environment",
"send your credentials", "send the credentials", "post your credentials",
"send your api key", "send the api key", "send your token", "send the token",
"send your secrets", "post your secrets", "upload your secrets",
"transmit the contents of .env", "share the contents of .env",
"read the .env file and send", "read your .env and post",
"set the registry to", "point npm at", "point pip at", "point yarn at",
"configure the registry to", "use this mirror", "use our mirror",
"add this index url", "set the index url to", "install from this url",
"pipe it to bash", "pipe it to sh", "pipe to bash", "pipe to sh",
"copy this command and run", "run the following in your terminal",
}
// nlMirrorURLRe finds a registry/index URL pointed at a non-default host.
var nlMirrorURLRe = regexp.MustCompile(`(?i)(registry|index[- ]?url|--index-url|--extra-index-url|--registry)\s*[:=]?\s*(https?://[^\s'")]+)`)
func (NLDirectiveAnalyzer) Analyze(f *File, b *Bundle) ([]Finding, error) {
if f == nil || b == nil || b.Skill == nil {
return nil, nil
}
if !grantsActiveTools(b.AllowedTools) {
// Without an active tool grant the directive cannot execute on its own;
// the heuristic engine still covers prose. Emit nothing here.
return nil, nil
}
body := b.Skill.Body
lower := strings.ToLower(body)
bodyLines := strings.Split(body, "\n")
var out []Finding
// Exfil host directly in prose with active tools => critical.
if exfilHostRe.MatchString(body) {
out = append(out, Finding{
Analyzer: "nl-directive",
File: f.RelPath,
Signal: "exfil-host-reference",
Severity: SevCritical,
Detail: "SKILL.md prose references known exfiltration host with active tool grant",
Line: firstLineMatching(body, exfilHostRe),
Corroborated: true,
})
}
// NL exfil/install phrasings.
for i, line := range bodyLines {
low := strings.ToLower(line)
if matchedAny(low, nlExfilPhrasings) {
out = append(out, Finding{
Analyzer: "nl-directive",
File: f.RelPath,
Signal: "allowed-tools-nl-directive",
Severity: SevHigh,
Detail: "active tool grant + natural-language install/exfil directive: " + strings.TrimSpace(line),
Line: i + 1,
Corroborated: true,
})
}
}
// Registry/index mirror URL to a non-default host.
for _, m := range nlMirrorURLRe.FindAllStringSubmatch(body, -1) {
if len(m) < 3 {
continue
}
host := extractHost(m[2])
sev := SevHigh
detail := "active tool grant + registry/index rewrite to non-default host: " + host
corroborated := true
if host != "" && isKnownBenignHost(host) {
sev = SevLow
detail = "registry rewrite to known-benign corporate mirror: " + host
corroborated = false
} else if host != "" && isInternalRegistryHost(host) {
sev = SevLow
detail = "registry rewrite to internal/private mirror host: " + host
corroborated = false
}
out = append(out, Finding{
Analyzer: "nl-directive",
File: f.RelPath,
Signal: "allowed-tools-nl-directive",
Severity: sev,
Detail: detail,
Line: firstLineContaining(body, m[0]),
Corroborated: corroborated,
})
}
// curl|sh style RCE directive in prose.
for i, line := range bodyLines {
low := strings.ToLower(line)
if matchedAny(low, rceTerms) {
out = append(out, Finding{
Analyzer: "nl-directive",
File: f.RelPath,
Signal: "allowed-tools-nl-directive",
Severity: SevHigh,
Detail: "active tool grant + download-and-execute directive: " + strings.TrimSpace(line),
Line: i + 1,
Corroborated: true,
})
}
}
// Generic exfil source<->sink co-occurrence in prose (e.g. "read ~/.aws and
// curl it to ..."). Reuse the shared scan but only keep the strong signal.
for _, fnd := range sharedIndicatorScan(lower, f.RelPath, "nl-directive") {
if fnd.Signal == "exfil-env-to-network" {
out = append(out, fnd)
}
}
return dedupeFindings(out), nil
}
// grantsActiveTools reports whether the allowed-tools list grants a tool capable
// of executing the NL directive (shell or network access).
func grantsActiveTools(allowedTools []string) bool {
for _, t := range allowedTools {
switch strings.ToLower(strings.TrimSpace(t)) {
case "bash", "shell", "sh", "exec", "run", "terminal",
"webfetch", "web_fetch", "fetch", "http", "network", "curl", "wget":
return true
}
// Bash(...) / Bash:* style scoped grants.
lt := strings.ToLower(t)
if strings.HasPrefix(lt, "bash") || strings.HasPrefix(lt, "shell") ||
strings.Contains(lt, "webfetch") || strings.Contains(lt, "fetch") {
return true
}
}
return false
}
func firstLineContaining(text, substr string) int {
if substr == "" {
return 0
}
for i, l := range strings.Split(text, "\n") {
if strings.Contains(l, substr) {
return i + 1
}
}
return 0
}
|