173 lines
4.0 KiB
Go
173 lines
4.0 KiB
Go
// ©AngelaMos | 2026
|
|
// export.go
|
|
|
|
package export
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
|
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
|
)
|
|
|
|
const (
|
|
dateLayout = "2006-01-02"
|
|
noDate = "----------"
|
|
scoreRounder = 10000.0
|
|
digestTitle = "# Nadezhda Digest"
|
|
)
|
|
|
|
type CVEEntry struct {
|
|
ID string `json:"id"`
|
|
CVSS *float64 `json:"cvss,omitempty"`
|
|
EPSS *float64 `json:"epss,omitempty"`
|
|
KEV bool `json:"kev"`
|
|
}
|
|
|
|
type Entry struct {
|
|
Rank int `json:"rank"`
|
|
Score float64 `json:"score"`
|
|
Headline string `json:"headline"`
|
|
URL string `json:"url"`
|
|
Outlets []string `json:"outlets"`
|
|
FirstSeen string `json:"first_seen"`
|
|
LastSeen string `json:"last_seen"`
|
|
CVEs []CVEEntry `json:"cves,omitempty"`
|
|
}
|
|
|
|
type Digest struct {
|
|
Count int `json:"count"`
|
|
Entries []Entry `json:"entries"`
|
|
}
|
|
|
|
func Build(scored []rank.Scored, top int) Digest {
|
|
limit := len(scored)
|
|
if top > 0 && top < limit {
|
|
limit = top
|
|
}
|
|
entries := make([]Entry, 0, limit)
|
|
for i := 0; i < limit; i++ {
|
|
s := scored[i]
|
|
h := headline(s.Cluster.Articles)
|
|
entries = append(entries, Entry{
|
|
Rank: i + 1,
|
|
Score: roundScore(s.Score),
|
|
Headline: h.Title,
|
|
URL: h.CanonicalURL,
|
|
Outlets: outlets(s.Cluster.Articles),
|
|
FirstSeen: formatDate(s.Cluster.FirstSeen),
|
|
LastSeen: formatDate(s.Cluster.LastSeen),
|
|
CVEs: cveEntries(s.Cluster.CVEs),
|
|
})
|
|
}
|
|
return Digest{Count: len(entries), Entries: entries}
|
|
}
|
|
|
|
func JSON(scored []rank.Scored, top int) (string, error) {
|
|
b, err := json.MarshalIndent(Build(scored, top), "", " ")
|
|
if err != nil {
|
|
return "", fmt.Errorf("export json: %w", err)
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
func Markdown(scored []rank.Scored, top int) string {
|
|
d := Build(scored, top)
|
|
var b strings.Builder
|
|
b.WriteString(digestTitle + "\n\n")
|
|
for _, e := range d.Entries {
|
|
fmt.Fprintf(&b, "## %d. %s\n", e.Rank, oneLine(e.Headline))
|
|
fmt.Fprintf(&b, "score %.2f | %d outlet(s): %s\n", e.Score, len(e.Outlets), strings.Join(e.Outlets, ", "))
|
|
for _, c := range e.CVEs {
|
|
fmt.Fprintf(&b, "- %s\n", cveLine(c))
|
|
}
|
|
fmt.Fprintf(&b, "%s\n\n", e.URL)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func headline(articles []store.DigestArticle) store.DigestArticle {
|
|
var best store.DigestArticle
|
|
for i, a := range articles {
|
|
if i == 0 || a.PublishedAt > best.PublishedAt ||
|
|
(a.PublishedAt == best.PublishedAt && a.ID < best.ID) {
|
|
best = a
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func outlets(articles []store.DigestArticle) []string {
|
|
seen := make(map[string]struct{}, len(articles))
|
|
var out []string
|
|
for _, a := range articles {
|
|
if _, ok := seen[a.SourceName]; ok {
|
|
continue
|
|
}
|
|
seen[a.SourceName] = struct{}{}
|
|
out = append(out, a.SourceName)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
func cveEntries(cves []store.DigestCVE) []CVEEntry {
|
|
out := make([]CVEEntry, 0, len(cves))
|
|
for _, v := range cves {
|
|
out = append(out, CVEEntry{ID: v.ID, CVSS: v.CVSSScore, EPSS: v.EPSS, KEV: v.IsKEV})
|
|
}
|
|
sort.SliceStable(out, func(a, b int) bool {
|
|
if out[a].KEV != out[b].KEV {
|
|
return out[a].KEV
|
|
}
|
|
if cvssOf(out[a]) != cvssOf(out[b]) {
|
|
return cvssOf(out[a]) > cvssOf(out[b])
|
|
}
|
|
return out[a].ID < out[b].ID
|
|
})
|
|
return out
|
|
}
|
|
|
|
func cvssOf(c CVEEntry) float64 {
|
|
if c.CVSS == nil {
|
|
return -1
|
|
}
|
|
return *c.CVSS
|
|
}
|
|
|
|
func cveLine(c CVEEntry) string {
|
|
var parts []string
|
|
if c.KEV {
|
|
parts = append(parts, "KEV")
|
|
}
|
|
if c.CVSS != nil {
|
|
parts = append(parts, fmt.Sprintf("CVSS %.1f", *c.CVSS))
|
|
}
|
|
if c.EPSS != nil {
|
|
parts = append(parts, fmt.Sprintf("EPSS %.2f", *c.EPSS))
|
|
}
|
|
if len(parts) == 0 {
|
|
return c.ID
|
|
}
|
|
return fmt.Sprintf("%s (%s)", c.ID, strings.Join(parts, ", "))
|
|
}
|
|
|
|
func oneLine(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
func roundScore(v float64) float64 {
|
|
return float64(int64(v*scoreRounder+0.5)) / scoreRounder
|
|
}
|
|
|
|
func formatDate(unix int64) string {
|
|
if unix == 0 {
|
|
return noDate
|
|
}
|
|
return time.Unix(unix, 0).UTC().Format(dateLayout)
|
|
}
|