From 2f3670789467ce4fc9fb4604133aef4c1bc39fa6 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 11 Apr 2026 19:58:52 +0100 Subject: [PATCH] Tighten threat-model output: filter stack, fix empty message, remove SSRF from backend mapping Stack now only includes tools that actually fire a threat mapping or carry sinks, trimming infrastructure tools (Bundler, Docker, CI, linters) that have taxonomy but no security relevance. ecosystem-dashboard goes from 16 stack entries to 7. Empty threat-model output now shows the detected ecosystems and stack before saying "No threat categories match the detected stack" instead of the misleading "No security data available for detected tools" which implied brief had no data about the stack. Removed SSRF from the role:framework + layer:backend mapping since frameworks handle inbound requests, not outbound. SSRF is correctly attributed via function:http-client on the actual HTTP client libraries (requests, axios, faraday, etc). Extracted resolveThreats helper to keep ThreatModel under the gocognit limit after adding the two-pass stack filtering. Closes #41, closes #42, closes #45 --- detect/threat.go | 70 +++++++++++++++++++-------------- detect/threat_test.go | 2 +- knowledge/_shared/_threats.toml | 2 +- report/markdown.go | 10 ++--- report/markdown_test.go | 2 +- report/report.go | 10 ++--- report/report_test.go | 2 +- 7 files changed, 55 insertions(+), 43 deletions(-) diff --git a/detect/threat.go b/detect/threat.go index b50b885..b46e5aa 100644 --- a/detect/threat.go +++ b/detect/threat.go @@ -36,41 +36,16 @@ func (e *Engine) ThreatModel(r *brief.Report) *brief.ThreatReport { } } - for _, d := range allDetections(r) { - tool := e.KB.ByName[d.Name] - if tool == nil { - continue - } + contributes := e.resolveThreats(allDetections(r), addThreat) - // Build the tool's tag set for conjunctive matching. - tags := make(map[string]bool) - for _, t := range tool.Taxonomy.Tags() { - tags[t] = true - } - - // Skip stack entry if the tool has no taxonomy and no security data: - // it contributes nothing and would just be noise. - hasSecurityData := len(tool.Security.Threats) > 0 || len(tool.Security.Sinks) > 0 - if len(tags) > 0 || hasSecurityData { + // Build stack from tools that actually contribute threats or sinks. + for _, d := range allDetections(r) { + if contributes[d.Name] { tr.Stack = append(tr.Stack, brief.StackEntry{ Name: d.Name, Taxonomy: d.Taxonomy, }) } - - // Check each mapping for a conjunctive match against this tool's tags. - for _, m := range e.KB.ThreatMappings { - if matchesAll(tags, m.Match) { - for _, id := range m.Threats { - addThreat(id, d.Name, m.Note) - } - } - } - - // Explicit threats on the tool definition. - for _, id := range tool.Security.Threats { - addThreat(id, d.Name, "") - } } // Resolve threat IDs against the registry and sort. @@ -154,6 +129,43 @@ func allDetections(r *brief.Report) []brief.Detection { return all } +// resolveThreats iterates detections, matches taxonomy tags against threat +// mappings, and calls addThreat for each hit. Returns a set of tool names +// that contribute threats or sinks (for stack filtering). +func (e *Engine) resolveThreats(detections []brief.Detection, addThreat func(id, tool, note string)) map[string]bool { + contributes := make(map[string]bool) + for _, d := range detections { + tool := e.KB.ByName[d.Name] + if tool == nil { + continue + } + + tags := make(map[string]bool) + for _, t := range tool.Taxonomy.Tags() { + tags[t] = true + } + + for _, m := range e.KB.ThreatMappings { + if matchesAll(tags, m.Match) { + contributes[d.Name] = true + for _, id := range m.Threats { + addThreat(id, d.Name, m.Note) + } + } + } + + for _, id := range tool.Security.Threats { + contributes[d.Name] = true + addThreat(id, d.Name, "") + } + + if len(tool.Security.Sinks) > 0 { + contributes[d.Name] = true + } + } + return contributes +} + // matchesAll reports whether the tag set contains every required tag. // An empty required slice matches nothing (vacuous mappings shouldn't fire). func matchesAll(have map[string]bool, required []string) bool { diff --git a/detect/threat_test.go b/detect/threat_test.go index 4771333..9a71b35 100644 --- a/detect/threat_test.go +++ b/detect/threat_test.go @@ -42,7 +42,7 @@ func TestThreatModelRubyProject(t *testing.T) { threatIDs[th.ID] = true } - wantThreats := []string{"xss", "csrf", "ssti", "auth_bypass", "ssrf"} + wantThreats := []string{"xss", "csrf", "ssti", "auth_bypass"} for _, w := range wantThreats { if !threatIDs[w] { t.Errorf("expected threat %q, got %v", w, tr.Threats) diff --git a/knowledge/_shared/_threats.toml b/knowledge/_shared/_threats.toml index 81226ce..ae98bf3 100644 --- a/knowledge/_shared/_threats.toml +++ b/knowledge/_shared/_threats.toml @@ -175,7 +175,7 @@ note = "Spawns OS processes from string arguments" [[mappings]] match = ["role:framework", "layer:backend"] -threats = ["xss", "csrf", "open_redirect", "ssrf", "path_traversal", "auth_bypass"] +threats = ["xss", "csrf", "open_redirect", "path_traversal", "auth_bypass"] note = "Server-side framework handling user-controlled HTTP input" [[mappings]] diff --git a/report/markdown.go b/report/markdown.go index 98ff41a..d24a1d1 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -354,15 +354,15 @@ func MissingMarkdown(w io.Writer, r *brief.MissingReport) { // ThreatMarkdown writes the threat report in markdown format. func ThreatMarkdown(w io.Writer, r *brief.ThreatReport) { - if len(r.Threats) == 0 { - _, _ = fmt.Fprintln(w, "No security data available for detected tools.") - return - } - if len(r.Ecosystems) > 0 { _, _ = fmt.Fprintf(w, "**Detected:** %s\n\n", strings.Join(r.Ecosystems, ", ")) } + if len(r.Threats) == 0 { + _, _ = fmt.Fprintln(w, "No threat categories match the detected stack.") + return + } + _, _ = fmt.Fprintln(w, "| Threat | CWE | OWASP | Introduced by |") _, _ = fmt.Fprintln(w, "|--------|-----|-------|---------------|") for _, t := range r.Threats { diff --git a/report/markdown_test.go b/report/markdown_test.go index b564f7f..c94a29a 100644 --- a/report/markdown_test.go +++ b/report/markdown_test.go @@ -296,7 +296,7 @@ func TestThreatMarkdown(t *testing.T) { func TestThreatMarkdownEmpty(t *testing.T) { var buf bytes.Buffer ThreatMarkdown(&buf, &brief.ThreatReport{}) - if !strings.Contains(buf.String(), "No security data available") { + if !strings.Contains(buf.String(), "No threat categories match") { t.Errorf("expected empty message\ngot:\n%s", buf.String()) } } diff --git a/report/report.go b/report/report.go index 4e2e4ba..1c62c26 100644 --- a/report/report.go +++ b/report/report.go @@ -361,11 +361,6 @@ func ThreatJSON(w io.Writer, r *brief.ThreatReport) error { // ThreatHuman writes the threat report in human-readable format. func ThreatHuman(w io.Writer, r *brief.ThreatReport) { - if len(r.Threats) == 0 { - _, _ = fmt.Fprintln(w, "No security data available for detected tools.") - return - } - if len(r.Ecosystems) > 0 { _, _ = fmt.Fprintf(w, "Detected: %s\n", strings.Join(r.Ecosystems, ", ")) } @@ -376,6 +371,11 @@ func ThreatHuman(w io.Writer, r *brief.ThreatReport) { } _, _ = fmt.Fprintf(w, "Stack: %s\n", strings.Join(names, ", ")) } + + if len(r.Threats) == 0 { + _, _ = fmt.Fprintln(w, "\nNo threat categories match the detected stack.") + return + } _, _ = fmt.Fprintln(w) for _, t := range r.Threats { diff --git a/report/report_test.go b/report/report_test.go index 5b9189f..9eb6012 100644 --- a/report/report_test.go +++ b/report/report_test.go @@ -252,7 +252,7 @@ func TestThreatHuman(t *testing.T) { func TestThreatHumanEmpty(t *testing.T) { var buf bytes.Buffer ThreatHuman(&buf, &brief.ThreatReport{}) - if !strings.Contains(buf.String(), "No security data available") { + if !strings.Contains(buf.String(), "No threat categories match") { t.Errorf("expected empty message, got:\n%s", buf.String()) } }