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
44 changes: 42 additions & 2 deletions scanner/nvd/chart/nvd-scanner/templates/cronjob.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ spec:
key: api_token
- name: HEUREKA_URL
value: {{ .Values.scanner.heureka_url }}
- name: NVDSERVER_URL
- name: NVD_API_URL
value: {{ .Values.scanner.nvd.api_url }}
- name: NVD_API_KEY
valueFrom:
Expand All @@ -36,4 +36,44 @@ spec:
value: {{ .Values.scanner.nvd.start_date }}
- name: NVD_END_DATE
value: {{ .Values.scanner.nvd.end_date }}
restartPolicy: OnFailure
restartPolicy: OnFailure
{{- if .Values.updateScanner.enabled }}
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ .Release.Name }}-update-cronjob
spec:
schedule: "{{ .Values.updateScanner.schedule }}"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
containers:
- name: {{ .Release.Name }}-update
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: HEUREKA_API_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secret
key: api_token
- name: HEUREKA_URL
value: {{ .Values.scanner.heureka_url }}
- name: NVD_API_URL
value: {{ .Values.scanner.nvd.api_url }}
- name: NVD_API_KEY
valueFrom:
secretKeyRef:
name: {{ .Release.Name }}-secret
key: nvd_api_key
- name: NVD_RESULTS_PER_PAGE
value: {{ .Values.scanner.nvd.results_per_page }}
- name: NVD_UPDATE_MODE
value: "true"
- name: NVD_REVIEW_INTERVAL_DAYS
value: "{{ .Values.updateScanner.reviewIntervalDays }}"
restartPolicy: OnFailure
{{- end }}
7 changes: 7 additions & 0 deletions scanner/nvd/chart/nvd-scanner/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ scanner:
start_date: ""
end_date: ""

# Configuration for the update-check job that polls NVD for modified CVEs
# and updates existing Heureka issues when data changes.
updateScanner:
enabled: true
schedule: "0 2 * * *"
reviewIntervalDays: 7

image:
repository: ghcr.io/cloudoperators/heureka-scanner-nvd
pullPolicy: Always
Expand Down
15 changes: 15 additions & 0 deletions scanner/nvd/client/queries/issue_update.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors
# SPDX-License-Identifier: Apache-2.0

mutation UpdateIssue ($id: ID!, $input: IssueInput!) {
# @genqlient(typename: Issue)
updateIssue (
id: $id,
input: $input
) {
id
primaryName
description
type
}
}
31 changes: 21 additions & 10 deletions scanner/nvd/client/queries/issues_query.graphql
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors
# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors
# SPDX-License-Identifier: Apache-2.0

query GetIssues ($filter: IssueFilter) {
# @genqlient(typename: IssueConnection)
Issues (
filter: $filter,
filter: $filter,
) {
totalCount
edges {
# @genqlient(typename: Issue)
node {
id
totalCount
edges {
node {
id
primaryName
description
type
}
}
issueVariants(first: 1) {
edges {
node {
id
secondaryName
severity {
cvss {
vector
}
}
}
}
}
}
}
}
}
14 changes: 14 additions & 0 deletions scanner/nvd/client/queries/issuevariant_update.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Greenhouse contributors
# SPDX-License-Identifier: Apache-2.0

mutation UpdateIssueVariant ($id: ID!, $input: IssueVariantInput!) {
# @genqlient(typename: IssueVariant)
updateIssueVariant (
id: $id,
input: $input
) {
id
secondaryName
issueId
}
}
60 changes: 50 additions & 10 deletions scanner/nvd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,12 @@ func startTimeWindow(scanner *s.Scanner, processor *p.Processor, config s.Config
for endTime.Before(absoluteEnd) {
startYear, startMonth, startDay := startTime.Date()
endYear, endMonth, endDay := endTime.Date()
start := fmt.Sprintf("%d-%02d-%02dT23:59:59.000", startYear, startMonth, startDay)
start := fmt.Sprintf("%d-%02d-%02dT00:00:00.000", startYear, startMonth, startDay)
end := fmt.Sprintf("%d-%02d-%02dT23:59:59.000", endYear, endMonth, endDay)
Comment thread
tsim-sap marked this conversation as resolved.

scanAndProcess(scanner, processor, start, end)

startTime = startTime.AddDate(0, 2, 0)
endTime = endTime.AddDate(0, 2, 0)
startTime = endTime.AddDate(0, 0, 1)
endTime = startTime.AddDate(0, 2, 0)
}
return nil
}
Expand All @@ -75,18 +74,45 @@ func scanAndProcess(scanner *s.Scanner, processor *p.Processor, yesterday string
log.WithFields(log.Fields{
"error": err,
}).Error("Couldn't get CVEs")
return
}

contextWithTimeout, _ := context.WithTimeout(context.Background(), time.Duration(2)*time.Hour)
processCVESConcurrently(contextWithTimeout, processor, cves)
contextWithTimeout, cancel := context.WithTimeout(context.Background(), time.Duration(2)*time.Hour)
defer cancel()
processCVESConcurrently(contextWithTimeout, processor, cves, false)
}

func updateAndProcess(scanner *s.Scanner, processor *p.Processor, modStart string, modEnd string) {
filter := models.CveFilter{
ModStartDate: modStart,
ModEndDate: modEnd,
}

cves, err := scanner.GetCVEs(filter)
if err != nil {
log.WithFields(log.Fields{
"error": err,
}).Error("Couldn't get CVEs for update check")
return
}

log.WithFields(log.Fields{
"count": len(cves),
"modStart": modStart,
"modEnd": modEnd,
}).Info("Starting update check for modified CVEs")

contextWithTimeout, cancel := context.WithTimeout(context.Background(), time.Duration(2)*time.Hour)
defer cancel()
processCVESConcurrently(contextWithTimeout, processor, cves, true)
}

type WorkerResult struct {
Error error
CveId string
}

func processCVESConcurrently(ctx context.Context, processor *p.Processor, cves []models.CveItem) {
func processCVESConcurrently(ctx context.Context, processor *p.Processor, cves []models.CveItem, updateMode bool) {
maxConcurrency := runtime.GOMAXPROCS(0)

// sem is an unbuffered channel meaning that sending onto it will block
Expand Down Expand Up @@ -120,7 +146,12 @@ func processCVESConcurrently(ctx context.Context, processor *p.Processor, cves [
go func(c models.CveItem) {
defer wg.Done()
<-sem // Wait for an available slot
err := processor.Process(&c.Cve)
var err error
if updateMode {
err = processor.ProcessOrUpdate(ctx, &c.Cve)
} else {
err = processor.Process(ctx, &c.Cve)
}
results <- WorkerResult{
Error: err,
CveId: c.Cve.Id,
Expand All @@ -144,7 +175,7 @@ func processCVESConcurrently(ctx context.Context, processor *p.Processor, cves [
} else {
log.WithFields(log.Fields{
"cve": result.CveId,
}).Error("Successfully processed CVE.")
}).Info("Successfully processed CVE.")
}
}
}
Expand Down Expand Up @@ -183,9 +214,18 @@ func main() {
"error": err,
}).Error("Couldn't fetch CVEs for time window")
}
} else if scannerCfg.UpdateMode {
t := time.Now()
yearEnd, monthEnd, dayEnd := t.Date()
modEnd := fmt.Sprintf("%d-%02d-%02dT23:59:59.000", yearEnd, monthEnd, dayEnd)

yearStart, monthStart, dayStart := t.AddDate(0, 0, -scannerCfg.ReviewIntervalDays).Date()
modStart := fmt.Sprintf("%d-%02d-%02dT00:00:00.000", yearStart, monthStart, dayStart)

updateAndProcess(scanner, processor, modStart, modEnd)
} else {
t := time.Now()
yearToday, monthToday, dayToday := time.Now().Date()
yearToday, monthToday, dayToday := t.Date()
today := fmt.Sprintf("%d-%02d-%02dT23:59:59.000", yearToday, monthToday, dayToday)

yearYesterday, monthYesterday, dayYesterday := t.AddDate(0, 0, -2).Date()
Expand Down
118 changes: 115 additions & 3 deletions scanner/nvd/processor/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (p *Processor) Setup() error {
return nil
}

func (p *Processor) Process(cve *models.Cve) error {
func (p *Processor) Process(ctx context.Context, cve *models.Cve) error {
var issueId string

// Create new Issue
Expand All @@ -99,7 +99,7 @@ func (p *Processor) Process(cve *models.Cve) error {
Description: cve.GetDescription("en"),
Type: "Vulnerability",
}
issueMutationResp, err := client.CreateIssue(context.TODO(), p.GraphqlClient, &createIssueInput)
issueMutationResp, err := client.CreateIssue(ctx, p.GraphqlClient, &createIssueInput)
if err != nil {
log.WithFields(log.Fields{
"error": err,
Expand All @@ -125,7 +125,7 @@ func (p *Processor) Process(cve *models.Cve) error {
},
}
variantMutationResp, err := client.CreateIssueVariant(
context.TODO(),
ctx,
p.GraphqlClient,
&issueVariantInput,
)
Expand All @@ -142,3 +142,115 @@ func (p *Processor) Process(cve *models.Cve) error {

return nil
}

// ProcessOrUpdate looks up an existing Issue by CVE ID. If none exists it creates
// both the Issue and IssueVariant (same as Process). If one exists it compares the
// description and CVSS vector and only writes an update when something changed.
func (p *Processor) ProcessOrUpdate(ctx context.Context, cve *models.Cve) error {
resp, err := client.GetIssues(
ctx,
p.GraphqlClient,
&client.IssueFilter{
PrimaryName: []string{cve.Id},
},
)
if err != nil {
return fmt.Errorf("couldn't look up issue for %s: %w", cve.Id, err)
}

if resp.Issues == nil || resp.Issues.TotalCount == 0 || len(resp.Issues.Edges) == 0 {
return p.Process(ctx, cve)
}

issueEdge := resp.Issues.Edges[0]
if issueEdge == nil || issueEdge.Node == nil {
return fmt.Errorf("unexpected nil node for existing issue %s", cve.Id)
}
existing := issueEdge.Node

newDescription := cve.GetDescription("en")
newVector := cve.SeverityVector()

issueChanged := existing.Description != newDescription
if issueChanged {
_, err = client.UpdateIssue(
ctx,
p.GraphqlClient,
existing.Id,
&client.IssueInput{
PrimaryName: existing.PrimaryName,
Description: newDescription,
Type: "Vulnerability",
},
)
if err != nil {
return fmt.Errorf("couldn't update issue %s: %w", cve.Id, err)
}
log.WithFields(log.Fields{"cve": cve.Id}).Info("Updated Issue description")
}

if existing.IssueVariants == nil || len(existing.IssueVariants.Edges) == 0 {
// Variant is missing — create it
_, err = client.CreateIssueVariant(
ctx,
p.GraphqlClient,
&client.IssueVariantInput{
SecondaryName: cve.Id,
Description: newDescription,
ExternalUrl: p.CveDetailsUrl + cve.Id,
IssueRepositoryId: p.IssueRepositoryId,
IssueId: existing.Id,
Severity: &client.SeverityInput{
Vector: newVector,
Rating: "None",
},
},
)
if err != nil {
return fmt.Errorf("couldn't create missing issue variant for %s: %w", cve.Id, err)
}
log.WithFields(log.Fields{"cve": cve.Id}).Info("Created missing IssueVariant")
return nil
}

variantEdge := existing.IssueVariants.Edges[0]
if variantEdge == nil || variantEdge.Node == nil {
return fmt.Errorf("unexpected nil variant node for issue %s", cve.Id)
}
variant := variantEdge.Node

existingVector := ""
if variant.Severity != nil && variant.Severity.Cvss != nil {
existingVector = variant.Severity.Cvss.Vector
}

variantChanged := existingVector != newVector || variant.SecondaryName != cve.Id || issueChanged
if variantChanged {
_, err = client.UpdateIssueVariant(
ctx,
p.GraphqlClient,
variant.Id,
&client.IssueVariantInput{
SecondaryName: cve.Id,
Description: newDescription,
ExternalUrl: p.CveDetailsUrl + cve.Id,
IssueRepositoryId: p.IssueRepositoryId,
IssueId: existing.Id,
Severity: &client.SeverityInput{
Vector: newVector,
Rating: "None",
},
},
)
if err != nil {
return fmt.Errorf("couldn't update issue variant for %s: %w", cve.Id, err)
}
log.WithFields(log.Fields{"cve": cve.Id}).Info("Updated IssueVariant severity")
}

if !issueChanged && !variantChanged {
log.WithFields(log.Fields{"cve": cve.Id}).Debug("No changes detected, skipping update")
}

return nil
}
Loading
Loading