diff --git a/README.md b/README.md index 06e3ab8..23997c2 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ github.com (secure, expires in 84 days) ✓ ## Why tlsctl? - **Instant insights** — One command shows certificate status, chain, SANs, and expiry at a glance -- **Multiple output formats** — Human-readable, JSON, YAML, verbose text, or raw PEM +- **Multiple output formats** — Human-readable, JSON, YAML, concise CSV, full CSV, verbose text, or raw PEM - **Revocation checking** — Built-in CRL and OCSP support to detect revoked certificates - **PEM file parsing** — Inspect local certificate files with the same rich output - **Custom CA support** — Validate against private CAs with `--cacert` @@ -270,7 +270,7 @@ suite is reported because Go's `crypto/tls` does not allow configuring TLS 1.3 c suites individually. In human output, insecure cipher suites are highlighted in red and tagged with `(insecure)`. -In non-human outputs (`json`, `yaml`, and `text`), cipher suites are split into +In non-human outputs (`json`, `yaml`, `csv-full`, and `text`), cipher suites are split into `secure_cipher_suites` and `insecure_cipher_suites`. ### Verbose text output @@ -394,6 +394,21 @@ certificates: verified: true ``` +### CSV output + +Use `-o csv` for a concise, spreadsheet-friendly summary. Each input produces one row based on the leaf certificate: + +```bash +$ tlsctl client -o csv badssl.com +``` + +```csv +target,common_name,issuer,not_before,not_after,days_remaining,sha256,subject_alternative_names +badssl.com:443,*.badssl.com,"CN=R13,O=Let's Encrypt,C=US",2026-01-20T20:02:51Z,2026-04-20T20:02:50Z,90,b4:5a:53:24:32:d9:8f:62:b6:ea:f1:47:32:06:10:f1:...,"*.badssl.com; badssl.com" +``` + +Use `-o csv-full` if you want the row-per-certificate export with the wider field set. + ### Raw PEM output Use `-o raw` to extract the PEM-encoded certificates: @@ -543,6 +558,8 @@ tlsctl client -o json google.com github.com | jq -r '.[] | .certificates[] | sel | Text | `-o text` | Verbose output with all certificate fields | | JSON | `-o json` | Full structured JSON, ideal for scripting and automation | | YAML | `-o yaml` | Full structured YAML | +| CSV | `-o csv` | Concise one-row-per-input summary using the leaf certificate | +| CSV Full | `-o csv-full` | Wide row-per-certificate export for detailed tabular processing | | Raw | `-o raw` | PEM-encoded certificates | ## Exit codes diff --git a/cmd/client.go b/cmd/client.go index 3de1f41..a79c0d9 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -132,7 +132,7 @@ func newClientCmd(rt *Runtime) *cobra.Command { }, } - cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: human (default), json, yaml, text (verbose), raw (PEM)") + cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: human (default), json, yaml, csv, csv-full, text (verbose), raw (PEM)") cmd.Flags().StringVar(&caCertFile, "cacert", "", "Path to CA certificate file (PEM format)") cmd.Flags().StringVarP(&proxyURL, "proxy", "x", "", "Proxy URL (e.g. http://proxy:8080). Falls back to HTTPS_PROXY/HTTP_PROXY env vars if not set") cmd.Flags().StringVar(&inputFile, "file", "", "Read endpoints from file (one per line, '-' for stdin)") diff --git a/cmd/pem.go b/cmd/pem.go index 43b6beb..f7738e6 100644 --- a/cmd/pem.go +++ b/cmd/pem.go @@ -47,6 +47,10 @@ func newPemCmd(rt *Runtime) *cobra.Command { return fmt.Errorf("failed to read stdin: %w", rErr) } chainInfo, err = tlsquery.ParsePEM(data, opts) + if err == nil { + chainInfo.InputName = "stdin" + chainInfo.InputLabel = "source" + } } else { chainInfo, err = tlsquery.ParsePEMFile(args[0], opts) } @@ -72,7 +76,7 @@ func newPemCmd(rt *Runtime) *cobra.Command { }, } - cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: human (default), json, yaml, text (verbose), raw (PEM)") + cmd.Flags().StringVarP(&outputFormat, "output", "o", "", "Output format: human (default), json, yaml, csv, csv-full, text (verbose), raw (PEM)") cmd.Flags().StringVar(&caCertFile, "cacert", "", "Path to CA certificate file (PEM format)") addRevocationFlags(cmd, &rf) addCertFlags(cmd) diff --git a/cmd/render_test.go b/cmd/render_test.go index ece01c2..843712c 100644 --- a/cmd/render_test.go +++ b/cmd/render_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/csv" "encoding/json" "strings" "testing" @@ -14,7 +15,9 @@ import ( func testChains() []*tlsquery.ChainInfo { return []*tlsquery.ChainInfo{ { - Verified: true, + InputName: "a.example.com:443", + InputLabel: "target", + Verified: true, Certificates: []tlsquery.CertInfo{ { Type: "leaf", @@ -28,7 +31,9 @@ func testChains() []*tlsquery.ChainInfo { }, }, { - Verified: true, + InputName: "b.example.com:443", + InputLabel: "target", + Verified: true, Certificates: []tlsquery.CertInfo{ { Type: "leaf", @@ -96,6 +101,55 @@ func TestRenderChains_MultiYAML(t *testing.T) { } } +func TestRenderChains_MultiCSV(t *testing.T) { + chains := testChains() + var buf bytes.Buffer + + err := renderChains(&buf, output.FormatCSV, chains, output.Options{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV output: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + + if rows[0][0] != "target" { + t.Fatalf("expected CSV header row, got %q", rows[0][0]) + } + if rows[1][0] != "a.example.com:443" || rows[2][0] != "b.example.com:443" { + t.Fatalf("expected target values for both rows, got %q and %q", rows[1][0], rows[2][0]) + } +} + +func TestRenderChains_MultiCSVFull(t *testing.T) { + chains := testChains() + var buf bytes.Buffer + + err := renderChains(&buf, output.FormatCSVFull, chains, output.Options{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV full output: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected header plus two rows, got %d rows", len(rows)) + } + if rows[0][0] != "target" { + t.Fatalf("expected CSV full header row, got %q", rows[0][0]) + } + if rows[1][2] != "leaf" || rows[2][2] != "leaf" { + t.Fatalf("expected leaf certificate rows, got %q and %q", rows[1][2], rows[2][2]) + } +} + func TestRenderChains_SingleJSON(t *testing.T) { chains := testChains()[:1] var buf bytes.Buffer diff --git a/internal/output/csv.go b/internal/output/csv.go new file mode 100644 index 0000000..bfe5fa1 --- /dev/null +++ b/internal/output/csv.go @@ -0,0 +1,263 @@ +package output + +import ( + "encoding/csv" + "fmt" + "io" + "strconv" + "strings" + + "github.com/catay/tlsctl/internal/tlsquery" +) + +type CSVRenderer struct{} + +type CSVFullRenderer struct{} + +func (CSVRenderer) Render(w io.Writer, chain *tlsquery.ChainInfo, opts Options) error { + return renderCSVSummary(w, []*tlsquery.ChainInfo{chain}, opts) +} + +func (CSVRenderer) RenderAll(w io.Writer, chains []*tlsquery.ChainInfo, opts Options) error { + return renderCSVSummary(w, chains, opts) +} + +func (CSVFullRenderer) Render(w io.Writer, chain *tlsquery.ChainInfo, opts Options) error { + return renderCSVFull(w, []*tlsquery.ChainInfo{chain}) +} + +func (CSVFullRenderer) RenderAll(w io.Writer, chains []*tlsquery.ChainInfo, opts Options) error { + return renderCSVFull(w, chains) +} + +func renderCSVSummary(w io.Writer, chains []*tlsquery.ChainInfo, opts Options) error { + headers := []string{ + csvInputHeader(chains), + "common_name", + "issuer", + "not_before", + "not_after", + "days_remaining", + "sha256", + "subject_alternative_names", + } + + writer := csv.NewWriter(w) + if err := writer.Write(headers); err != nil { + return err + } + + for _, chain := range chains { + if chain == nil { + continue + } + leaf, err := chain.Leaf() + if err != nil { + return err + } + record, err := csvSummaryRow(chain, leaf, opts) + if err != nil { + return err + } + if err := writer.Write(record); err != nil { + return err + } + } + + writer.Flush() + return writer.Error() +} + +func csvSummaryRow(chain *tlsquery.ChainInfo, leaf *tlsquery.CertInfo, opts Options) ([]string, error) { + notAfter, err := leaf.NotAfterTime() + if err != nil { + return nil, fmt.Errorf("failed to parse expiry date: %w", err) + } + + daysRemaining := int(notAfter.Sub(opts.NowFunc()).Hours() / 24) + + return []string{ + csvInputValue(chain), + leaf.CommonName, + leaf.Issuer, + leaf.NotBefore, + leaf.NotAfter, + strconv.Itoa(daysRemaining), + leaf.Fingerprint.SHA256, + csvJoin(leaf.SubjectAltNames), + }, nil +} + +func renderCSVFull(w io.Writer, chains []*tlsquery.ChainInfo) error { + headers := []string{ + csvInputHeader(chains), + "certificate_index", + "certificate_type", + "chain", + "verified", + "verification_error", + "version", + "serial_number", + "signature_algorithm", + "issuer", + "subject", + "common_name", + "not_before", + "not_after", + "public_key_algorithm", + "key_length", + "key_usage", + "extended_key_usage", + "basic_constraints_is_ca", + "basic_constraints_max_path_len", + "subject_key_id", + "authority_key_id", + "subject_alternative_names", + "email_addresses", + "ip_addresses", + "ocsp_servers", + "issuing_cert_url", + "crl_distribution_points", + "fingerprint_sha1", + "fingerprint_sha256", + "tls_versions", + "secure_cipher_suites", + "insecure_cipher_suites", + "revocation_status", + "revocation_checked_at", + } + + writer := csv.NewWriter(w) + if err := writer.Write(headers); err != nil { + return err + } + + for _, chain := range chains { + if chain == nil { + continue + } + for certIndex := range chain.Certificates { + if err := writer.Write(csvFullRow(certIndex, chain, &chain.Certificates[certIndex])); err != nil { + return err + } + } + } + + writer.Flush() + return writer.Error() +} + +func csvFullRow(certIndex int, chain *tlsquery.ChainInfo, cert *tlsquery.CertInfo) []string { + basicConstraintsIsCA := "" + basicConstraintsMaxPathLen := "" + if cert.BasicConstraints != nil { + basicConstraintsIsCA = strconv.FormatBool(cert.BasicConstraints.IsCA) + basicConstraintsMaxPathLen = strconv.Itoa(cert.BasicConstraints.MaxPathLen) + } + + revocationStatus := "" + revocationCheckedAt := "" + if cert.Revocation != nil { + revocationStatus = string(cert.Revocation.OverallStatus) + revocationCheckedAt = cert.Revocation.CheckedAt + } + + return []string{ + csvInputValue(chain), + strconv.Itoa(certIndex), + cert.Type, + strings.Join(chain.ChainNames(), " -> "), + strconv.FormatBool(chain.Verified), + chain.VerificationError, + strconv.Itoa(cert.Version), + cert.SerialNumber, + cert.SignatureAlgorithm, + cert.Issuer, + cert.Subject, + cert.CommonName, + cert.NotBefore, + cert.NotAfter, + cert.PublicKeyAlgorithm, + strconv.Itoa(cert.KeyLength), + csvJoin(cert.KeyUsage), + csvJoin(cert.ExtKeyUsage), + basicConstraintsIsCA, + basicConstraintsMaxPathLen, + cert.SubjectKeyID, + cert.AuthorityKeyID, + csvJoin(cert.SubjectAltNames), + csvJoin(cert.EmailAddresses), + csvJoin(cert.IPAddresses), + csvJoin(cert.OCSPServers), + csvJoin(cert.IssuingCertURL), + csvJoin(cert.CRLDistPoints), + cert.Fingerprint.SHA1, + cert.Fingerprint.SHA256, + csvTLSVersions(chain.TLSVersions), + csvCipherSuites(chain.TLSVersions, func(version tlsquery.TLSVersionInfo) []string { + return version.SecureCipherSuites + }), + csvCipherSuites(chain.TLSVersions, func(version tlsquery.TLSVersionInfo) []string { + return version.InsecureCipherSuites + }), + revocationStatus, + revocationCheckedAt, + } +} + +func csvInputHeader(chains []*tlsquery.ChainInfo) string { + if len(chains) == 0 { + return "input" + } + + header := "" + for _, chain := range chains { + if chain == nil || chain.InputLabel == "" { + return "input" + } + if header == "" { + header = chain.InputLabel + continue + } + if chain.InputLabel != header { + return "input" + } + } + + if header == "" { + return "input" + } + return header +} + +func csvInputValue(chain *tlsquery.ChainInfo) string { + if chain == nil { + return "" + } + return chain.InputName +} + +func csvJoin(values []string) string { + return strings.Join(values, "; ") +} + +func csvTLSVersions(versions []tlsquery.TLSVersionInfo) string { + names := make([]string, 0, len(versions)) + for _, version := range versions { + if version.Version == "" { + continue + } + names = append(names, version.Version) + } + return csvJoin(names) +} + +func csvCipherSuites(versions []tlsquery.TLSVersionInfo, suites func(tlsquery.TLSVersionInfo) []string) string { + var flattened []string + for _, version := range versions { + for _, suite := range suites(version) { + flattened = append(flattened, version.Version+": "+suite) + } + } + return csvJoin(flattened) +} diff --git a/internal/output/factory.go b/internal/output/factory.go index d913ff0..a46b5c9 100644 --- a/internal/output/factory.go +++ b/internal/output/factory.go @@ -9,6 +9,8 @@ const ( FormatHuman Format = "human" FormatJSON Format = "json" FormatYAML Format = "yaml" + FormatCSV Format = "csv" + FormatCSVFull Format = "csv-full" FormatText Format = "text" FormatRaw Format = "raw" ) @@ -21,11 +23,15 @@ func New(format Format) (Renderer, error) { return JSONRenderer{}, nil case FormatYAML: return YAMLRenderer{}, nil + case FormatCSV: + return CSVRenderer{}, nil + case FormatCSVFull: + return CSVFullRenderer{}, nil case FormatText: return VerboseTextRenderer{}, nil case FormatRaw: return RawPEMRenderer{}, nil default: - return nil, fmt.Errorf("invalid output format: %q (valid: human, json, yaml, text, raw)", format) + return nil, fmt.Errorf("invalid output format: %q (valid: human, json, yaml, csv, csv-full, text, raw)", format) } } diff --git a/internal/output/renderer_test.go b/internal/output/renderer_test.go index ddfa350..1e11656 100644 --- a/internal/output/renderer_test.go +++ b/internal/output/renderer_test.go @@ -3,6 +3,7 @@ package output import ( "bytes" "crypto/tls" + "encoding/csv" "encoding/json" "strings" "testing" @@ -14,6 +15,8 @@ import ( func testChain() *tlsquery.ChainInfo { return &tlsquery.ChainInfo{ + InputName: "test.example.com:443", + InputLabel: "target", Certificates: []tlsquery.CertInfo{ { Type: "leaf", @@ -27,7 +30,11 @@ func testChain() *tlsquery.ChainInfo { NotAfter: "2027-01-01T00:00:00Z", PublicKeyAlgorithm: "RSA", SubjectAltNames: []string{"test.example.com", "www.example.com"}, - PEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n", + Fingerprint: tlsquery.Fingerprint{ + SHA1: "11:22:33", + SHA256: "aa:bb:cc", + }, + PEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n", }, }, } @@ -63,6 +70,8 @@ func TestNewFactory(t *testing.T) { {FormatHuman, "HumanRenderer", false}, {FormatJSON, "JSONRenderer", false}, {FormatYAML, "YAMLRenderer", false}, + {FormatCSV, "CSVRenderer", false}, + {FormatCSVFull, "CSVFullRenderer", false}, {FormatText, "VerboseTextRenderer", false}, {FormatRaw, "RawPEMRenderer", false}, {"invalid", "", true}, @@ -171,6 +180,86 @@ func TestYAMLRenderer(t *testing.T) { } } +func TestCSVRenderer(t *testing.T) { + chain := testChain() + chain.Verified = true + secureCipher, insecureCipher := sampleCipherSuites(t) + chain.TLSVersions = []tlsquery.TLSVersionInfo{ + { + Version: "TLS 1.2", + CipherSuites: []string{secureCipher, insecureCipher}, + SecureCipherSuites: []string{secureCipher}, + InsecureCipherSuites: []string{insecureCipher}, + }, + } + var buf bytes.Buffer + r := CSVRenderer{} + + if err := r.Render(&buf, chain, Options{Now: fixedNow}); err != nil { + t.Fatalf("Render failed: %v", err) + } + + rows, err := csv.NewReader(bytes.NewReader(buf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected header plus one row, got %d rows", len(rows)) + } + + record := make(map[string]string, len(rows[0])) + for i, header := range rows[0] { + record[header] = rows[1][i] + } + + if record["target"] != "test.example.com:443" { + t.Errorf("expected target=test.example.com:443, got %q", record["target"]) + } + if record["common_name"] != "test.example.com" { + t.Errorf("expected common_name=test.example.com, got %q", record["common_name"]) + } + if record["issuer"] != "CN=Test CA" { + t.Errorf("expected issuer=CN=Test CA, got %q", record["issuer"]) + } + if record["days_remaining"] != "329" { + t.Errorf("expected days_remaining=329, got %q", record["days_remaining"]) + } + if record["sha256"] == "" { + t.Error("expected SHA256 fingerprint in CSV output") + } + if record["subject_alternative_names"] != "test.example.com; www.example.com" { + t.Errorf("unexpected SAN value: %q", record["subject_alternative_names"]) + } + + fullBuf := bytes.Buffer{} + full := CSVFullRenderer{} + if err := full.Render(&fullBuf, chain, Options{Now: fixedNow}); err != nil { + t.Fatalf("CSVFull Render failed: %v", err) + } + + fullRows, err := csv.NewReader(bytes.NewReader(fullBuf.Bytes())).ReadAll() + if err != nil { + t.Fatalf("failed to parse CSV full output: %v", err) + } + if len(fullRows) != 2 { + t.Fatalf("expected header plus one full row, got %d rows", len(fullRows)) + } + + fullRecord := make(map[string]string, len(fullRows[0])) + for i, header := range fullRows[0] { + fullRecord[header] = fullRows[1][i] + } + if fullRecord["certificate_type"] != "leaf" { + t.Errorf("expected certificate_type=leaf in csv-full, got %q", fullRecord["certificate_type"]) + } + if !strings.Contains(fullRecord["secure_cipher_suites"], "TLS 1.2: "+secureCipher) { + t.Errorf("expected secure cipher suites to include version-qualified entry, got %q", fullRecord["secure_cipher_suites"]) + } + if !strings.Contains(fullRecord["insecure_cipher_suites"], "TLS 1.2: "+insecureCipher) { + t.Errorf("expected insecure cipher suites to include version-qualified entry, got %q", fullRecord["insecure_cipher_suites"]) + } +} + func TestRawPEMRenderer(t *testing.T) { chain := testChain() chain.Verified = true diff --git a/internal/tlsquery/chain.go b/internal/tlsquery/chain.go index e149e91..1c1796e 100644 --- a/internal/tlsquery/chain.go +++ b/internal/tlsquery/chain.go @@ -15,6 +15,8 @@ func (c *ChainInfo) WithoutPEM() *ChainInfo { Verified: c.Verified, VerificationError: c.VerificationError, TLSVersions: c.TLSVersions, + InputName: c.InputName, + InputLabel: c.InputLabel, } for i := range c.Certificates { out.Certificates[i] = c.Certificates[i] diff --git a/internal/tlsquery/chain_test.go b/internal/tlsquery/chain_test.go index 64d40be..6620bbe 100644 --- a/internal/tlsquery/chain_test.go +++ b/internal/tlsquery/chain_test.go @@ -41,6 +41,8 @@ func TestChainInfo_Leaf(t *testing.T) { func TestChainInfo_WithoutPEM(t *testing.T) { chain := &ChainInfo{ + InputName: "example.com:443", + InputLabel: "target", Certificates: []CertInfo{ {CommonName: "test", PEM: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"}, }, @@ -54,6 +56,12 @@ func TestChainInfo_WithoutPEM(t *testing.T) { if chain.Certificates[0].PEM == "" { t.Error("original chain should not be modified") } + if result.InputName != "example.com:443" { + t.Errorf("expected InputName to be preserved, got %q", result.InputName) + } + if result.InputLabel != "target" { + t.Errorf("expected InputLabel to be preserved, got %q", result.InputLabel) + } } func TestChainInfo_ChainNames(t *testing.T) { diff --git a/internal/tlsquery/pem.go b/internal/tlsquery/pem.go index c2f1dc2..95dd1d1 100644 --- a/internal/tlsquery/pem.go +++ b/internal/tlsquery/pem.go @@ -19,7 +19,13 @@ func ParsePEMFile(path string, opts PEMOptions) (*ChainInfo, error) { return nil, fmt.Errorf("failed to read file: %w", err) } - return ParsePEM(data, opts) + chain, err := ParsePEM(data, opts) + if err != nil { + return nil, err + } + chain.InputName = path + chain.InputLabel = "source" + return chain, nil } // ParsePEM parses PEM-encoded certificate data and returns certificate information. diff --git a/internal/tlsquery/query.go b/internal/tlsquery/query.go index ace1fea..911db80 100644 --- a/internal/tlsquery/query.go +++ b/internal/tlsquery/query.go @@ -48,6 +48,8 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { chain := buildChain(certs) chain.Verified = false chain.VerificationError = abbreviateVerifyErrorWithChain(verifyErr, certs) + chain.InputName = endpoint + chain.InputLabel = "target" if probeVersions { chain.TLSVersions = probeTLSVersions(endpoint, proxyURL, config, true, startTLS) } @@ -56,6 +58,8 @@ func Query(endpoint string, opts QueryOptions) (*ChainInfo, error) { chain := buildChain(certs) chain.Verified = true + chain.InputName = endpoint + chain.InputLabel = "target" if probeVersions { chain.TLSVersions = probeTLSVersions(endpoint, proxyURL, config, false, startTLS) } diff --git a/internal/tlsquery/types.go b/internal/tlsquery/types.go index 6cad5b2..b2e2032 100644 --- a/internal/tlsquery/types.go +++ b/internal/tlsquery/types.go @@ -59,6 +59,8 @@ type ChainInfo struct { Verified bool `json:"verified" yaml:"verified"` VerificationError string `json:"verification_error,omitempty" yaml:"verification_error,omitempty"` TLSVersions []TLSVersionInfo `json:"tls_versions,omitempty" yaml:"tls_versions,omitempty"` + InputName string `json:"-" yaml:"-"` + InputLabel string `json:"-" yaml:"-"` } // QueryOptions configures the TLS query behavior.