diff --git a/malwaredb/verdict.go b/malwaredb/verdict.go new file mode 100644 index 0000000..c321b9f --- /dev/null +++ b/malwaredb/verdict.go @@ -0,0 +1,290 @@ +package malwaredb + +import ( + "fmt" + "strings" +) + +const ( + SourceOpenSSF = "OpenSSF" + SourceBlockList = "MalwareBlockList" + SourceAllowList = "MalwareAllowList" + SourceTrustedModules = "TrustedModules" + SourceApprovedModules = "ApprovedModules" + SourceApprovedGlobal = "ApprovedGlobalModules" + SourceRemote = "remote" + SourceSkip = "skip" + SourceMissingDB = "missing_db" + SourceNone = "" + + ReasonOpenSSF = "recognized as malware" + ReasonBlockList = "organization malware block" + ReasonRemote = "remote malware policy" + ReasonAllowList = "OpenSSF hit allowed" + ReasonBlockListAllow = "organization malware block allowed" + ReasonSkip = "malware checks skipped" + ReasonMissingDB = "malware definitions are missing or invalid" + ReasonClear = "" + + MsgOpenSSF = "recognized as malware by OpenSSF" + MsgBlockList = "blocked by organization malware policy" + MsgAllowList = "OpenSSF block overruled by malware acceptance policy" + MsgTrustOSSF = "OpenSSF block overruled by module trust policy" + MsgTrustLocal = "Malware block overruled by module trust policy" + MsgApprovedOSSF = "OpenSSF block overruled by approved module policy" + MsgApprovedLocal = "Malware block overruled by approved module policy" + MsgApprovedGlobalOSSF = "OpenSSF block overruled by approved global module policy" + MsgApprovedGlobalLocal = "Malware block overruled by approved global module policy" + MsgSkip = "malware checks skipped" + MsgMissingDB = "malware definitions are missing or invalid" + MsgNone = "no malware detected" +) + +// Module is a name plus optional version from status args or an install token. +type Module struct { + Raw string + Name string + Version string +} + +// Row is one local malware verdict. Remote HTTPS is never consulted. +type Row struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Blocked bool `json:"blocked"` + Status string `json:"status"` + Source string `json:"source,omitempty"` + Reason string `json:"-"` + Message string `json:"message"` + RemoteConfigured bool `json:"remote_configured,omitempty"` + Remote string `json:"remote,omitempty"` +} + +// Input is the local data Check needs. Lists must already be HKLM policy values. +type Input struct { + Certified bool + Skip bool + AllowList []string + BlockList []string + RemoteConfigured bool + Trusted bool + Approved bool + ApprovedGlobal bool + TrustConflict string + DB *DB + DBErr error +} + +// ParseModule splits left-pad, left-pad@1.2.3, and @scope/pkg@1.0.0. +func ParseModule(raw string) Module { + raw = strings.TrimSpace(raw) + name, ver, hasVer := splitPattern(raw) + if !hasVer { + return Module{Raw: raw, Name: raw} + } + return Module{Raw: raw, Name: name, Version: ver} +} + +func patternHit(rules []string, name, version string) bool { + for _, raw := range rules { + e := strings.TrimSpace(raw) + if e == "" { + continue + } + if len(e) >= 8 && strings.EqualFold(e[:8], "https://") { + continue + } + if MatchPattern(e, name, version) { + return true + } + } + return false +} + +// Check one module against local inputs. Does not POST the org URL. +func Check(mod Module, in Input) Row { + row := Row{ + Name: mod.Name, + Version: mod.Version, + RemoteConfigured: in.RemoteConfigured, + } + if in.RemoteConfigured { + row.Remote = "configured, not consulted" + } + if in.Skip { + row.Source = SourceSkip + row.Reason = ReasonSkip + return finalize(row) + } + if in.DB == nil || in.DBErr != nil { + row.Blocked = true + row.Source = SourceMissingDB + row.Reason = ReasonMissingDB + return finalize(row) + } + + local := in.Certified && patternHit(in.BlockList, mod.Name, mod.Version) + ossf, err := in.DB.Lookup(mod.Name, mod.Version) + if err != nil { + row.Blocked = true + row.Source = SourceMissingDB + row.Reason = ReasonMissingDB + return finalize(row) + } + allow := ossf && patternHit(in.AllowList, mod.Name, mod.Version) + + hit := HitNone + if local { + hit = HitLocal + } else if ossf { + hit = HitOSSF + } + edition := Community + if in.Certified { + edition = Certified + } + granted := in.Trusted || in.Approved || in.ApprovedGlobal + blocked := Block(VerdictInput{ + Edition: edition, + Hit: hit, + Allow: allow, + Trusted: granted, + TrustConflict: in.TrustConflict, + }) + row.Blocked = blocked + switch { + case local && blocked: + row.Source = SourceBlockList + row.Reason = ReasonBlockList + case ossf && allow && !blocked: + row.Source = SourceAllowList + row.Reason = ReasonAllowList + case !blocked && granted && (local || ossf): + row.Source = grantSource(in) + if local { + row.Reason = ReasonBlockListAllow + } else { + row.Reason = ReasonAllowList + } + case ossf && blocked: + row.Source = SourceOpenSSF + row.Reason = ReasonOpenSSF + } + return finalize(row) +} + +func finalize(row Row) Row { + row.Status = StatusLabel(row.Blocked) + row.Message = StatusMessage(row) + return row +} + +func StatusLabel(blocked bool) string { + if blocked { + return "blocked" + } + return "allowed" +} + +func StatusMessage(row Row) string { + switch row.Source { + case SourceOpenSSF: + return MsgOpenSSF + case SourceBlockList: + return MsgBlockList + case SourceAllowList: + return MsgAllowList + case SourceTrustedModules: + if row.Reason == ReasonBlockListAllow { + return MsgTrustLocal + } + return MsgTrustOSSF + case SourceApprovedModules: + if row.Reason == ReasonBlockListAllow { + return MsgApprovedLocal + } + return MsgApprovedOSSF + case SourceApprovedGlobal: + if row.Reason == ReasonBlockListAllow { + return MsgApprovedGlobalLocal + } + return MsgApprovedGlobalOSSF + case SourceSkip: + return MsgSkip + case SourceMissingDB: + return MsgMissingDB + default: + return MsgNone + } +} + +func grantSource(in Input) string { + if in.Trusted { + return SourceTrustedModules + } + if in.Approved { + return SourceApprovedModules + } + if in.ApprovedGlobal { + return SourceApprovedGlobal + } + return SourceNone +} + +// FormatStatusLine is one TSV status row: module, status, message. +func FormatStatusLine(row Row) string { + spec := row.Name + if row.Version != "" { + spec += "@" + row.Version + } + return fmt.Sprintf("%s\t%s\t%s", spec, StatusLabel(row.Blocked), StatusMessage(row)) +} + +// FormatInstallLine is the shim/install NVM4602 sentence (reason already final). +func FormatInstallLine(spec, source, reason string) string { + body := installBody(source, reason) + return fmt.Sprintf("NVM blocked %s — %s. (NVM4602)", spec, body) +} + +func installBody(source, reason string) string { + switch source { + case SourceOpenSSF: + return ReasonOpenSSF + " (OpenSSF)" + case SourceBlockList: + return ReasonBlockList + case SourceRemote: + if strings.TrimSpace(reason) == "" { + return ReasonRemote + } + return ReasonRemote + ": " + reason + default: + if strings.TrimSpace(reason) == "" { + return ReasonOpenSSF + " (OpenSSF)" + } + return reason + } +} + +// ToastTitle picks the notify-block title from sources in a batch. +func ToastTitle(sources []string) string { + ossf, local, remote, other := false, false, false, false + for _, s := range sources { + switch s { + case SourceOpenSSF: + ossf = true + case SourceBlockList: + local = true + case SourceRemote: + remote = true + default: + other = true + } + } + if ossf && !local && !remote && !other { + return "Malicious package blocked" + } + if local && !ossf && !remote && !other { + return "Organization malware block" + } + return "Package blocked" +} diff --git a/malwaredb/verdict_test.go b/malwaredb/verdict_test.go new file mode 100644 index 0000000..5cf17c7 --- /dev/null +++ b/malwaredb/verdict_test.go @@ -0,0 +1,259 @@ +package malwaredb + +import "testing" + +func ossfDB(t *testing.T, name string) *DB { + t.Helper() + var state [32]byte + raw, err := Encode([]Entry{{Name: name, Kind: KindAll}}, 1, 1, state) + if err != nil { + t.Fatal(err) + } + db, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + return db +} + +func TestCheckSkip(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{Skip: true, DB: ossfDB(t, "left-pad")}) + if row.Blocked || row.Source != SourceSkip || row.Reason != ReasonSkip { + t.Fatalf("%+v", row) + } +} + +func TestCheckMissingDB(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{DBErr: ErrFormat}) + if !row.Blocked || row.Source != SourceMissingDB { + t.Fatalf("%+v", row) + } +} + +func TestCheckOpenSSF(t *testing.T) { + row := Check(ParseModule("left-pad@1.0.0"), Input{DB: ossfDB(t, "left-pad")}) + if !row.Blocked || row.Source != SourceOpenSSF || row.Reason != ReasonOpenSSF { + t.Fatalf("%+v", row) + } +} + +func TestCheckAllowListClearsOSSF(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + DB: ossfDB(t, "left-pad"), + AllowList: []string{"left-pad"}, + }) + if row.Blocked || row.Source != SourceAllowList { + t.Fatalf("%+v", row) + } +} + +func TestCheckCommunityIgnoresBlockList(t *testing.T) { + row := Check(ParseModule("evil"), Input{ + DB: ossfDB(t, "left-pad"), + BlockList: []string{"evil"}, + }) + if row.Blocked || row.Source != "" { + t.Fatalf("community local list must not block: %+v", row) + } +} + +func TestCheckCertifiedBlockList(t *testing.T) { + row := Check(ParseModule("evil"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + BlockList: []string{"evil"}, + }) + if !row.Blocked || row.Source != SourceBlockList || row.Reason != ReasonBlockList { + t.Fatalf("%+v", row) + } +} + +func TestCheckTrustContinueMatchesShim(t *testing.T) { + in := Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + Trusted: true, + TrustConflict: "continue", + } + row := Check(ParseModule("left-pad"), in) + if row.Blocked || row.Source != SourceTrustedModules || row.Reason != ReasonAllowList { + t.Fatalf("continue must name TrustedModules: %+v", row) + } + in.TrustConflict = "prevent" + row = Check(ParseModule("left-pad"), in) + if !row.Blocked || row.Source != SourceOpenSSF { + t.Fatalf("prevent must keep ossf: %+v", row) + } +} + +func TestCheckTrustContinueNamesApprovedGrant(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + Approved: true, + TrustConflict: "continue", + }) + if row.Blocked || row.Source != SourceApprovedModules || row.Reason != ReasonAllowList { + t.Fatalf("%+v", row) + } + row = Check(ParseModule("left-pad"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + ApprovedGlobal: true, + TrustConflict: "continue", + }) + if row.Blocked || row.Source != SourceApprovedGlobal || row.Reason != ReasonAllowList { + t.Fatalf("%+v", row) + } +} + +func TestCheckTrustContinuePrefersTrustedOverApproved(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + Trusted: true, + Approved: true, + TrustConflict: "continue", + }) + if row.Source != SourceTrustedModules { + t.Fatalf("%+v", row) + } +} + +func TestCheckAllowListWinsOverTrustContinue(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + AllowList: []string{"left-pad"}, + Trusted: true, + TrustConflict: "continue", + }) + if row.Blocked || row.Source != SourceAllowList { + t.Fatalf("%+v", row) + } +} + +func TestCheckLocalTrustContinueNamesGrant(t *testing.T) { + row := Check(ParseModule("evil"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + BlockList: []string{"evil"}, + Trusted: true, + TrustConflict: "continue", + }) + if row.Blocked || row.Source != SourceTrustedModules || row.Reason != ReasonBlockListAllow { + t.Fatalf("%+v", row) + } +} + +func TestCheckDoesNotConsultRemote(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + Certified: true, + RemoteConfigured: true, + DB: ossfDB(t, "left-pad"), + }) + if row.Remote != "configured, not consulted" { + t.Fatalf("%+v", row) + } + if row.Source == SourceRemote { + t.Fatal("status must not claim a remote verdict") + } +} + +func TestCheckApprovedCountsAsTrust(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + Approved: true, + TrustConflict: "continue", + }) + if row.Blocked || row.Source != SourceApprovedModules { + t.Fatalf("%+v", row) + } +} + +func TestFormatStatusLine(t *testing.T) { + got := FormatStatusLine(Row{Name: "left-pad", Version: "1.0.0", Blocked: true, Source: SourceOpenSSF, Reason: ReasonOpenSSF}) + want := "left-pad@1.0.0\tblocked\t" + MsgOpenSSF + if got != want { + t.Fatalf("got %q want %q", got, want) + } + got = FormatStatusLine(Row{Name: "eslint", Source: SourceAllowList, Reason: ReasonAllowList}) + want = "eslint\tallowed\t" + MsgAllowList + if got != want { + t.Fatalf("got %q want %q", got, want) + } + got = FormatStatusLine(Row{Name: "chalk"}) + want = "chalk\tallowed\t" + MsgNone + if got != want { + t.Fatalf("got %q want %q", got, want) + } +} + +func TestStatusMessageTrustAndApproved(t *testing.T) { + if got := StatusMessage(Row{Source: SourceTrustedModules, Reason: ReasonAllowList}); got != MsgTrustOSSF { + t.Fatalf("%q", got) + } + if got := StatusMessage(Row{Source: SourceTrustedModules, Reason: ReasonBlockListAllow}); got != MsgTrustLocal { + t.Fatalf("%q", got) + } + if got := StatusMessage(Row{Source: SourceApprovedModules, Reason: ReasonAllowList}); got != MsgApprovedOSSF { + t.Fatalf("%q", got) + } + if got := StatusMessage(Row{Source: SourceApprovedGlobal, Reason: ReasonAllowList}); got != MsgApprovedGlobalOSSF { + t.Fatalf("%q", got) + } +} + +func TestCheckFinalizesHumanFields(t *testing.T) { + row := Check(ParseModule("left-pad"), Input{ + Certified: true, + DB: ossfDB(t, "left-pad"), + Trusted: true, + TrustConflict: "continue", + }) + if row.Status != "allowed" || row.Message != MsgTrustOSSF { + t.Fatalf("%+v", row) + } +} + +func TestFormatInstallLineReasonFallback(t *testing.T) { + if got := FormatInstallLine("foo", SourceOpenSSF, ""); got != "NVM blocked foo — recognized as malware (OpenSSF). (NVM4602)" { + t.Fatalf("ossf generic: %q", got) + } + if got := FormatInstallLine("foo", SourceBlockList, ""); got != "NVM blocked foo — organization malware block. (NVM4602)" { + t.Fatalf("local generic: %q", got) + } + if got := FormatInstallLine("foo", SourceRemote, ""); got != "NVM blocked foo — remote malware policy. (NVM4602)" { + t.Fatalf("remote generic: %q", got) + } + if got := FormatInstallLine("foo", SourceRemote, "cve"); got != "NVM blocked foo — remote malware policy: cve. (NVM4602)" { + t.Fatalf("remote reason: %q", got) + } +} + +func TestToastTitle(t *testing.T) { + if got := ToastTitle([]string{SourceOpenSSF, SourceOpenSSF}); got != "Malicious package blocked" { + t.Fatalf("%q", got) + } + if got := ToastTitle([]string{SourceBlockList}); got != "Organization malware block" { + t.Fatalf("%q", got) + } + if got := ToastTitle([]string{SourceOpenSSF, SourceBlockList}); got != "Package blocked" { + t.Fatalf("%q", got) + } + if got := ToastTitle([]string{SourceRemote}); got != "Package blocked" { + t.Fatalf("%q", got) + } +} + +func TestParseModule(t *testing.T) { + m := ParseModule("left-pad@1.2.3") + if m.Name != "left-pad" || m.Version != "1.2.3" { + t.Fatalf("%+v", m) + } + m = ParseModule("@scope/pkg@1.0.0") + if m.Name != "@scope/pkg" || m.Version != "1.0.0" { + t.Fatalf("%+v", m) + } +} diff --git a/modulefirewall/rules.go b/modulefirewall/rules.go index 0391d5e..d29a7ad 100644 --- a/modulefirewall/rules.go +++ b/modulefirewall/rules.go @@ -16,6 +16,43 @@ const ( DefaultApprovedWhenEmpty = "ALL" ) +var bundledMalwareTrustSeeds = []string{"npm", "npx", "yarn", "yarnpkg", "corepack", "pnpm"} + +// ExplicitModuleGrant is true only for an explicit local grant. +// Empty lists, ALL, NOT ALL, HTTPS URLs, and bundled package-manager names do not count. +func ExplicitModuleGrant(pkg PackageSpec, rules []string) bool { + for _, raw := range rules { + e := strings.TrimSpace(raw) + if e == "" { + continue + } + if _, ok := ExtractHTTPSURL([]string{e}); ok { + continue + } + entry, negated, err := normalizeRuleEntry(e) + if err != nil || negated || entry == "" { + continue + } + if strings.EqualFold(entry, "all") { + continue + } + skip := false + for _, name := range bundledMalwareTrustSeeds { + if strings.EqualFold(entry, name) { + skip = true + break + } + } + if skip { + continue + } + if ruleMatches(entry, pkg) { + return true + } + } + return false +} + // NormalizeTrustedModules applies the empty TrustedModules default: // deny every module except bundled package managers, so npm/yarn/corepack // can re-sign after an upgrade. diff --git a/modulefirewall/trust_test.go b/modulefirewall/trust_test.go index bf5f4c0..763d5b5 100644 --- a/modulefirewall/trust_test.go +++ b/modulefirewall/trust_test.go @@ -122,3 +122,22 @@ func TestEvaluateTrustedModules_URLOnlyQueriesRemote(t *testing.T) { t.Fatalf("message=%q", res.Message) } } + +func TestExplicitModuleGrant(t *testing.T) { + pkg := PackageSpec{Name: "eslint", Raw: "eslint"} + if ExplicitModuleGrant(pkg, nil) { + t.Fatal("empty") + } + if ExplicitModuleGrant(pkg, []string{"ALL"}) { + t.Fatal("ALL") + } + if ExplicitModuleGrant(pkg, []string{"NOT ALL", "npm", "npx"}) { + t.Fatal("bundled seeds") + } + if ExplicitModuleGrant(pkg, []string{"https://policy.example/fw"}) { + t.Fatal("url") + } + if !ExplicitModuleGrant(pkg, []string{"NOT ALL", "eslint"}) { + t.Fatal("explicit eslint") + } +}