* Copyright (c) 2026 Huawei Technologies Co., Ltd.
* openFuyao is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
* http://license.coscl.org.cn/MulanPSL2
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
package report
import (
"encoding/json"
"fmt"
"html/template"
"io"
"strings"
"sync"
"time"
ctrl "sigs.k8s.io/controller-runtime"
compliancev1alpha1 "gitcode.com/openFuyao/compliance-operator/api/v1alpha1"
"gitcode.com/openFuyao/compliance-operator/pkg/unified"
)
var generatorLog = ctrl.Log.WithName("report-generator")
var (
reportTemplate *template.Template
reportTemplateOnce sync.Once
knowledgeBase *KnowledgeBase
knowledgeBaseOnce sync.Once
)
func getReportTemplate() *template.Template {
reportTemplateOnce.Do(func() {
funcMap := template.FuncMap{
"mulf": func(a, b float64) float64 { return a * b },
"divf": func(a, b float64) float64 {
if b == 0 {
return 0
}
return a / b
},
"toFloat": func(i int) float64 { return float64(i) },
"toLower": func(s string) string { return strings.ToLower(s) },
"add": func(a, b int) int { return a + b },
"filterByStatus": func(results []unified.RenderedCheckResult, status string) []unified.RenderedCheckResult {
var filtered []unified.RenderedCheckResult
for _, r := range results {
if r.Status == status {
filtered = append(filtered, r)
}
}
return filtered
},
"filterOtherStatuses": func(results []unified.RenderedCheckResult) []unified.RenderedCheckResult {
var filtered []unified.RenderedCheckResult
for _, r := range results {
if r.Status != unified.StatusFail && r.Status != unified.StatusWarn && r.Status != unified.StatusError {
filtered = append(filtered, r)
}
}
return filtered
},
"nodeAnchor": func(nodeName string) string {
return "node-" + strings.ToLower(strings.ReplaceAll(nodeName, " ", "-"))
},
}
tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate)
if err != nil {
panic(fmt.Sprintf("failed to parse report template: %v", err))
}
reportTemplate = tmpl
})
return reportTemplate
}
func getKnowledgeBase() *KnowledgeBase {
knowledgeBaseOnce.Do(func() {
knowledgeBase = NewKnowledgeBase()
})
return knowledgeBase
}
type ReportData struct {
ScanName string
ScanID string
Scanner string
Benchmark string
Profile string
StartTime time.Time
EndTime time.Time
Summary compliancev1alpha1.ScanSummary
ComplianceRate float64
NodeSummaries []compliancev1alpha1.NodeSummary
NodeResults []compliancev1alpha1.ComplianceScanNodeResult
NodeName string
NodeType string
}
type Generator struct {
knowledgeBase *KnowledgeBase
}
func NewGenerator() *Generator {
return &Generator{
knowledgeBase: getKnowledgeBase(),
}
}
func (g *Generator) GenerateHTML(w io.Writer, data *ReportData) error {
if data == nil {
return fmt.Errorf("report data is nil")
}
generatorLog.V(1).Info("generating HTML report", "scanName", data.ScanName)
tmpl := getReportTemplate()
enrichedData := g.enrichReportData(data)
if err := tmpl.Execute(w, enrichedData); err != nil {
generatorLog.Error(err, "failed to execute HTML template")
return fmt.Errorf("failed to execute template: %w", err)
}
return nil
}
func (g *Generator) GenerateJSON(w io.Writer, data *ReportData) error {
if data == nil {
return fmt.Errorf("report data is nil")
}
generatorLog.V(1).Info("generating JSON report", "scanName", data.ScanName)
enrichedData := g.enrichReportData(data)
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(enrichedData); err != nil {
generatorLog.Error(err, "failed to encode JSON report")
return fmt.Errorf("failed to encode JSON: %w", err)
}
return nil
}
func (g *Generator) enrichReportData(data *ReportData) *EnrichedReportData {
enriched := &EnrichedReportData{
ReportData: *data,
}
for _, nodeResult := range data.NodeResults {
enrichedNode := buildEnrichedNode(nodeResult, g.knowledgeBase, data.Scanner, data.Benchmark)
enriched.EnrichedNodeResults = append(enriched.EnrichedNodeResults, enrichedNode)
}
return enriched
}
func buildEnrichedNode(nodeResult compliancev1alpha1.ComplianceScanNodeResult, kb *KnowledgeBase, scanner, version string) EnrichedNodeResult {
enrichedNode := EnrichedNodeResult{
NodeName: nodeResult.Spec.NodeName,
NodeType: nodeResult.Spec.NodeType,
Summary: nodeResult.Spec.Summary,
PassCount: nodeResult.Spec.PassCount,
}
for _, check := range nodeResult.Spec.NonPassResults {
rendered := enrichCheckResult(check, kb, scanner, version)
enrichedNode.RenderedResults = append(enrichedNode.RenderedResults, rendered)
}
return enrichedNode
}
func enrichCheckResult(check compliancev1alpha1.UnifiedCheckResult, kb *KnowledgeBase, scanner, version string) unified.RenderedCheckResult {
rule, err := kb.Lookup(scanner, version, check.ID)
if err != nil {
generatorLog.V(1).Info("rule definition not available", "scanner", scanner, "ruleID", check.ID)
return unified.RenderedCheckResult{
UnifiedCheckResult: check,
RuleDefinition: unified.RuleDefinition{
ID: check.ID,
Description: "Rule definition not available",
Severity: "unknown",
},
}
}
return unified.RenderedCheckResult{
UnifiedCheckResult: check,
RuleDefinition: *rule,
}
}
type EnrichedReportData struct {
ReportData
EnrichedNodeResults []EnrichedNodeResult
}
type EnrichedNodeResult struct {
NodeName string
NodeType string
Summary compliancev1alpha1.ScanSummary
PassCount int
RenderedResults []unified.RenderedCheckResult
}
func BuildReportData(scan *compliancev1alpha1.ComplianceScan, result *compliancev1alpha1.ComplianceScanResult, nodeResults []compliancev1alpha1.ComplianceScanNodeResult, nodeName string) *ReportData {
if scan == nil || result == nil {
return &ReportData{}
}
generatorLog.V(1).Info("building report data", "scanName", scan.Name, "nodeCount", len(nodeResults))
data := &ReportData{
ScanName: scan.Name,
ScanID: result.Spec.ScanID,
Scanner: string(result.Spec.Scanner),
Benchmark: result.Spec.Benchmark,
Profile: result.Spec.Profile,
StartTime: result.Spec.ScanStartTime.Time,
EndTime: result.Spec.ScanEndTime.Time,
Summary: result.Spec.Summary,
NodeSummaries: result.Spec.NodeSummaries,
NodeResults: nodeResults,
}
switch data.Scanner {
case "kube-bench":
data.Profile = ""
case "openscap":
data.Benchmark = ""
default:
generatorLog.Info("unknown scanner type, keeping all fields", "scanner", data.Scanner)
}
if nodeName != "" {
filterReportDataByNode(data, nodeName, result, nodeResults)
}
scoredTotal := data.Summary.Pass + data.Summary.Fail
if scoredTotal > 0 {
data.ComplianceRate = float64(data.Summary.Pass) / float64(scoredTotal) * 100.0
}
return data
}
func filterReportDataByNode(data *ReportData, nodeName string, result *compliancev1alpha1.ComplianceScanResult, nodeResults []compliancev1alpha1.ComplianceScanNodeResult) {
data.NodeName = nodeName
data.NodeType, data.Summary = findNodeSummary(result.Spec.NodeSummaries, nodeName)
data.NodeResults = filterNodeResults(nodeResults, nodeName)
if len(data.NodeResults) == 0 {
generatorLog.Info("no matching node result found for node filter", "nodeName", nodeName)
}
}
func findNodeSummary(summaries []compliancev1alpha1.NodeSummary, nodeName string) (string, compliancev1alpha1.ScanSummary) {
for _, ns := range summaries {
if ns.NodeName == nodeName {
return ns.NodeType, ns.Summary
}
}
return "", compliancev1alpha1.ScanSummary{}
}
func filterNodeResults(nodeResults []compliancev1alpha1.ComplianceScanNodeResult, nodeName string) []compliancev1alpha1.ComplianceScanNodeResult {
for _, nr := range nodeResults {
if nr.Spec.NodeName == nodeName {
return []compliancev1alpha1.ComplianceScanNodeResult{nr}
}
}
return nil
}