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