Skip to content

Commit cc57cb9

Browse files
committed
feat: add option to enumerate subdomains
1 parent 1c0d4d8 commit cc57cb9

File tree

6 files changed

+172
-53
lines changed

6 files changed

+172
-53
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ Usage: crt [options...] <domain name>
99
1010
Options:
1111
-o <path> Output file path. Write to file instead of stdout.
12+
-s Enumerate subdomains.
1213
-e Exclude expired certificates.
1314
-l <int> Limit the number of results. (default: 1000)
1415
-json Turn results to JSON.
1516
-csv Turn results to CSV.
1617
1718
Examples:
1819
crt example.com
20+
crt -s -e example.com
1921
crt -o logs.json -json example.com
2022
crt -csv -o logs.csv -l 15 example.com
2123
```

cmd/cmd.go

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,27 @@ import (
1111
)
1212

1313
var (
14-
filename = flag.String("o", "", "")
15-
expired = flag.Bool("e", false, "")
16-
limit = flag.Int("l", 1000, "")
17-
jsonOut = flag.Bool("json", false, "")
18-
csvOut = flag.Bool("csv", false, "")
14+
filename = flag.String("o", "", "")
15+
subdomain = flag.Bool("s", false, "")
16+
expired = flag.Bool("e", false, "")
17+
limit = flag.Int("l", 1000, "")
18+
jsonOut = flag.Bool("json", false, "")
19+
csvOut = flag.Bool("csv", false, "")
1920
)
2021

2122
var usage = `Usage: crt [options...] <domain name>
2223
2324
Options:
2425
-o <path> Output file path. Write to file instead of stdout.
26+
-s Enumerate subdomains.
2527
-e Exclude expired certificates.
2628
-l <int> Limit the number of results. (default: 1000)
2729
-json Turn results to JSON.
2830
-csv Turn results to CSV.
2931
3032
Examples:
3133
crt example.com
34+
crt -s -e example.com
3235
crt -o logs.json -json example.com
3336
crt -csv -o logs.csv -l 15 example.com
3437
`
@@ -55,9 +58,13 @@ func Execute() {
5558
}
5659
defer repo.Close()
5760

58-
var res result.CertResult
61+
var res result.QueryResult
5962

60-
res, err = repo.GetCertLogs(domain, *expired, *limit)
63+
if *subdomain {
64+
res, err = repo.GetSubdomains(domain, *expired, *limit)
65+
} else {
66+
res, err = repo.GetCertLogs(domain, *expired, *limit)
67+
}
6168
if err != nil {
6269
log.Fatal(err)
6370
}

repository/database.go

Lines changed: 33 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import (
44
"database/sql"
55
"fmt"
66

7-
_ "github.com/lib/pq"
8-
97
"github.com/cemulus/crt/result"
8+
9+
_ "github.com/lib/pq"
1010
)
1111

1212
var (
@@ -18,48 +18,6 @@ var (
1818
login = fmt.Sprintf("host=%s port=%d user=%s dbname=%s", host, port, user, dbname)
1919
)
2020

21-
const (
22-
statement = `WITH ci AS (
23-
SELECT min(sub.CERTIFICATE_ID) ID,
24-
min(sub.ISSUER_CA_ID) ISSUER_CA_ID,
25-
array_agg(DISTINCT sub.NAME_VALUE) NAME_VALUES,
26-
x509_commonName(sub.CERTIFICATE) COMMON_NAME,
27-
x509_notBefore(sub.CERTIFICATE) NOT_BEFORE,
28-
x509_notAfter(sub.CERTIFICATE) NOT_AFTER,
29-
encode(x509_serialNumber(sub.CERTIFICATE), 'hex') SERIAL_NUMBER
30-
FROM (SELECT *
31-
FROM certificate_and_identities cai
32-
WHERE plainto_tsquery('certwatch', '%s') @@ identities(cai.CERTIFICATE)
33-
AND cai.NAME_VALUE ILIKE ('%%' || '%s' || '%%')
34-
%s --filter
35-
LIMIT 10000
36-
) sub
37-
GROUP BY sub.CERTIFICATE
38-
)
39-
SELECT ci.ISSUER_CA_ID,
40-
ca.NAME ISSUER_NAME,
41-
ci.COMMON_NAME,
42-
array_to_string(ci.NAME_VALUES, chr(10)) NAME_VALUE,
43-
ci.ID ID,
44-
le.ENTRY_TIMESTAMP,
45-
ci.NOT_BEFORE,
46-
ci.NOT_AFTER,
47-
ci.SERIAL_NUMBER
48-
FROM ci
49-
LEFT JOIN LATERAL (
50-
SELECT min(ctle.ENTRY_TIMESTAMP) ENTRY_TIMESTAMP
51-
FROM ct_log_entry ctle
52-
WHERE ctle.CERTIFICATE_ID = ci.ID
53-
) le ON TRUE,
54-
ca
55-
WHERE ci.ISSUER_CA_ID = ca.ID
56-
ORDER BY le.ENTRY_TIMESTAMP DESC NULLS LAST
57-
LIMIT %d`
58-
59-
excludeExpired = `AND coalesce(x509_notAfter(cai.CERTIFICATE), 'infinity'::timestamp) >= date_trunc('year', now() AT TIME ZONE 'UTC')
60-
AND x509_notAfter(cai.CERTIFICATE) >= now() AT TIME ZONE 'UTC'`
61-
)
62-
6321
type Repository struct {
6422
db *sql.DB
6523
}
@@ -77,10 +35,10 @@ func (r *Repository) GetCertLogs(domain string, expired bool, limit int) (result
7735
filter := ""
7836

7937
if expired {
80-
filter = excludeExpired
38+
filter = excludeExpiredFilter
8139
}
8240

83-
stmt := fmt.Sprintf(statement, domain, domain, filter, limit)
41+
stmt := fmt.Sprintf(certLogScript, domain, domain, filter, limit)
8442

8543
rows, err := r.db.Query(stmt)
8644
if err != nil {
@@ -129,6 +87,35 @@ func (r *Repository) GetCertLogs(domain string, expired bool, limit int) (result
12987
return res, nil
13088
}
13189

90+
func (r *Repository) GetSubdomains(domain string, expired bool, limit int) (result.SubdomainResult, error) {
91+
filter := ""
92+
93+
if expired {
94+
filter = excludeExpiredFilter
95+
}
96+
97+
stmt := fmt.Sprintf(subdomainScript, domain, domain, filter, limit)
98+
99+
rows, err := r.db.Query(stmt)
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to query row: %s", err)
102+
}
103+
defer rows.Close()
104+
105+
var res result.SubdomainResult
106+
var subdmn sql.NullString
107+
108+
for rows.Next() {
109+
if err = rows.Scan(&subdmn); err != nil {
110+
return nil, fmt.Errorf("failed to scan row: %s", err)
111+
}
112+
113+
res = append(res, result.Subdomain{Name: subdmn.String})
114+
}
115+
116+
return res, nil
117+
}
118+
132119
func (r *Repository) Close() error {
133120
return r.db.Close()
134121
}

repository/sql.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package repository
2+
3+
const (
4+
certLogScript = `WITH ci AS (
5+
SELECT min(sub.CERTIFICATE_ID) ID,
6+
min(sub.ISSUER_CA_ID) ISSUER_CA_ID,
7+
array_agg(DISTINCT sub.NAME_VALUE) NAME_VALUES,
8+
x509_commonName(sub.CERTIFICATE) COMMON_NAME,
9+
x509_notBefore(sub.CERTIFICATE) NOT_BEFORE,
10+
x509_notAfter(sub.CERTIFICATE) NOT_AFTER,
11+
encode(x509_serialNumber(sub.CERTIFICATE), 'hex') SERIAL_NUMBER
12+
FROM (SELECT *
13+
FROM certificate_and_identities cai
14+
WHERE plainto_tsquery('certwatch', '%s') @@ identities(cai.CERTIFICATE)
15+
AND cai.NAME_VALUE ILIKE ('%%' || '%s' || '%%')
16+
%s --filter
17+
LIMIT 10000
18+
) sub
19+
GROUP BY sub.CERTIFICATE
20+
)
21+
SELECT ci.ISSUER_CA_ID,
22+
ca.NAME ISSUER_NAME,
23+
ci.COMMON_NAME,
24+
array_to_string(ci.NAME_VALUES, chr(10)) NAME_VALUE,
25+
ci.ID ID,
26+
le.ENTRY_TIMESTAMP,
27+
ci.NOT_BEFORE,
28+
ci.NOT_AFTER,
29+
ci.SERIAL_NUMBER
30+
FROM ci
31+
LEFT JOIN LATERAL (
32+
SELECT min(ctle.ENTRY_TIMESTAMP) ENTRY_TIMESTAMP
33+
FROM ct_log_entry ctle
34+
WHERE ctle.CERTIFICATE_ID = ci.ID
35+
) le ON TRUE,
36+
ca
37+
WHERE ci.ISSUER_CA_ID = ca.ID
38+
ORDER BY le.ENTRY_TIMESTAMP DESC NULLS LAST
39+
LIMIT %d`
40+
41+
subdomainScript = `SELECT DISTINCT cai.NAME_VALUE
42+
FROM certificate_and_identities cai
43+
WHERE plainto_tsquery('certwatch', '%s') @@ identities(cai.CERTIFICATE)
44+
AND cai.NAME_VALUE ILIKE ('%%' || '%s' || '%%')
45+
%s --filter
46+
LIMIT %d`
47+
48+
excludeExpiredFilter = `AND coalesce(x509_notAfter(cai.CERTIFICATE), 'infinity'::timestamp) >= date_trunc('year', now() AT TIME ZONE 'UTC')
49+
AND x509_notAfter(cai.CERTIFICATE) >= now() AT TIME ZONE 'UTC'`
50+
)

result/result.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package result
2+
3+
type QueryResult interface {
4+
Table() string
5+
JSON() (string, error)
6+
CSV() (string, error)
7+
Size() int
8+
}

result/subodomain.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package result
2+
3+
import (
4+
"bytes"
5+
"encoding/csv"
6+
"encoding/json"
7+
"fmt"
8+
9+
"github.com/olekukonko/tablewriter"
10+
)
11+
12+
type Subdomain struct {
13+
Name string `json:"subdomain"`
14+
}
15+
16+
type SubdomainResult []Subdomain
17+
18+
func (s SubdomainResult) Table() string {
19+
res := new(bytes.Buffer)
20+
table := tablewriter.NewWriter(res)
21+
22+
table.SetHeader([]string{"Subdomains"})
23+
24+
table.SetHeaderColor(tablewriter.Color(tablewriter.FgHiBlueColor))
25+
table.SetColumnColor(tablewriter.Color(tablewriter.FgHiYellowColor))
26+
27+
for _, sub := range s {
28+
table.Append([]string{sub.Name})
29+
}
30+
31+
table.SetRowLine(true)
32+
table.SetRowSeparator("—")
33+
table.Render()
34+
35+
return res.String()
36+
}
37+
38+
func (s SubdomainResult) JSON() (string, error) {
39+
res, err := json.MarshalIndent(s, "", "\t")
40+
if err != nil {
41+
return "", fmt.Errorf("failed to marshal results: %s", err)
42+
}
43+
44+
return string(res), nil
45+
}
46+
47+
func (s SubdomainResult) CSV() (string, error) {
48+
res := new(bytes.Buffer)
49+
w := csv.NewWriter(res)
50+
51+
if err := w.Write([]string{"subdomain"}); err != nil {
52+
return "", fmt.Errorf("failed to write CSV headers: %s", err)
53+
}
54+
55+
for _, sub := range s {
56+
if err := w.Write([]string{sub.Name}); err != nil {
57+
return "", fmt.Errorf("failed to write CSV content: %s", err)
58+
}
59+
}
60+
w.Flush()
61+
62+
return res.String(), nil
63+
}
64+
65+
func (s SubdomainResult) Size() int { return len(s) }

0 commit comments

Comments
 (0)