Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 41 additions & 29 deletions detect/threat.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion detect/threat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion knowledge/_shared/_threats.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
10 changes: 5 additions & 5 deletions report/markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion report/markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Expand Down
10 changes: 5 additions & 5 deletions report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, ", "))
}
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Expand Down