From 0bc91d251f14ebd59ca1911af21b70b265b983fb Mon Sep 17 00:00:00 2001 From: richardsonnick Date: Thu, 23 Jul 2026 17:14:39 +0000 Subject: [PATCH] Add unsigned TLS posture attestation emitter with component rollup Emit a stable --attestation-file JSON document that rolls per-port scan results up by OpenShift component (PASS/FAIL/SKIP) for CI to Cosign-sign. Include ClusterVersion subject hints when scanning with --all-pods. Co-authored-by: Cursor --- README.md | 3 +- cmd/tls-scanner/main.go | 36 +++- internal/k8s/clusterversion.go | 30 +++ internal/output/attestation.go | 311 ++++++++++++++++++++++++++++ internal/output/attestation_test.go | 230 ++++++++++++++++++++ internal/output/json.go | 30 +-- internal/output/json_test.go | 10 +- scanner-job.yaml.template | 2 +- 8 files changed, 623 insertions(+), 29 deletions(-) create mode 100644 internal/k8s/clusterversion.go create mode 100644 internal/output/attestation.go create mode 100644 internal/output/attestation_test.go diff --git a/README.md b/README.md index 9973b351..82a7bc99 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ mkdir -p ./artifacts kubectl cp "${NAMESPACE}/${POD_NAME}:/artifacts/." "./artifacts/" ``` -Your `./artifacts` directory will now contain `results.json`, `results.csv`, and `scan.log`. +Your `./artifacts` directory will now contain `results.json`, `results.csv`, `scan.log`, and (when enabled) `tls-posture.intoto.json`. ### Host-based scanning @@ -186,6 +186,7 @@ The scanner binary accepts the following command-line options. These are configu - `-json-file ` - Output results in JSON format to specified file - `-csv-file ` - Output results in CSV format to specified file - `-junit-file ` - Output results in JUnit XML format to specified file +- `-attestation-file ` - Write an unsigned TLS posture attestation JSON (per-component PASS/FAIL rollup) suitable for Cosign signing by CI. Includes optional ClusterVersion when scanning with `--all-pods`. Policy bar is `pqc` with `-pqc-check`, `tls-profile` when cluster TLS adherence is enforced, otherwise `observe`. - `-log-file ` - Redirect all log output to the specified file - `-timing-file ` - Write timing report to specified file in artifact-dir - `-starttls-ports ` - Enable STARTTLS for specific ports (e.g., `postgres=5432:6432,mysql=3306`). Comma separates protocols, colon separates multiple ports within a protocol. Supported protocols: `ftp`, `smtp`, `lmtp`, `pop3`, `imap`, `xmpp`, `xmpp-server`, `telnet`, `ldap`, `nntp`, `sieve`, `postgres`, `mysql`. **Auto-detection:** When process names are available from `/proc` discovery (e.g., `postgres`, `mysqld`), STARTTLS is used automatically without needing this flag. Explicit `--starttls-ports` mappings take priority over auto-detection. diff --git a/cmd/tls-scanner/main.go b/cmd/tls-scanner/main.go index c5c5815c..d48a33af 100644 --- a/cmd/tls-scanner/main.go +++ b/cmd/tls-scanner/main.go @@ -58,6 +58,7 @@ func run(args []string) (exitCode int) { jsonFile := fs.String("json-file", "", "Output results in JSON format to specified file in artifact-dir") csvFile := fs.String("csv-file", "", "Output results in CSV format to specified file in artifact-dir") junitFile := fs.String("junit-file", "", "Output results in JUnit XML format to specified file in artifact-dir") + attestationFile := fs.String("attestation-file", "", "Write unsigned TLS posture attestation JSON (component rollup) to specified file in artifact-dir") concurrentScans := fs.Int("j", 0, "Number of concurrent scans; 0 = runtime.NumCPU()") allPods := fs.Bool("all-pods", false, "Scan all pods in the cluster (overrides --host)") componentFilter := fs.String("component-filter", "", "Filter pods by a comma-separated list of component names (only used with --all-pods)") @@ -176,6 +177,25 @@ func run(args []string) (exitCode int) { var client *k8s.Client var pods []k8s.PodInfo + writeOutputs := func(scanResults scanner.ScanResults) error { + meta := &output.AttestationMeta{ + ScannerVersion: version, + ScannerCommit: commit, + } + if client != nil { + if cv, err := client.GetClusterVersionInfo(); err != nil { + slog.Debug("cluster version unavailable for attestation", "error", err) + } else { + meta.ClusterVersion = cv + } + } + return output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, *attestationFile, isPQCCheck, meta) + } + + noFileOutputs := func() bool { + return *jsonFile == "" && *csvFile == "" && *junitFile == "" && *attestationFile == "" + } + if *targets != "" { targetList := strings.Split(*targets, ",") if len(targetList) == 0 || (len(targetList) == 1 && targetList[0] == "") { @@ -206,13 +226,13 @@ func run(args []string) (exitCode int) { scanResults := scanner.Scan(jobs, *concurrentScans, nil, tlsProfileOverride, policy, timeouts, starttlsPorts) finalScanResults = &scanResults - if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { + if err := writeOutputs(scanResults); err != nil { slog.Error("writing output files", "error", err) return 1 } if isPQCCheck { output.PrintPQCClusterResults(scanResults) - } else if *jsonFile == "" && *csvFile == "" && *junitFile == "" { + } else if noFileOutputs() { output.PrintClusterResults(scanResults) } @@ -229,13 +249,13 @@ func run(args []string) (exitCode int) { scanResults := scanner.Scan(jobs, *concurrentScans, nil, tlsProfileOverride, policy, timeouts, starttlsPorts) finalScanResults = &scanResults - if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { + if err := writeOutputs(scanResults); err != nil { slog.Error("writing output files", "error", err) return 1 } if isPQCCheck { output.PrintPQCClusterResults(scanResults) - } else if *jsonFile == "" && *csvFile == "" && *junitFile == "" { + } else if noFileOutputs() { output.PrintClusterResults(scanResults) } @@ -292,13 +312,13 @@ func run(args []string) (exitCode int) { scanResults := scanner.PerformClusterScan(pods, *concurrentScans, client, policy, timeouts, tlsProfileOverride, starttlsPorts) finalScanResults = &scanResults - if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { + if err := writeOutputs(scanResults); err != nil { slog.Error("writing output files", "error", err) return 1 } if isPQCCheck { output.PrintPQCClusterResults(scanResults) - } else if *jsonFile == "" && *csvFile == "" && *junitFile == "" { + } else if noFileOutputs() { output.PrintClusterResults(scanResults) } @@ -321,13 +341,13 @@ func run(args []string) (exitCode int) { scanResults := scanner.Scan(jobs, *concurrentScans, client, tlsProfileOverride, policy, timeouts, starttlsPorts) finalScanResults = &scanResults - if err := output.WriteOutputFiles(scanResults, *artifactDir, *jsonFile, *csvFile, *junitFile, isPQCCheck); err != nil { + if err := writeOutputs(scanResults); err != nil { slog.Error("writing output files", "error", err) return 1 } if isPQCCheck { output.PrintPQCClusterResults(scanResults) - } else if *jsonFile == "" && *csvFile == "" && *junitFile == "" { + } else if noFileOutputs() { output.PrintParsedResults(scanResults) } diff --git a/internal/k8s/clusterversion.go b/internal/k8s/clusterversion.go new file mode 100644 index 00000000..ccba773e --- /dev/null +++ b/internal/k8s/clusterversion.go @@ -0,0 +1,30 @@ +package k8s + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ClusterVersionInfo is the OpenShift payload identity useful as an attestation subject. +type ClusterVersionInfo struct { + Version string `json:"version"` + Image string `json:"image"` +} + +// GetClusterVersionInfo returns desired version and release image from clusterversion/cluster. +// Returns an error when the OpenShift config API is unavailable (e.g. plain Kubernetes). +func (c *Client) GetClusterVersionInfo() (*ClusterVersionInfo, error) { + if c == nil || c.configClient == nil { + return nil, fmt.Errorf("openshift config client not available") + } + cv, err := c.configClient.ConfigV1().ClusterVersions().Get(context.TODO(), "cluster", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get clusterversion/cluster: %w", err) + } + return &ClusterVersionInfo{ + Version: cv.Status.Desired.Version, + Image: cv.Status.Desired.Image, + }, nil +} diff --git a/internal/output/attestation.go b/internal/output/attestation.go new file mode 100644 index 00000000..3e5dc582 --- /dev/null +++ b/internal/output/attestation.go @@ -0,0 +1,311 @@ +package output + +import ( + "fmt" + "log/slog" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/openshift/tls-scanner/internal/k8s" + "github.com/openshift/tls-scanner/internal/scanner" +) + +const ( + // TLSPosturePredicateType identifies the unsigned TLS posture attestation payload. + // CI (Prow/Konflux) signs this blob; the scanner does not embed signing keys. + TLSPosturePredicateType = "https://github.com/openshift/tls-scanner/tls-posture/v1" + + attestationUnknownComponent = "unknown" + + PolicyBarPQC = "pqc" + PolicyBarTLSProfile = "tls-profile" + PolicyBarObserve = "observe" + + ComponentResultPass = "PASS" + ComponentResultFail = "FAIL" + ComponentResultSkip = "SKIP" + + OverallResultPass = "PASS" + OverallResultFail = "FAIL" +) + +// AttestationMeta carries build/runtime context embedded in the attestation payload. +type AttestationMeta struct { + ScannerVersion string + ScannerCommit string + ClusterVersion *k8s.ClusterVersionInfo +} + +// TLSPostureAttestation is the unsigned attestation document written by --attestation-file. +// It is suitable for Cosign/in-toto signing by CI without further transformation of the +// predicate semantics. +type TLSPostureAttestation struct { + PredicateType string `json:"predicateType"` + Predicate TLSPosturePredicate `json:"predicate"` +} + +// TLSPosturePredicate is the semantic claim: cluster/component TLS posture rollup. +type TLSPosturePredicate struct { + ScannerVersion string `json:"scannerVersion"` + ScannerCommit string `json:"scannerCommit"` + Timestamp string `json:"timestamp"` + PolicyBar string `json:"policyBar"` + Result string `json:"result"` + ClusterVersion *k8s.ClusterVersionInfo `json:"clusterVersion,omitempty"` + Summary TLSPostureSummary `json:"summary"` + Components []ComponentPosture `json:"components"` +} + +// TLSPostureSummary is aggregate counts across components and ports. +type TLSPostureSummary struct { + ComponentsTotal int `json:"componentsTotal"` + ComponentsPassed int `json:"componentsPassed"` + ComponentsFailed int `json:"componentsFailed"` + ComponentsSkipped int `json:"componentsSkipped"` + PortsInScope int `json:"portsInScope"` + PortsSkipped int `json:"portsSkipped"` +} + +// ComponentPosture is the per-component rollup used by attestation consumers. +type ComponentPosture struct { + Name string `json:"name"` + Maintainer string `json:"maintainer,omitempty"` + Result string `json:"result"` + Successes int `json:"successes"` + Failures int `json:"failures"` + Skipped int `json:"skipped"` + Images []string `json:"images,omitempty"` + FailureDetails []PortFailureDetail `json:"failureDetails,omitempty"` +} + +// PortFailureDetail records why an in-scope port failed the active policy bar. +type PortFailureDetail struct { + IP string `json:"ip"` + Port int `json:"port"` + Pod string `json:"pod,omitempty"` + Reasons []string `json:"reasons"` +} + +// BuildTLSPostureAttestation rolls ScanResults up by openshift component. +// +// In-scope ports are those not SkipUnscannable (NO_PORTS, LOCALHOST_ONLY, NO_TLS, PROBE_PORT). +// A component FAILs if any in-scope port fails the active policy bar; PASS if it has at least +// one in-scope success and no failures; SKIP if it only has skipped ports. +func BuildTLSPostureAttestation(results scanner.ScanResults, pqcCheck bool, meta AttestationMeta) TLSPostureAttestation { + policyBar := PolicyBarObserve + if pqcCheck { + policyBar = PolicyBarPQC + } else if scanner.TLSConfigComplianceFailuresEnforced(results) { + policyBar = PolicyBarTLSProfile + } + + type agg struct { + name string + maintainer string + successes int + failures int + skipped int + images map[string]struct{} + details []PortFailureDetail + } + + byComponent := make(map[string]*agg) + portsInScope := 0 + portsSkipped := 0 + + getAgg := func(ipResult scanner.IPResult) *agg { + name := attestationUnknownComponent + maintainer := "" + if ipResult.OpenshiftComponent != nil { + if ipResult.OpenshiftComponent.Component != "" { + name = ipResult.OpenshiftComponent.Component + } + maintainer = ipResult.OpenshiftComponent.MaintainerComponent + } + a, ok := byComponent[name] + if !ok { + a = &agg{name: name, maintainer: maintainer, images: make(map[string]struct{})} + byComponent[name] = a + } + if maintainer != "" && a.maintainer == "" { + a.maintainer = maintainer + } + if ipResult.Pod != nil && ipResult.Pod.Image != "" { + a.images[ipResult.Pod.Image] = struct{}{} + } + return a + } + + for _, ipResult := range results.IPResults { + a := getAgg(ipResult) + podName := "" + if ipResult.Pod != nil { + podName = ipResult.Pod.Name + } + + for _, portResult := range ipResult.PortResults { + if scanner.SkipUnscannable(portResult.Status) { + a.skipped++ + portsSkipped++ + continue + } + + portsInScope++ + reasons := portFailureReasons(portResult, policyBar) + if len(reasons) > 0 { + a.failures++ + a.details = append(a.details, PortFailureDetail{ + IP: ipResult.IP, + Port: portResult.Port, + Pod: podName, + Reasons: reasons, + }) + continue + } + a.successes++ + } + } + + components := make([]ComponentPosture, 0, len(byComponent)) + summary := TLSPostureSummary{PortsInScope: portsInScope, PortsSkipped: portsSkipped} + + for _, a := range byComponent { + cp := ComponentPosture{ + Name: a.name, + Maintainer: a.maintainer, + Successes: a.successes, + Failures: a.failures, + Skipped: a.skipped, + FailureDetails: a.details, + Images: sortedKeys(a.images), + } + switch { + case a.failures > 0: + cp.Result = ComponentResultFail + summary.ComponentsFailed++ + case a.successes > 0: + cp.Result = ComponentResultPass + summary.ComponentsPassed++ + default: + cp.Result = ComponentResultSkip + summary.ComponentsSkipped++ + } + summary.ComponentsTotal++ + components = append(components, cp) + } + + sort.Slice(components, func(i, j int) bool { + return components[i].Name < components[j].Name + }) + + overall := OverallResultPass + if summary.ComponentsFailed > 0 { + overall = OverallResultFail + } + // Observe mode never fails the overall result solely from missing PQC/profile — + // portFailureReasons already returns no reasons for observe on successful scans. + // If there were zero in-scope ports at all, still PASS (nothing to assert). + + ts := results.Timestamp + if ts == "" { + ts = time.Now().UTC().Format(time.RFC3339) + } + + return TLSPostureAttestation{ + PredicateType: TLSPosturePredicateType, + Predicate: TLSPosturePredicate{ + ScannerVersion: meta.ScannerVersion, + ScannerCommit: meta.ScannerCommit, + Timestamp: ts, + PolicyBar: policyBar, + Result: overall, + ClusterVersion: meta.ClusterVersion, + Summary: summary, + Components: components, + }, + } +} + +func portFailureReasons(portResult scanner.PortResult, policyBar string) []string { + var reasons []string + switch policyBar { + case PolicyBarPQC: + if !portResult.TLS13Supported { + reasons = append(reasons, "PQC: TLS 1.3 not supported") + } + if !portResult.MLKEMSupported { + reasons = append(reasons, "PQC: ML-KEM not supported") + } + case PolicyBarTLSProfile: + if portResult.IngressTLSConfigCompliance != nil && !scanner.IsTLSConfigCompliant(portResult.IngressTLSConfigCompliance) { + reasons = append(reasons, "Ingress TLS config is not compliant") + } + if portResult.APIServerTLSConfigCompliance != nil && !scanner.IsTLSConfigCompliant(portResult.APIServerTLSConfigCompliance) { + reasons = append(reasons, "API Server TLS config is not compliant") + } + if portResult.KubeletTLSConfigCompliance != nil && !scanner.IsTLSConfigCompliant(portResult.KubeletTLSConfigCompliance) { + reasons = append(reasons, "Kubelet TLS config is not compliant") + } + case PolicyBarObserve: + // Observational attestation: in-scope ports that scanned OK do not fail. + // Still record hard scan errors on OK-status ports with Error set. + if portResult.Status == scanner.StatusOK && portResult.Error != "" { + reasons = append(reasons, "scan error: "+portResult.Error) + } + } + return reasons +} + +func sortedKeys(m map[string]struct{}) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// WriteAttestationFile builds the TLS posture attestation and writes it as indented JSON. +func WriteAttestationFile(results scanner.ScanResults, filename string, pqcCheck bool, meta AttestationMeta) error { + doc := BuildTLSPostureAttestation(results, pqcCheck, meta) + if err := WriteJSONOutput(doc, filename); err != nil { + return fmt.Errorf("write attestation: %w", err) + } + slog.Info("TLS posture attestation written", + "path", filename, + "policyBar", doc.Predicate.PolicyBar, + "result", doc.Predicate.Result, + "components", doc.Predicate.Summary.ComponentsTotal, + ) + return nil +} + +// resolveOutputPath joins artifactDir when filename is relative. +func resolveOutputPath(artifactDir, filename string) string { + if filepath.IsAbs(filename) { + return filename + } + return filepath.Join(artifactDir, filename) +} + +// FormatAttestationSummary returns a one-line human summary for logs. +func FormatAttestationSummary(doc TLSPostureAttestation) string { + s := doc.Predicate.Summary + return fmt.Sprintf("result=%s policyBar=%s components=%d (pass=%d fail=%d skip=%d) portsInScope=%d", + doc.Predicate.Result, doc.Predicate.PolicyBar, s.ComponentsTotal, + s.ComponentsPassed, s.ComponentsFailed, s.ComponentsSkipped, s.PortsInScope) +} + +// ComponentNames returns sorted component names (test helper / debugging). +func ComponentNames(doc TLSPostureAttestation) string { + names := make([]string, 0, len(doc.Predicate.Components)) + for _, c := range doc.Predicate.Components { + names = append(names, c.Name) + } + return strings.Join(names, ",") +} diff --git a/internal/output/attestation_test.go b/internal/output/attestation_test.go new file mode 100644 index 00000000..4cade445 --- /dev/null +++ b/internal/output/attestation_test.go @@ -0,0 +1,230 @@ +package output + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + configv1 "github.com/openshift/api/config/v1" + + "github.com/openshift/tls-scanner/internal/k8s" + "github.com/openshift/tls-scanner/internal/scanner" +) + +func TestBuildTLSPostureAttestationPQCRollup(t *testing.T) { + t.Parallel() + + results := scanner.ScanResults{ + Timestamp: "2026-07-23T12:00:00Z", + IPResults: []scanner.IPResult{ + { + IP: "10.0.0.1", + OpenshiftComponent: &k8s.OpenshiftComponent{ + Component: "kube-apiserver", + MaintainerComponent: "ocp", + }, + Pod: &k8s.PodInfo{Name: "apiserver", Image: "quay.io/openshift/kube-apiserver@sha256:aaa"}, + PortResults: []scanner.PortResult{ + { + Port: 6443, + Status: scanner.StatusOK, + TLS13Supported: true, + MLKEMSupported: true, + }, + { + Port: 8080, + Status: scanner.StatusLocalhostOnly, + }, + }, + }, + { + IP: "10.0.0.2", + OpenshiftComponent: &k8s.OpenshiftComponent{ + Component: "openshift-ingress", + }, + Pod: &k8s.PodInfo{Name: "router", Image: "quay.io/openshift/router@sha256:bbb"}, + PortResults: []scanner.PortResult{ + { + Port: 443, + Status: scanner.StatusOK, + TLS13Supported: true, + MLKEMSupported: false, + }, + }, + }, + { + IP: "10.0.0.3", + PortResults: []scanner.PortResult{ + {Port: 9090, Status: scanner.StatusNoTLS}, + }, + }, + }, + } + + doc := BuildTLSPostureAttestation(results, true, AttestationMeta{ + ScannerVersion: "test", + ScannerCommit: "abc", + ClusterVersion: &k8s.ClusterVersionInfo{Version: "4.22.0", Image: "quay.io/ocp@sha256:ccc"}, + }) + + if doc.PredicateType != TLSPosturePredicateType { + t.Errorf("PredicateType = %q", doc.PredicateType) + } + if doc.Predicate.PolicyBar != PolicyBarPQC { + t.Errorf("PolicyBar = %q, want %q", doc.Predicate.PolicyBar, PolicyBarPQC) + } + if doc.Predicate.Result != OverallResultFail { + t.Errorf("Result = %q, want FAIL", doc.Predicate.Result) + } + if doc.Predicate.ClusterVersion == nil || doc.Predicate.ClusterVersion.Version != "4.22.0" { + t.Errorf("ClusterVersion = %+v", doc.Predicate.ClusterVersion) + } + if doc.Predicate.Summary.ComponentsTotal != 3 { + t.Errorf("ComponentsTotal = %d, want 3", doc.Predicate.Summary.ComponentsTotal) + } + if doc.Predicate.Summary.ComponentsPassed != 1 || doc.Predicate.Summary.ComponentsFailed != 1 || doc.Predicate.Summary.ComponentsSkipped != 1 { + t.Errorf("summary pass/fail/skip = %d/%d/%d, want 1/1/1", + doc.Predicate.Summary.ComponentsPassed, doc.Predicate.Summary.ComponentsFailed, doc.Predicate.Summary.ComponentsSkipped) + } + + byName := map[string]ComponentPosture{} + for _, c := range doc.Predicate.Components { + byName[c.Name] = c + } + + api := byName["kube-apiserver"] + if api.Result != ComponentResultPass || api.Successes != 1 || api.Skipped != 1 { + t.Errorf("kube-apiserver = %+v", api) + } + if len(api.Images) != 1 { + t.Errorf("kube-apiserver images = %v", api.Images) + } + + ing := byName["openshift-ingress"] + if ing.Result != ComponentResultFail || ing.Failures != 1 { + t.Errorf("openshift-ingress = %+v", ing) + } + if len(ing.FailureDetails) != 1 || ing.FailureDetails[0].Port != 443 { + t.Errorf("openshift-ingress failureDetails = %+v", ing.FailureDetails) + } + + unk := byName["unknown"] + if unk.Result != ComponentResultSkip { + t.Errorf("unknown = %+v", unk) + } +} + +func TestBuildTLSPostureAttestationObserveMode(t *testing.T) { + t.Parallel() + + results := scanner.ScanResults{ + IPResults: []scanner.IPResult{{ + IP: "10.0.0.1", + OpenshiftComponent: &k8s.OpenshiftComponent{Component: "foo"}, + PortResults: []scanner.PortResult{{ + Port: 443, + Status: scanner.StatusOK, + TLS13Supported: false, + MLKEMSupported: false, + }}, + }}, + } + + doc := BuildTLSPostureAttestation(results, false, AttestationMeta{ScannerVersion: "dev"}) + if doc.Predicate.PolicyBar != PolicyBarObserve { + t.Errorf("PolicyBar = %q, want observe", doc.Predicate.PolicyBar) + } + if doc.Predicate.Result != OverallResultPass { + t.Errorf("observe mode Result = %q, want PASS", doc.Predicate.Result) + } + if doc.Predicate.Components[0].Result != ComponentResultPass { + t.Errorf("component result = %q, want PASS in observe mode", doc.Predicate.Components[0].Result) + } +} + +func TestBuildTLSPostureAttestationTLSProfileBar(t *testing.T) { + t.Parallel() + + results := scanner.ScanResults{ + TLSSecurityConfig: &k8s.TLSSecurityProfile{ + TLSAdherence: configv1.TLSAdherencePolicyStrictAllComponents, + }, + IPResults: []scanner.IPResult{{ + IP: "10.0.0.1", + OpenshiftComponent: &k8s.OpenshiftComponent{Component: "kube-apiserver"}, + PortResults: []scanner.PortResult{{ + Port: 6443, + Status: scanner.StatusOK, + APIServerTLSConfigCompliance: &scanner.TLSConfigComplianceResult{ + Version: false, + Ciphers: true, + }, + }}, + }}, + } + + doc := BuildTLSPostureAttestation(results, false, AttestationMeta{}) + if doc.Predicate.PolicyBar != PolicyBarTLSProfile { + t.Errorf("PolicyBar = %q, want tls-profile", doc.Predicate.PolicyBar) + } + if doc.Predicate.Result != OverallResultFail { + t.Errorf("Result = %q, want FAIL", doc.Predicate.Result) + } + if len(doc.Predicate.Components[0].FailureDetails) != 1 { + t.Fatalf("expected failure detail, got %+v", doc.Predicate.Components[0].FailureDetails) + } +} + +func TestWriteAttestationFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "tls-posture.intoto.json") + results := scanner.ScanResults{ + Timestamp: "2026-07-23T12:00:00Z", + IPResults: []scanner.IPResult{{ + IP: "10.0.0.1", + OpenshiftComponent: &k8s.OpenshiftComponent{Component: "kube-apiserver"}, + PortResults: []scanner.PortResult{{ + Port: 6443, + Status: scanner.StatusOK, + TLS13Supported: true, + MLKEMSupported: true, + }}, + }}, + } + + if err := WriteAttestationFile(results, path, true, AttestationMeta{ScannerVersion: "1.0"}); err != nil { + t.Fatalf("WriteAttestationFile: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var doc TLSPostureAttestation + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if doc.Predicate.Result != OverallResultPass { + t.Errorf("Result = %q", doc.Predicate.Result) + } + if FormatAttestationSummary(doc) == "" { + t.Error("expected non-empty summary") + } +} + +func TestWriteOutputFilesAttestation(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + results := testScanResults() + meta := &AttestationMeta{ScannerVersion: "test"} + if err := WriteOutputFiles(results, dir, "", "", "", "attestation.json", false, meta); err != nil { + t.Fatalf("WriteOutputFiles: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "attestation.json")); err != nil { + t.Errorf("missing attestation file: %v", err) + } +} diff --git a/internal/output/json.go b/internal/output/json.go index 7d655af5..1c5967e8 100644 --- a/internal/output/json.go +++ b/internal/output/json.go @@ -28,8 +28,8 @@ func WriteJSONOutput(data interface{}, filename string) error { return nil } -func WriteOutputFiles(results scanner.ScanResults, artifactDir, jsonFile, csvFile, junitFile string, pqcCheck bool) error { - if jsonFile == "" && csvFile == "" && junitFile == "" { +func WriteOutputFiles(results scanner.ScanResults, artifactDir, jsonFile, csvFile, junitFile, attestationFile string, pqcCheck bool, meta *AttestationMeta) error { + if jsonFile == "" && csvFile == "" && junitFile == "" && attestationFile == "" { return nil } @@ -39,10 +39,7 @@ func WriteOutputFiles(results scanner.ScanResults, artifactDir, jsonFile, csvFil slog.Info("artifacts directory created", "path", artifactDir) if jsonFile != "" { - jsonPath := jsonFile - if !filepath.IsAbs(jsonPath) { - jsonPath = filepath.Join(artifactDir, jsonFile) - } + jsonPath := resolveOutputPath(artifactDir, jsonFile) if err := WriteJSONOutput(results, jsonPath); err != nil { slog.Error("writing JSON output", "error", err) } else { @@ -51,10 +48,7 @@ func WriteOutputFiles(results scanner.ScanResults, artifactDir, jsonFile, csvFil } if csvFile != "" { - csvPath := csvFile - if !filepath.IsAbs(csvPath) { - csvPath = filepath.Join(artifactDir, csvFile) - } + csvPath := resolveOutputPath(artifactDir, csvFile) if err := WriteCSVOutput(results, csvPath); err != nil { slog.Error("writing CSV output", "error", err) } else { @@ -72,10 +66,7 @@ func WriteOutputFiles(results scanner.ScanResults, artifactDir, jsonFile, csvFil } if junitFile != "" { - junitPath := junitFile - if !filepath.IsAbs(junitPath) { - junitPath = filepath.Join(artifactDir, junitFile) - } + junitPath := resolveOutputPath(artifactDir, junitFile) if err := WriteJUnitOutput(results, junitPath, pqcCheck); err != nil { slog.Error("writing JUnit XML output", "error", err) } else { @@ -83,5 +74,16 @@ func WriteOutputFiles(results scanner.ScanResults, artifactDir, jsonFile, csvFil } } + if attestationFile != "" { + attestationPath := resolveOutputPath(artifactDir, attestationFile) + attMeta := AttestationMeta{} + if meta != nil { + attMeta = *meta + } + if err := WriteAttestationFile(results, attestationPath, pqcCheck, attMeta); err != nil { + slog.Error("writing attestation output", "error", err) + } + } + return nil } diff --git a/internal/output/json_test.go b/internal/output/json_test.go index 74c9b17c..d2feeb7c 100644 --- a/internal/output/json_test.go +++ b/internal/output/json_test.go @@ -63,7 +63,7 @@ func TestWriteOutputFilesNoop(t *testing.T) { t.Parallel() results := testScanResults() - if err := WriteOutputFiles(results, t.TempDir(), "", "", "", false); err != nil { + if err := WriteOutputFiles(results, t.TempDir(), "", "", "", "", false, nil); err != nil { t.Fatalf("expected nil for empty filenames, got: %v", err) } } @@ -73,7 +73,7 @@ func TestWriteOutputFilesJSON(t *testing.T) { dir := t.TempDir() results := testScanResults() - if err := WriteOutputFiles(results, dir, "out.json", "", "", false); err != nil { + if err := WriteOutputFiles(results, dir, "out.json", "", "", "", false, nil); err != nil { t.Fatalf("WriteOutputFiles returned error: %v", err) } @@ -90,7 +90,7 @@ func TestWriteOutputFilesAbsolutePath(t *testing.T) { artifactDir := filepath.Join(dir, "artifacts") absPath := filepath.Join(dir, "absolute.json") results := testScanResults() - if err := WriteOutputFiles(results, artifactDir, absPath, "", "", false); err != nil { + if err := WriteOutputFiles(results, artifactDir, absPath, "", "", "", false, nil); err != nil { t.Fatalf("WriteOutputFiles returned error: %v", err) } @@ -104,7 +104,7 @@ func TestWriteOutputFilesCSV(t *testing.T) { dir := t.TempDir() results := testScanResults() - if err := WriteOutputFiles(results, dir, "", "out.csv", "", false); err != nil { + if err := WriteOutputFiles(results, dir, "", "out.csv", "", "", false, nil); err != nil { t.Fatalf("WriteOutputFiles returned error: %v", err) } @@ -119,7 +119,7 @@ func TestWriteOutputFilesJUnit(t *testing.T) { dir := t.TempDir() results := testScanResults() - if err := WriteOutputFiles(results, dir, "", "", "out.xml", false); err != nil { + if err := WriteOutputFiles(results, dir, "", "", "out.xml", "", false, nil); err != nil { t.Fatalf("WriteOutputFiles returned error: %v", err) } diff --git a/scanner-job.yaml.template b/scanner-job.yaml.template index 29237f66..37d51c1a 100644 --- a/scanner-job.yaml.template +++ b/scanner-job.yaml.template @@ -16,7 +16,7 @@ spec: args: - | # Run the scanner in the background and capture its exit code - /usr/local/bin/tls-scanner --all-pods -j ${SCANNER_PARALLEL:-4} --artifact-dir /artifacts --json-file /artifacts/results.json --csv-file /artifacts/results.csv --timing-file timing.txt --log-file /artifacts/scan.log ${NAMESPACE_FILTER_ARG} ${LIMIT_IPS_ARG} ${STARTTLS_PORTS_ARG} & + /usr/local/bin/tls-scanner --all-pods -j ${SCANNER_PARALLEL:-4} --artifact-dir /artifacts --json-file /artifacts/results.json --csv-file /artifacts/results.csv --attestation-file /artifacts/tls-posture.intoto.json --timing-file timing.txt --log-file /artifacts/scan.log ${NAMESPACE_FILTER_ARG} ${LIMIT_IPS_ARG} ${STARTTLS_PORTS_ARG} & SCANNER_PID=$!