Files
loupe/internal/fetcher/parser/debian.go
T
weihao dcd03769da feat(vulndb): add Ubuntu OVAL parser and hard delete affected packages
- 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
2026-04-10 11:08:09 +08:00

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 ""
}