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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
6 changes: 5 additions & 1 deletion cmd/pem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down
58 changes: 56 additions & 2 deletions cmd/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"bytes"
"encoding/csv"
"encoding/json"
"strings"
"testing"
Expand All @@ -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",
Expand All @@ -28,7 +31,9 @@ func testChains() []*tlsquery.ChainInfo {
},
},
{
Verified: true,
InputName: "b.example.com:443",
InputLabel: "target",
Verified: true,
Certificates: []tlsquery.CertInfo{
{
Type: "leaf",
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading