Files
bucketeer/third_party/certspotter/ctclient/client.go
T
StasonJatham e8f7d6bfdf ctcloud: CertSpotter-backed CT cloud-resource monitor
Single stateless Go process that consumes CT logs via embedded (patched)
CertSpotter internals, classifies cloud/database/storage DNS identifiers
in-process with a zero-allocation classifier, enriches via round-robin DNS
resolvers with CNAME following, and batch-writes useful findings to PostgreSQL.

- third_party/certspotter: 2-hunk IdentifierSink patch, no stdout/JSON/files
- internal/classify: AWS/Azure/GCP/Firebase/Cloudflare R2/DigitalOcean/Supabase/OCI
- internal/dns: sharded bounded TTL cache, multi-resolver load balancing + failover
- internal/pipeline: bounded queues, dedup, direct-priority load shedding
- internal/store: pgx batch writer, Postgres CT checkpoints (no cert contents)
- internal/probe: optional non-destructive reachability checks (off by default)
- Docker: distroless non-root, read-only rootfs, no app volume, resource limits
- tests enforce 0 allocs/op on the classifier and sink hot path
2026-09-22 13:13:10 +02:00

205 lines
6.1 KiB
Go

// Copyright (C) 2025 Opsmate, Inc.
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License, v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// This software is distributed WITHOUT A WARRANTY OF ANY KIND.
// See the Mozilla Public License for details.
// Package ctclient implements a client for monitoring RFC6962 and static-ct-api Certificate Transparency logs
package ctclient
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"time"
"software.sslmate.com/src/certspotter/cttypes"
)
var UserAgent = ""
// Create an HTTP client suitable for communicating with CT logs. dialContext, if non-nil, is used for dialing.
func NewHTTPClient(dialContext func(context.Context, string, string) (net.Conn, error)) *http.Client {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSHandshakeTimeout: 15 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: &tls.Config{
// We have to disable TLS certificate validation because because several logs
// (WoSign, StartCom, GDCA) use certificates that are not widely trusted.
// Since we verify that every response we receive from the log is signed
// by the log's CT public key (either directly, or indirectly via the Merkle Tree),
// TLS certificate validation is not actually necessary. (We don't want to manage
// our own trust store because that adds undesired complexity and would require
// updating should a log ever change to a different CA.)
InsecureSkipVerify: true,
},
DialContext: dialContext,
ForceAttemptHTTP2: true,
},
CheckRedirect: func(*http.Request, []*http.Request) error {
return errors.New("redirects not followed")
},
Timeout: 60 * time.Second,
}
}
var defaultHTTPClient = NewHTTPClient(nil)
// maxResponseBytes is a generous upper bound on the size of an HTTP response
// body that we are willing to buffer in memory.
const maxResponseBytes = 64 << 20 // 64 MiB
// maxErrorResponseBytes is longest error response that we'll read
const maxErrorResponseBytes = 8 << 10 // 8 KiB
// readResponseBody reads a response body, returning an error if the
// body is larger than maxBytes.
func readResponseBody(r io.Reader, maxBytes int64) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("response body exceeds maximum allowed size of %d bytes", maxBytes)
}
return body, nil
}
func get(ctx context.Context, httpClient *http.Client, fullURL string) (io.ReadCloser, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", UserAgent)
if httpClient == nil {
httpClient = defaultHTTPClient
}
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode != 200 {
defer response.Body.Close()
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorResponseBytes))
return nil, fmt.Errorf("Get %q: %s (%s)", fullURL, response.Status, formatResponseBody(responseBody))
}
return response.Body, nil
}
func getBytes(ctx context.Context, httpClient *http.Client, fullURL string) ([]byte, error) {
body, err := get(ctx, httpClient, fullURL)
if err != nil {
return nil, err
}
defer body.Close()
bodyBytes, err := readResponseBody(body, maxResponseBytes)
if err != nil {
return nil, fmt.Errorf("Get %q: %w", fullURL, err)
}
return bodyBytes, nil
}
func getJSON(ctx context.Context, httpClient *http.Client, fullURL string, response any) error {
responseBytes, err := getBytes(ctx, httpClient, fullURL)
if err != nil {
return err
}
if err := json.Unmarshal(responseBytes, response); err != nil {
return fmt.Errorf("Get %q: error parsing response JSON: %w", fullURL, err)
}
return nil
}
func getRoots(ctx context.Context, httpClient *http.Client, logURL *url.URL) ([][]byte, error) {
fullURL := logURL.JoinPath("/ct/v1/get-roots").String()
var parsedResponse struct {
Certificates [][]byte `json:"certificates"`
}
if err := getJSON(ctx, httpClient, fullURL, &parsedResponse); err != nil {
return nil, err
}
return parsedResponse.Certificates, nil
}
func addChainOrPreChain(ctx context.Context, httpClient *http.Client, logURL *url.URL, isPreChain bool, chain [][]byte) (*cttypes.SignedCertificateTimestamp, error) {
var endpoint string
if isPreChain {
endpoint = "/ct/v1/add-pre-chain"
} else {
endpoint = "/ct/v1/add-chain"
}
fullURL := logURL.JoinPath(endpoint).String()
requestBody, err := json.Marshal(struct {
Chain [][]byte `json:"chain"`
}{
Chain: chain,
})
if err != nil {
return nil, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(requestBody))
if err != nil {
return nil, err
}
request.Header.Set("User-Agent", UserAgent)
request.Header.Set("Content-Type", "application/json")
if httpClient == nil {
httpClient = defaultHTTPClient
}
response, err := httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
responseBody, err := readResponseBody(response.Body, maxResponseBytes)
if err != nil {
return nil, fmt.Errorf("Post %q: %w", fullURL, err)
}
if response.StatusCode != 200 {
return nil, fmt.Errorf("Post %q: %s (%s)", fullURL, response.Status, formatResponseBody(responseBody))
}
sct := new(cttypes.SignedCertificateTimestamp)
if err := json.Unmarshal(responseBody, sct); err != nil {
return nil, fmt.Errorf("Post %q: error parsing response JSON: %w", fullURL, err)
}
return sct, nil
}
func formatResponseBody(body []byte) string {
const maxLen = 200
body = bytes.TrimSpace(body)
if len(body) > maxLen {
return strconv.QuoteToASCII(string(body[:maxLen])) + "..."
} else {
return strconv.QuoteToASCII(string(body))
}
}