- implement Ubuntu OVAL XML parser to stream AffectedPackage records - derive package, Ubuntu release and version constraints from OVAL metadata - wire Ubuntu parser UpsertDB with batched inserts and detailed logging - switch Alpine/Debian/NVD/Ubuntu affected_packages cleanup to Unscoped hard delete - align log messages to explicitly state hard deletion behavior
347 lines
10 KiB
Go
347 lines
10 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"loupe/common"
|
|
"loupe/internal/fetcher"
|
|
"loupe/internal/logger"
|
|
"loupe/internal/vulndb"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Debian JSON 结构定义,对应 security-tracker.debian.org 的 data/json。
|
|
// 参考示例见 data/debian/debian-security-tracker.json。
|
|
//
|
|
// 顶层:{"pkg-name": {"CVE-xxxx": { ... }}}
|
|
// 其中每个 package 下是若干 CVE 条目。
|
|
|
|
// DebianTrackerJSON 顶层结构:包名 -> (CVE ID -> 条目)
|
|
type DebianTrackerJSON map[string]map[string]DebianCVEEntry
|
|
|
|
// DebianCVEEntry 单条 Debian CVE 条目
|
|
// 这里只建模当前解析流程需要的字段。
|
|
type DebianCVEEntry struct {
|
|
Description string `json:"description"`
|
|
Scope string `json:"scope"`
|
|
Releases map[string]DebianReleaseInfo `json:"releases"`
|
|
}
|
|
|
|
// DebianReleaseInfo 描述某个 Debian 发行版在该 CVE 下的信息。
|
|
// 示例:
|
|
// "bookworm": {
|
|
// "status": "resolved",
|
|
// "repositories": {"bookworm": "2.3.1+dfsg1-1+deb12u1"},
|
|
// "fixed_version": "0",
|
|
// "urgency": "unimportant"
|
|
// }
|
|
|
|
type DebianReleaseInfo struct {
|
|
Status string `json:"status"`
|
|
Repositories map[string]string `json:"repositories"`
|
|
FixedVersion string `json:"fixed_version"`
|
|
Urgency string `json:"urgency"`
|
|
}
|
|
|
|
// DebianParser 负责解析 Debian security-tracker JSON,并写入统一的 VulnDB 结构。
|
|
// 注意:根据设计,Debian 仅负责补充 AffectedPackage,不向 Vulnerability 表写入记录。
|
|
type DebianParser struct {
|
|
f fetcher.Fetcher
|
|
db *gorm.DB
|
|
}
|
|
|
|
func NewDebianParser(f fetcher.Fetcher, db *vulndb.DB) *DebianParser {
|
|
return &DebianParser{f: f, db: db.GetOrm()}
|
|
}
|
|
|
|
// debianStreamItem 表示从 Debian JSON 解析出的流式结果,这里只携带受影响包列表。
|
|
type debianStreamItem struct {
|
|
VulnID string
|
|
APs []vulndb.AffectedPackage
|
|
}
|
|
|
|
// Parse 将本地 Debian JSON 解析为一个流,通过 outCh 持续输出解析结果。
|
|
//
|
|
// 行为:
|
|
// - 由 DebianFetcher.Fetch 提供本地 debian-security-tracker.json 文件;
|
|
// - 仅使用 Metadata["file_path"] 进行文件解析;
|
|
// - 遍历所有 package 与 CVE,按 CVE 维度输出受影响包列表;
|
|
// - 上层 UpsertDB 仅对 affected_packages 做增删,不改动 vulnerabilities 表。
|
|
func (p *DebianParser) Parse(ctx context.Context, outCh chan<- debianStreamItem) error {
|
|
raws, err := p.f.Fetch(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(raws) == 0 {
|
|
fmt.Printf("[Debian] no local Debian security-tracker JSON found, nothing to parse\n")
|
|
logger.Info("[Debian] no local Debian security-tracker JSON found, nothing to parse")
|
|
return nil
|
|
}
|
|
|
|
for _, raw := range raws {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
if err := p.parseOneFile(ctx, raw, outCh); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpsertDB 实现 Parser 接口:解析 Debian JSON,并仅对 affected_packages 做更新。
|
|
// Vulnerability 表由 NVD 等上游源统一维护,Debian 不直接写入漏洞元数据。
|
|
func (p *DebianParser) UpsertDB(ctx context.Context) error {
|
|
items := make(chan debianStreamItem, 1024)
|
|
parseErrCh := make(chan error, 1)
|
|
|
|
// 先删除所有 ecosystem = "debian" 的 AffectedPackage,避免旧数据残留
|
|
if err := p.db.WithContext(ctx).
|
|
Where("ecosystem = ?", common.VulnSourceDebian).
|
|
Unscoped().
|
|
Delete(&vulndb.AffectedPackage{}).
|
|
Error; err != nil {
|
|
return fmt.Errorf("[Debian] delete old affected packages failed: %w", err)
|
|
} else {
|
|
fmt.Println("[Debian] hard delete old affected packages")
|
|
logger.Info("[Debian] hard delete old affected packages")
|
|
}
|
|
|
|
// 异步启动解析协程,通过 items channel 向下游推送数据
|
|
go func() {
|
|
defer close(items)
|
|
if err := p.Parse(ctx, items); err != nil {
|
|
parseErrCh <- err
|
|
return
|
|
}
|
|
parseErrCh <- nil
|
|
}()
|
|
|
|
var countAP int
|
|
var lastLog time.Time
|
|
|
|
for it := range items {
|
|
if len(it.APs) == 0 {
|
|
continue
|
|
}
|
|
|
|
// 周期性打印数据库写入进度,避免长时间无输出
|
|
if time.Since(lastLog) > 10*time.Second {
|
|
logger.Debug("[Debian] UpsertDB progress: %d affected packages written so far\n", countAP)
|
|
lastLog = time.Now()
|
|
}
|
|
|
|
// logger.Debug("[Debian] db receive debian data vulnID=%s, aps=%d\n", it.VulnID, len(it.APs))
|
|
|
|
if err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
// 仅写入 AffectedPackage,不触碰 Vulnerability 表
|
|
const batchSize = 200
|
|
for start := 0; start < len(it.APs); start += batchSize {
|
|
end := start + batchSize
|
|
if end > len(it.APs) {
|
|
end = len(it.APs)
|
|
}
|
|
batch := it.APs[start:end]
|
|
if err := tx.Create(&batch).Error; err != nil {
|
|
return fmt.Errorf("[Debian] insert affected packages failed: %w", err)
|
|
}
|
|
countAP += len(batch)
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := <-parseErrCh; err != nil {
|
|
return fmt.Errorf("[Debian] parse failed: %w", err)
|
|
}
|
|
|
|
msg := fmt.Sprintf("[Debian] UpsertDB: streamed %d affected packages (vulnerabilities untouched)\n", countAP)
|
|
fmt.Print(msg)
|
|
logger.Info(msg)
|
|
return nil
|
|
}
|
|
|
|
// parseOneFile 解析单个 Debian JSON 文件。
|
|
func (p *DebianParser) parseOneFile(ctx context.Context, raw fetcher.RawAdvisory, outCh chan<- debianStreamItem) error {
|
|
path, ok := raw.Metadata["file_path"]
|
|
if !ok || path == "" {
|
|
return fmt.Errorf("debian parser: missing file_path metadata")
|
|
}
|
|
|
|
fmt.Printf("[Debian] start parse debian security-tracker JSON: %s\n", path)
|
|
logger.Info("[Debian] start parse debian security-tracker JSON: %s", path)
|
|
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("debian parser: open file %s: %w", path, err)
|
|
}
|
|
defer file.Close()
|
|
|
|
dec := json.NewDecoder(file)
|
|
|
|
var full DebianTrackerJSON
|
|
if err := dec.Decode(&full); err != nil {
|
|
return fmt.Errorf("debian parser: decode json %s: %w", path, err)
|
|
}
|
|
|
|
// 为了有稳定的遍历顺序,先收集包名+排序(调试更友好)。
|
|
pkgNames := make([]string, 0, len(full))
|
|
for pkg := range full {
|
|
pkgNames = append(pkgNames, pkg)
|
|
}
|
|
sort.Strings(pkgNames)
|
|
|
|
var totalVulns int
|
|
lastLog := time.Now()
|
|
|
|
for _, pkg := range pkgNames {
|
|
cveMap := full[pkg]
|
|
// Debian JSON 中的键一般形如 "CVE-2012-0833",也可能有 "TEMP-xxxxxxxx" 等。
|
|
cveIDs := make([]string, 0, len(cveMap))
|
|
for cveID := range cveMap {
|
|
cveIDs = append(cveIDs, cveID)
|
|
}
|
|
sort.Strings(cveIDs)
|
|
|
|
for _, cveID := range cveIDs {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
entry := cveMap[cveID]
|
|
aps := p.buildAPs(pkg, cveID, &entry)
|
|
|
|
// Debian 源可能存在非 CVE 格式的 ID(如 DSA-xxx),这里仅保留以 "CVE-" 开头的记录,
|
|
// 其它记录可后续根据需要再扩展。
|
|
if !strings.HasPrefix(strings.ToUpper(cveID), "CVE-") {
|
|
continue
|
|
}
|
|
|
|
if len(aps) == 0 {
|
|
continue
|
|
}
|
|
|
|
// 计数与进度日志
|
|
totalVulns++
|
|
if totalVulns%1000 == 0 || time.Since(lastLog) > 10*time.Second {
|
|
fmt.Printf("[Debian] parsing debian JSON %s: processed %d vulnerabilities (with affected packages)\n", path, totalVulns)
|
|
logger.Debug("[Debian] parsing debian JSON %s: processed %d vulnerabilities (with affected packages)", path, totalVulns)
|
|
lastLog = time.Now()
|
|
}
|
|
|
|
outCh <- debianStreamItem{VulnID: cveID, APs: aps}
|
|
}
|
|
}
|
|
|
|
fmt.Printf("[Debian] finished parsing debian security-tracker JSON %s: total %d vulnerabilities with affected packages\n", path, totalVulns)
|
|
logger.Info("[Debian] finished parsing debian security-tracker JSON %s: total %d vulnerabilities with affected packages", path, totalVulns)
|
|
|
|
return nil
|
|
}
|
|
|
|
// buildAPs 将单个 DebianCVEEntry 转换为统一的 AffectedPackage 列表。
|
|
// 注意:这里不构造 Vulnerability,只根据 CVE ID 和 Debian 维度的信息生成受影响包数据。
|
|
func (p *DebianParser) buildAPs(pkgName, cveID string, entry *DebianCVEEntry) []vulndb.AffectedPackage {
|
|
// 解析 releases -> AffectedPackage。
|
|
aps := make([]vulndb.AffectedPackage, 0, len(entry.Releases))
|
|
for rel, info := range entry.Releases {
|
|
status := strings.ToLower(strings.TrimSpace(info.Status))
|
|
|
|
// 粗略将 status 映射到一个“是否已修复”语义(当前仅用来决定 FixedVersion 是否有意义):
|
|
// - vulnerable / unresolved / open -> 视为未修复
|
|
// - resolved / fixed -> 视为已修复
|
|
// - ignored / not-affected 等 -> 可以标记为未受影响
|
|
var unaffected bool
|
|
if strings.Contains(status, "not-affected") || status == "ignored" {
|
|
unaffected = true
|
|
}
|
|
|
|
// Debian JSON 中,repositories 一般包含该 release 对应的仓库名和版本信息:
|
|
// {
|
|
// "repositories": {
|
|
// "bookworm": {
|
|
// "version": "1.2.4-1"
|
|
// }
|
|
// }
|
|
// }
|
|
// 这里的 version 是“仓库中当前有的版本”,不要与 fixed_version 混淆。
|
|
var firstRepo string
|
|
if len(info.Repositories) > 0 {
|
|
// 为保证稳定性,对 key 排序后取第一个仓库作为代表。
|
|
repoNames := make([]string, 0, len(info.Repositories))
|
|
for name := range info.Repositories {
|
|
repoNames = append(repoNames, name)
|
|
}
|
|
sort.Strings(repoNames)
|
|
firstRepoName := repoNames[0]
|
|
firstRepo = strings.TrimSpace(firstRepoName)
|
|
}
|
|
|
|
osVersion := DebianCodenameToVersion(rel)
|
|
if osVersion == "" {
|
|
// 如果未能识别该代号,则原样保留
|
|
osVersion = rel
|
|
}
|
|
|
|
ap := vulndb.AffectedPackage{
|
|
VulnID: cveID,
|
|
PackageName: pkgName,
|
|
Ecosystem: string(common.VulnSourceDebian),
|
|
OSFamily: common.OSDebian,
|
|
OSVersion: osVersion,
|
|
Repository: firstRepo,
|
|
}
|
|
|
|
// fixed_version 是“修复该漏洞的版本”,与 repositories 中的 version 含义不同。
|
|
fixedVer := strings.TrimSpace(info.FixedVersion)
|
|
affectedAll := false
|
|
switch fixedVer {
|
|
case "", "0":
|
|
// 不提供修复版本 => 所有版本受影响
|
|
affectedAll = true
|
|
default:
|
|
if !unaffected {
|
|
ap.FixedVersion = fixedVer
|
|
}
|
|
}
|
|
|
|
// VersionConstraint 暂时使用一个简单的 "< fixedVer" 或 "*" 表示,后续可以根据
|
|
// Debian 的版本规则扩展为更精确的约束表达式。
|
|
if !unaffected {
|
|
if affectedAll {
|
|
ap.VersionConstraint = "*"
|
|
} else if ap.FixedVersion != "" {
|
|
ap.VersionConstraint = "<" + ap.FixedVersion
|
|
}
|
|
}
|
|
|
|
aps = append(aps, ap)
|
|
}
|
|
|
|
return aps
|
|
}
|
|
|
|
func DebianCodenameToVersion(code string) string {
|
|
code = strings.ToLower(strings.TrimSpace(code))
|
|
if v, ok := common.DebianReleaseMap[code]; ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|