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
1619 lines
26 KiB
Markdown
1619 lines
26 KiB
Markdown
# Goal
|
|
|
|
Build a production-ready, extremely resource-efficient Certificate Transparency monitoring service in **Go**, running entirely in **Docker**, using **CertSpotter's existing CT ingestion/parsing implementation**.
|
|
|
|
The purpose is to continuously consume CT logs, identify cloud storage/database/data-service resources, enrich candidates via DNS/CNAME resolution, optionally perform minimal safe reachability checks, and persist only useful enriched findings into PostgreSQL.
|
|
|
|
The primary design objective is:
|
|
|
|
> **Maximum throughput and minimum CPU, memory, allocation, disk, serialization, and IPC overhead.**
|
|
|
|
Do not build another generic CertStream consumer. Reuse CertSpotter's CT downloading, validation, parsing, and log-monitoring logic.
|
|
|
|
---
|
|
|
|
# Critical architectural requirement
|
|
|
|
DO NOT use CertSpotter like this:
|
|
|
|
```text
|
|
certspotter
|
|
→ stdout
|
|
→ JSON
|
|
→ pipe
|
|
→ parser
|
|
```
|
|
|
|
DO NOT use:
|
|
|
|
```text
|
|
CertSpotter
|
|
→ JSON_FILENAME
|
|
→ filesystem
|
|
→ hook
|
|
→ subprocess
|
|
→ parser
|
|
```
|
|
|
|
Instead modify/integrate CertSpotter so parsed certificate DNS identifiers are handed directly to our Go code **inside the same process**.
|
|
|
|
Target architecture:
|
|
|
|
```text
|
|
Certificate Transparency Logs
|
|
│
|
|
▼
|
|
CertSpotter internals
|
|
fetch / verify / parse
|
|
│
|
|
▼
|
|
DNS identifiers
|
|
│
|
|
▼
|
|
FAST CLASSIFIER
|
|
│
|
|
┌─────┴─────┐
|
|
│ │
|
|
direct cloud normal
|
|
resource hostname
|
|
│ │
|
|
│ ▼
|
|
│ DNS/CNAME enrichment
|
|
│ │
|
|
│ ▼
|
|
│ classify CNAME targets
|
|
│ │
|
|
└─────┬─────┘
|
|
▼
|
|
dedup/enrichment
|
|
│
|
|
▼
|
|
optional safe reachability
|
|
│
|
|
▼
|
|
batch DB writer
|
|
│
|
|
▼
|
|
PostgreSQL
|
|
```
|
|
|
|
Everything between certificate parsing and candidate classification must remain in memory.
|
|
|
|
---
|
|
|
|
# CertSpotter integration
|
|
|
|
Use the upstream CertSpotter Go code as the CT engine.
|
|
|
|
DO NOT reimplement CT log handling unless absolutely required.
|
|
|
|
Inspect the current CertSpotter source before modifying anything.
|
|
|
|
Create the **smallest possible maintainable patch** that exposes parsed certificate identifiers to our code.
|
|
|
|
Preferred abstraction:
|
|
|
|
```go
|
|
type IdentifierSink interface {
|
|
HandleIdentifier(ctx context.Context, identifier Identifier)
|
|
}
|
|
```
|
|
|
|
or an equally efficient callback.
|
|
|
|
The goal is:
|
|
|
|
```text
|
|
CertSpotter parsed certificate
|
|
↓
|
|
direct Go function call
|
|
↓
|
|
classifier
|
|
```
|
|
|
|
There must be:
|
|
|
|
```text
|
|
NO stdout handoff
|
|
NO JSON handoff
|
|
NO hook scripts
|
|
NO subprocesses
|
|
NO certificate files
|
|
NO JSON files
|
|
NO temporary certificate files
|
|
```
|
|
|
|
Certificate DER/metadata may exist temporarily in memory while CertSpotter processes it, but must never be persisted.
|
|
|
|
Do not use CertSpotter's normal certificate/output persistence functionality.
|
|
|
|
Preserve upstream licensing/notices and keep our CertSpotter changes isolated so upstream changes can later be rebased.
|
|
|
|
---
|
|
|
|
# Runtime filesystem policy
|
|
|
|
The application must not persist certificates, SAN lists, JSON events, queues, enrichment payloads, or findings to the filesystem.
|
|
|
|
No filesystem-based spool.
|
|
|
|
No file-based queue.
|
|
|
|
No external configuration files.
|
|
|
|
No pattern YAML at runtime.
|
|
|
|
No file uploads or file ingestion API.
|
|
|
|
Configuration comes from environment variables.
|
|
|
|
Provider patterns should be compiled into the binary.
|
|
|
|
The Docker application container should require **no persistent volume**.
|
|
|
|
Run with a read-only root filesystem.
|
|
|
|
---
|
|
|
|
# CT checkpoint persistence
|
|
|
|
We still need restart-safe CT monitoring.
|
|
|
|
Do NOT persist CertSpotter checkpoint/state files locally.
|
|
|
|
Store only the minimum required CT progress/checkpoint metadata in PostgreSQL.
|
|
|
|
Example conceptual table:
|
|
|
|
```text
|
|
ct_checkpoints
|
|
|
|
log_id
|
|
tree_size
|
|
position / index
|
|
updated_at
|
|
```
|
|
|
|
Inspect CertSpotter's real state model and preserve whatever fields are actually required.
|
|
|
|
If CertSpotter currently assumes filesystem-backed state, introduce a minimal storage abstraction:
|
|
|
|
```go
|
|
type StateStore interface {
|
|
Load(...)
|
|
Save(...)
|
|
}
|
|
```
|
|
|
|
Implement:
|
|
|
|
```text
|
|
PostgresStateStore
|
|
```
|
|
|
|
Do not store certificate contents in it.
|
|
|
|
---
|
|
|
|
# Hot path requirements
|
|
|
|
The certificate identifier classifier is the most performance-sensitive part.
|
|
|
|
It must have:
|
|
|
|
```text
|
|
NO regexp
|
|
NO JSON
|
|
NO reflection
|
|
NO interface{} maps
|
|
NO database access
|
|
NO DNS
|
|
NO HTTP
|
|
NO filesystem
|
|
NO subprocess
|
|
NO logging per identifier
|
|
NO unnecessary heap allocations
|
|
NO string splitting into []string
|
|
NO fmt.Sprintf
|
|
```
|
|
|
|
Target:
|
|
|
|
```text
|
|
CertSpotter DNS identifier
|
|
↓
|
|
normalize
|
|
↓
|
|
cheap provider suffix rejection
|
|
↓
|
|
manual label/suffix classifier
|
|
↓
|
|
small struct
|
|
↓
|
|
bounded channel/ring
|
|
```
|
|
|
|
Use:
|
|
|
|
```go
|
|
strings.HasSuffix
|
|
strings.HasPrefix
|
|
strings.Index / LastIndexByte
|
|
direct byte/string comparisons
|
|
manual backwards DNS-label scanning
|
|
small uint8 enums
|
|
```
|
|
|
|
Do not use regex.
|
|
|
|
Do not use Hyperscan.
|
|
|
|
Do not use Aho-Corasick unless benchmarking proves it faster for this actual tiny pattern set.
|
|
|
|
For this number of signatures, direct suffix/label matching should be faster and simpler.
|
|
|
|
---
|
|
|
|
# Allocation requirements
|
|
|
|
The classifier itself should ideally achieve:
|
|
|
|
```text
|
|
0 allocs/op
|
|
```
|
|
|
|
after receiving the hostname string.
|
|
|
|
Avoid lowercasing every hostname if unnecessary.
|
|
|
|
Fast-path ASCII lowercase names directly.
|
|
|
|
Only normalize when uppercase characters are actually present.
|
|
|
|
Avoid:
|
|
|
|
```go
|
|
strings.Split()
|
|
```
|
|
|
|
because it allocates.
|
|
|
|
Parse DNS labels by indexes.
|
|
|
|
---
|
|
|
|
# Core data structures
|
|
|
|
Use compact enums.
|
|
|
|
Example:
|
|
|
|
```go
|
|
type Provider uint8
|
|
|
|
const (
|
|
ProviderUnknown Provider = iota
|
|
ProviderAWS
|
|
ProviderAzure
|
|
ProviderGCP
|
|
ProviderCloudflare
|
|
ProviderDigitalOcean
|
|
ProviderSupabase
|
|
ProviderOracle
|
|
)
|
|
|
|
type Service uint8
|
|
|
|
const (
|
|
ServiceUnknown Service = iota
|
|
|
|
ServiceObjectStorage
|
|
ServiceS3
|
|
ServiceBlobStorage
|
|
ServiceDatabase
|
|
ServicePostgres
|
|
ServiceMySQL
|
|
ServiceRDS
|
|
ServiceDocumentDB
|
|
ServiceRedshift
|
|
ServiceOpenSearch
|
|
ServiceNeptune
|
|
ServiceRedis
|
|
ServiceMemoryDB
|
|
ServiceCosmosDB
|
|
ServiceFirebaseDB
|
|
)
|
|
```
|
|
|
|
A hot-path match should look roughly like:
|
|
|
|
```go
|
|
type Candidate struct {
|
|
Hostname string
|
|
Provider Provider
|
|
Service Service
|
|
Source Source
|
|
}
|
|
```
|
|
|
|
Keep hot-path structs small.
|
|
|
|
Do not attach large certificate structures to downstream jobs.
|
|
|
|
---
|
|
|
|
# Source tracking
|
|
|
|
We need to distinguish:
|
|
|
|
```text
|
|
DIRECT_CT
|
|
CNAME
|
|
DNS_ALIAS
|
|
```
|
|
|
|
Example:
|
|
|
|
```text
|
|
assets.company.com
|
|
CNAME
|
|
company-assets.s3.eu-central-1.amazonaws.com
|
|
```
|
|
|
|
should produce an enriched record containing both:
|
|
|
|
```text
|
|
original_hostname = assets.company.com
|
|
provider_hostname = company-assets.s3.eu-central-1.amazonaws.com
|
|
source = CNAME
|
|
provider = AWS
|
|
service = S3
|
|
```
|
|
|
|
---
|
|
|
|
# Provider classification
|
|
|
|
Implement dedicated provider classifiers.
|
|
|
|
## AWS
|
|
|
|
Recognize resource-specific forms including:
|
|
|
|
```text
|
|
# S3
|
|
<bucket>.s3.amazonaws.com
|
|
<bucket>.s3.<region>.amazonaws.com
|
|
<bucket>.s3.dualstack.<region>.amazonaws.com
|
|
<bucket>.s3-website.<region>.amazonaws.com
|
|
<bucket>.s3-website-<region>.amazonaws.com
|
|
|
|
# Access Points
|
|
<access-point>-<account>.s3-accesspoint.<region>.amazonaws.com
|
|
*.s3-accesspoint.dualstack.<region>.amazonaws.com
|
|
*.s3-accesspoint-fips.<region>.amazonaws.com
|
|
|
|
# Multi-region
|
|
*.mrap.accesspoint.s3-global.amazonaws.com
|
|
|
|
# S3 Express
|
|
*.s3express-<zone>.<region>.amazonaws.com
|
|
|
|
# RDS / Aurora / RDS Proxy
|
|
*.rds.amazonaws.com
|
|
|
|
# DocumentDB
|
|
*.docdb.amazonaws.com
|
|
|
|
# Redshift
|
|
*.redshift.amazonaws.com
|
|
*.redshift-serverless.amazonaws.com
|
|
|
|
# OpenSearch
|
|
search-*.es.amazonaws.com
|
|
vpc-*.es.amazonaws.com
|
|
|
|
# Neptune
|
|
*.neptune.amazonaws.com
|
|
|
|
# MemoryDB
|
|
*.memorydb.<region>.amazonaws.com
|
|
|
|
# ElastiCache
|
|
*.cache.amazonaws.com
|
|
```
|
|
|
|
Further classify AWS subtypes where cheap.
|
|
|
|
Examples:
|
|
|
|
```text
|
|
cluster-* → Aurora/cluster
|
|
cluster-ro-* → reader
|
|
proxy-* → RDS proxy
|
|
|
|
search-* → OpenSearch public endpoint form
|
|
vpc-* → OpenSearch VPC endpoint form
|
|
```
|
|
|
|
Do not treat generic AWS service API endpoints as customer resources.
|
|
|
|
Examples to reject:
|
|
|
|
```text
|
|
s3.eu-central-1.amazonaws.com
|
|
dynamodb.eu-central-1.amazonaws.com
|
|
redshift.eu-central-1.amazonaws.com
|
|
```
|
|
|
|
---
|
|
|
|
# Azure
|
|
|
|
Recognize:
|
|
|
|
```text
|
|
*.blob.core.windows.net
|
|
*.dfs.core.windows.net
|
|
*.file.core.windows.net
|
|
*.queue.core.windows.net
|
|
*.table.core.windows.net
|
|
*.web.core.windows.net
|
|
|
|
*.database.windows.net
|
|
*.postgres.database.azure.com
|
|
*.mysql.database.azure.com
|
|
|
|
*.documents.azure.com
|
|
*.mongo.cosmos.azure.com
|
|
*.cassandra.cosmos.azure.com
|
|
*.gremlin.cosmos.azure.com
|
|
|
|
*.redis.cache.windows.net
|
|
*.redis.azure.net
|
|
|
|
*.search.windows.net
|
|
```
|
|
|
|
Require a customer/resource-specific prefix.
|
|
|
|
Do not classify bare provider service endpoints as resource findings.
|
|
|
|
---
|
|
|
|
# Google Cloud / Firebase
|
|
|
|
Recognize:
|
|
|
|
```text
|
|
<bucket>.storage.googleapis.com
|
|
|
|
<database>.firebaseio.com
|
|
|
|
<database>.<region>.firebasedatabase.app
|
|
|
|
*.appspot.com
|
|
```
|
|
|
|
Do not classify:
|
|
|
|
```text
|
|
storage.googleapis.com
|
|
```
|
|
|
|
itself as a resource.
|
|
|
|
---
|
|
|
|
# Cloudflare
|
|
|
|
Recognize:
|
|
|
|
```text
|
|
<account-id>.r2.cloudflarestorage.com
|
|
*.r2.dev
|
|
```
|
|
|
|
Classify:
|
|
|
|
```text
|
|
r2.cloudflarestorage.com → Cloudflare R2 API/resource endpoint
|
|
r2.dev → public R2 development/public endpoint
|
|
```
|
|
|
|
Remember that arbitrary custom R2 domains will only be discovered through DNS/CNAME enrichment.
|
|
|
|
---
|
|
|
|
# DigitalOcean
|
|
|
|
Recognize:
|
|
|
|
```text
|
|
<bucket>.<region>.digitaloceanspaces.com
|
|
<bucket>.<region>.cdn.digitaloceanspaces.com
|
|
|
|
*.db.ondigitalocean.com
|
|
```
|
|
|
|
Do not treat:
|
|
|
|
```text
|
|
<region>.digitaloceanspaces.com
|
|
```
|
|
|
|
as a customer bucket.
|
|
|
|
---
|
|
|
|
# Supabase
|
|
|
|
Recognize:
|
|
|
|
```text
|
|
<project-ref>.supabase.co
|
|
db.<project-ref>.supabase.co
|
|
aws-<index>-<region>.pooler.supabase.com
|
|
```
|
|
|
|
Classify direct PostgreSQL separately where possible:
|
|
|
|
```text
|
|
db.<project-ref>.supabase.co
|
|
```
|
|
|
|
---
|
|
|
|
# Oracle Cloud / OCI
|
|
|
|
Recognize:
|
|
|
|
```text
|
|
<namespace>.objectstorage.<region>.oci.customer-oci.com
|
|
<namespace>.swiftobjectstorage.<region>.oci.customer-oci.com
|
|
```
|
|
|
|
Also recognize generic OCI storage service endpoints but mark them:
|
|
|
|
```text
|
|
resource_specific = false
|
|
```
|
|
|
|
Examples:
|
|
|
|
```text
|
|
objectstorage.<region>.oraclecloud.com
|
|
swiftobjectstorage.<region>.oraclecloud.com
|
|
```
|
|
|
|
These should normally not become high-value findings because the bucket name exists in the path rather than the hostname.
|
|
|
|
---
|
|
|
|
# Classification quality
|
|
|
|
Every matcher must determine:
|
|
|
|
```go
|
|
type Match struct {
|
|
Provider Provider
|
|
Service Service
|
|
ResourceSpecific bool
|
|
PublicSemantic PublicSemantic
|
|
}
|
|
```
|
|
|
|
`PublicSemantic` should distinguish cases where hostname format itself carries useful public/private information.
|
|
|
|
Example:
|
|
|
|
```text
|
|
AWS search-*.es.amazonaws.com
|
|
→ public network endpoint form
|
|
|
|
AWS vpc-*.es.amazonaws.com
|
|
→ VPC endpoint form
|
|
|
|
Cloudflare *.r2.dev
|
|
→ explicitly public URL form
|
|
```
|
|
|
|
Do NOT equate:
|
|
|
|
```text
|
|
resource exists
|
|
```
|
|
|
|
with:
|
|
|
|
```text
|
|
data is publicly accessible
|
|
```
|
|
|
|
---
|
|
|
|
# Direct-match path
|
|
|
|
Every SAN/DNS identifier received from CertSpotter should first be passed through the provider classifier.
|
|
|
|
Direct provider matches should be extremely cheap:
|
|
|
|
```text
|
|
SAN
|
|
↓
|
|
provider suffix rejection
|
|
↓
|
|
specific classifier
|
|
↓
|
|
Candidate
|
|
```
|
|
|
|
Direct matches go immediately into enrichment.
|
|
|
|
---
|
|
|
|
# CNAME discovery
|
|
|
|
Provider-native names alone are insufficient because certificates frequently contain custom hostnames:
|
|
|
|
```text
|
|
files.example.com
|
|
↓ CNAME
|
|
foo.blob.core.windows.net
|
|
```
|
|
|
|
or:
|
|
|
|
```text
|
|
db.example.com
|
|
↓ CNAME
|
|
prod.abc.eu-central-1.rds.amazonaws.com
|
|
```
|
|
|
|
Therefore also support DNS enrichment of CT hostnames.
|
|
|
|
Pipeline:
|
|
|
|
```text
|
|
CT hostname
|
|
↓
|
|
dedupe/cache
|
|
↓
|
|
DNS
|
|
↓
|
|
follow CNAME chain
|
|
↓
|
|
run classifier against EVERY CNAME target
|
|
↓
|
|
candidate
|
|
```
|
|
|
|
Follow CNAME chains with:
|
|
|
|
```text
|
|
maximum depth: configurable
|
|
default: 8
|
|
```
|
|
|
|
Detect loops.
|
|
|
|
---
|
|
|
|
# DNS resolver
|
|
|
|
DNS enrichment must be optimized heavily.
|
|
|
|
Use a fixed worker pool.
|
|
|
|
Do not spawn unbounded goroutines.
|
|
|
|
Use either Go's resolver or `miekg/dns`, whichever benchmarks better for our workload.
|
|
|
|
Requirements:
|
|
|
|
```text
|
|
connection reuse where applicable
|
|
bounded concurrency
|
|
context cancellation
|
|
timeouts
|
|
positive TTL cache
|
|
negative TTL cache
|
|
CNAME cache
|
|
duplicate suppression
|
|
```
|
|
|
|
The DNS cache is memory-only.
|
|
|
|
No persistence.
|
|
|
|
Use a memory-bounded implementation.
|
|
|
|
It must have a configurable maximum number of entries.
|
|
|
|
If implementing a custom sharded TTL cache is simpler/faster than bringing in a heavy dependency, prefer the custom implementation.
|
|
|
|
---
|
|
|
|
# Backpressure
|
|
|
|
Do not allow enrichment to stall CertSpotter indefinitely.
|
|
|
|
Use bounded queues.
|
|
|
|
Separate:
|
|
|
|
```text
|
|
direct-match queue
|
|
|
|
DNS-enrichment queue
|
|
|
|
DB-write queue
|
|
```
|
|
|
|
Expose counters for:
|
|
|
|
```text
|
|
queue depth
|
|
queue full events
|
|
dropped optional DNS enrichments
|
|
direct matches
|
|
CNAME matches
|
|
DNS errors
|
|
DB errors
|
|
```
|
|
|
|
Direct provider matches should receive higher priority than speculative CNAME enrichment.
|
|
|
|
If load shedding becomes necessary, drop optional CNAME enrichment before dropping direct cloud-resource matches.
|
|
|
|
---
|
|
|
|
# Deduplication
|
|
|
|
Certificates and CT logs produce duplicates.
|
|
|
|
Perform cheap in-memory dedup before enrichment.
|
|
|
|
Key approximately:
|
|
|
|
```text
|
|
normalized hostname
|
|
```
|
|
|
|
After classification:
|
|
|
|
```text
|
|
provider + service + provider_hostname
|
|
```
|
|
|
|
Use memory-bounded TTL caches.
|
|
|
|
Do not keep an unbounded map.
|
|
|
|
Database uniqueness is the final deduplication layer.
|
|
|
|
---
|
|
|
|
# Enrichment model
|
|
|
|
For useful candidates collect, when cheap:
|
|
|
|
```text
|
|
original CT hostname
|
|
provider hostname
|
|
provider
|
|
service
|
|
region if extractable
|
|
resource identifier if extractable
|
|
|
|
first seen
|
|
last seen
|
|
|
|
CNAME chain
|
|
|
|
A records
|
|
AAAA records
|
|
|
|
public DNS resolution boolean
|
|
|
|
source:
|
|
direct_ct
|
|
cname
|
|
|
|
resource_specific
|
|
|
|
public_semantic
|
|
```
|
|
|
|
Do not store the full certificate unless there is a concrete future requirement.
|
|
|
|
Optional useful certificate metadata:
|
|
|
|
```text
|
|
certificate fingerprint
|
|
not_before
|
|
not_after
|
|
issuer CN
|
|
CT log ID
|
|
```
|
|
|
|
Only include these if CertSpotter already has them available without additional expensive parsing/copying.
|
|
|
|
---
|
|
|
|
# Safe reachability checks
|
|
|
|
Make active network checks optional:
|
|
|
|
```text
|
|
ACTIVE_PROBING=false
|
|
```
|
|
|
|
by default.
|
|
|
|
When enabled, only perform minimal non-destructive checks on infrastructure the operator is authorized to assess.
|
|
|
|
Allowed checks:
|
|
|
|
```text
|
|
DNS resolution
|
|
TCP connect
|
|
TLS handshake
|
|
HTTP HEAD
|
|
HTTP GET /
|
|
```
|
|
|
|
No authentication attempts.
|
|
|
|
No password guessing.
|
|
|
|
No object downloading.
|
|
|
|
No mutation.
|
|
|
|
No PUT/POST/DELETE.
|
|
|
|
No database queries.
|
|
|
|
No exploitation.
|
|
|
|
No brute force.
|
|
|
|
For HTTP-based storage endpoints, use response status, headers, and provider error codes to classify:
|
|
|
|
```text
|
|
reachable
|
|
not_found
|
|
auth_required
|
|
public_read
|
|
unknown
|
|
```
|
|
|
|
Do not claim something is "exposed" merely because its hostname exists.
|
|
|
|
---
|
|
|
|
# Exposure state
|
|
|
|
Use an explicit enum:
|
|
|
|
```go
|
|
type ExposureState uint8
|
|
|
|
const (
|
|
ExposureUnknown ExposureState = iota
|
|
ExposurePublicDNS
|
|
ExposureReachable
|
|
ExposureAuthRequired
|
|
ExposurePublicRead
|
|
ExposurePrivate
|
|
ExposureNotFound
|
|
)
|
|
```
|
|
|
|
Keep factual observations separate from conclusions.
|
|
|
|
---
|
|
|
|
# PostgreSQL
|
|
|
|
Use PostgreSQL with:
|
|
|
|
```text
|
|
pgx/v5
|
|
pgxpool
|
|
```
|
|
|
|
Do not write one row per network event synchronously.
|
|
|
|
Use an asynchronous bounded DB writer with batching.
|
|
|
|
Use `COPY FROM` or efficient pgx batches where appropriate.
|
|
|
|
Suggested schema:
|
|
|
|
```text
|
|
cloud_assets
|
|
------------
|
|
id
|
|
provider
|
|
service
|
|
hostname
|
|
provider_hostname
|
|
resource_identifier
|
|
region
|
|
resource_specific
|
|
public_semantic
|
|
first_seen
|
|
last_seen
|
|
exposure_state
|
|
|
|
UNIQUE(provider, service, provider_hostname)
|
|
```
|
|
|
|
and:
|
|
|
|
```text
|
|
observations
|
|
------------
|
|
asset_id
|
|
observed_at
|
|
source_hostname
|
|
source_type
|
|
cname_chain
|
|
ipv4
|
|
ipv6
|
|
cert_fingerprint
|
|
ct_log_id
|
|
```
|
|
|
|
Exact schema may be improved if benchmarking/query requirements justify it.
|
|
|
|
Do not store observations that provide no useful information.
|
|
|
|
Avoid unnecessary JSONB if typed PostgreSQL arrays/columns are more efficient.
|
|
|
|
---
|
|
|
|
# Checkpoints
|
|
|
|
Separate CT processing state from cloud-asset data.
|
|
|
|
Example:
|
|
|
|
```text
|
|
ct_checkpoints
|
|
--------------
|
|
log_id PRIMARY KEY
|
|
state
|
|
updated_at
|
|
```
|
|
|
|
Persist only enough to resume monitoring reliably.
|
|
|
|
Batch/debounce checkpoint writes rather than writing on every certificate if CertSpotter semantics permit.
|
|
|
|
Correctness must be preserved.
|
|
|
|
---
|
|
|
|
# Logging
|
|
|
|
Logging must never be on the certificate hot path.
|
|
|
|
Do not log every certificate.
|
|
|
|
Do not log every SAN.
|
|
|
|
Do not log every rejected hostname.
|
|
|
|
Log:
|
|
|
|
```text
|
|
startup
|
|
shutdown
|
|
fatal errors
|
|
persistent DB errors
|
|
periodic aggregated statistics
|
|
```
|
|
|
|
Default periodic stats interval:
|
|
|
|
```text
|
|
60 seconds
|
|
```
|
|
|
|
Use structured logging but keep it lightweight.
|
|
|
|
---
|
|
|
|
# Metrics
|
|
|
|
Expose lightweight Prometheus-compatible metrics on an optional HTTP listener.
|
|
|
|
At minimum:
|
|
|
|
```text
|
|
ct_certificates_total
|
|
ct_identifiers_total
|
|
|
|
classifier_direct_matches_total
|
|
classifier_cname_matches_total
|
|
|
|
dns_queries_total
|
|
dns_cache_hits_total
|
|
dns_errors_total
|
|
|
|
candidates_total
|
|
|
|
db_rows_total
|
|
db_errors_total
|
|
|
|
queue_depth
|
|
queue_drops_total
|
|
|
|
process memory
|
|
goroutines
|
|
```
|
|
|
|
Do not add expensive high-cardinality labels.
|
|
|
|
Never label metrics by hostname.
|
|
|
|
---
|
|
|
|
# Health
|
|
|
|
Provide:
|
|
|
|
```text
|
|
GET /healthz
|
|
GET /readyz
|
|
```
|
|
|
|
`readyz` should verify:
|
|
|
|
```text
|
|
CT monitor initialized
|
|
database reachable
|
|
critical worker pipelines running
|
|
```
|
|
|
|
---
|
|
|
|
# Configuration
|
|
|
|
Environment variables only.
|
|
|
|
Suggested configuration:
|
|
|
|
```text
|
|
DATABASE_URL=
|
|
|
|
DNS_WORKERS=
|
|
DNS_TIMEOUT=
|
|
DNS_CACHE_SIZE=
|
|
DNS_MAX_CNAME_DEPTH=
|
|
|
|
DIRECT_QUEUE_SIZE=
|
|
DNS_QUEUE_SIZE=
|
|
DB_QUEUE_SIZE=
|
|
|
|
DB_BATCH_SIZE=
|
|
DB_FLUSH_INTERVAL=
|
|
|
|
ACTIVE_PROBING=false
|
|
PROBE_WORKERS=
|
|
PROBE_TIMEOUT=
|
|
|
|
METRICS_ADDR=:9090
|
|
LOG_LEVEL=info
|
|
STATS_INTERVAL=60s
|
|
```
|
|
|
|
Provide sensible defaults.
|
|
|
|
Validate configuration on startup.
|
|
|
|
---
|
|
|
|
# Docker
|
|
|
|
Create a production Docker setup.
|
|
|
|
Use a multi-stage Dockerfile.
|
|
|
|
Build a static/minimal Go binary where possible.
|
|
|
|
Final image should be minimal, for example distroless or equivalent.
|
|
|
|
Run as non-root.
|
|
|
|
Requirements:
|
|
|
|
```text
|
|
read-only root filesystem
|
|
no application volume
|
|
no CertSpotter volume
|
|
no certificate storage
|
|
no writable application directory
|
|
```
|
|
|
|
Use:
|
|
|
|
```text
|
|
tmpfs
|
|
```
|
|
|
|
only if absolutely unavoidable; preferably the application should not require it.
|
|
|
|
Provide:
|
|
|
|
```text
|
|
Dockerfile
|
|
docker-compose.yml
|
|
```
|
|
|
|
Docker Compose should contain:
|
|
|
|
```text
|
|
ctcloud
|
|
postgres
|
|
```
|
|
|
|
The application itself must remain stateless apart from PostgreSQL.
|
|
|
|
---
|
|
|
|
# Code organization
|
|
|
|
Keep the project simple.
|
|
|
|
Suggested layout:
|
|
|
|
```text
|
|
cmd/ctcloud/
|
|
main.go
|
|
|
|
internal/
|
|
certspotter/
|
|
classify/
|
|
classify.go
|
|
aws.go
|
|
azure.go
|
|
gcp.go
|
|
cloudflare.go
|
|
digitalocean.go
|
|
supabase.go
|
|
oracle.go
|
|
|
|
dns/
|
|
probe/
|
|
pipeline/
|
|
store/
|
|
metrics/
|
|
config/
|
|
model/
|
|
|
|
migrations/
|
|
Dockerfile
|
|
docker-compose.yml
|
|
README.md
|
|
ARCHITECTURE.md
|
|
```
|
|
|
|
If CertSpotter is vendored/forked, keep upstream changes clearly separated.
|
|
|
|
Do not introduce microservices.
|
|
|
|
This should be **one Go application process** plus PostgreSQL.
|
|
|
|
No Kafka.
|
|
|
|
No Redis.
|
|
|
|
No RabbitMQ.
|
|
|
|
No external queue.
|
|
|
|
No Elasticsearch.
|
|
|
|
No unnecessary infrastructure.
|
|
|
|
---
|
|
|
|
# Performance strategy
|
|
|
|
Optimize in this order:
|
|
|
|
1. eliminate I/O from hot path
|
|
2. eliminate serialization
|
|
3. eliminate process boundaries
|
|
4. eliminate allocations
|
|
5. cheap provider rejection
|
|
6. cheap suffix/label matching
|
|
7. bounded worker pools
|
|
8. DNS caching
|
|
9. deduplication before enrichment
|
|
10. batched DB writes
|
|
|
|
Do not prematurely implement complex SIMD/native libraries unless benchmarks demonstrate an actual bottleneck.
|
|
|
|
---
|
|
|
|
# Benchmarks
|
|
|
|
Add benchmarks for classification.
|
|
|
|
Example:
|
|
|
|
```go
|
|
func BenchmarkClassifyAWS(b *testing.B)
|
|
func BenchmarkClassifyAzure(b *testing.B)
|
|
func BenchmarkClassifyMixed(b *testing.B)
|
|
func BenchmarkRejectNormalDomain(b *testing.B)
|
|
```
|
|
|
|
Benchmark realistic input:
|
|
|
|
```text
|
|
www.example.com
|
|
foo.bar.example.org
|
|
bucket.s3.eu-central-1.amazonaws.com
|
|
foo.blob.core.windows.net
|
|
db.example.supabase.co
|
|
project.firebaseio.com
|
|
search-example-abc.eu-west-1.es.amazonaws.com
|
|
```
|
|
|
|
Important metric:
|
|
|
|
```text
|
|
allocs/op
|
|
```
|
|
|
|
Classifier target:
|
|
|
|
```text
|
|
0 allocs/op
|
|
```
|
|
|
|
Do not make CI depend on an absolute nanosecond threshold because runner performance varies.
|
|
|
|
Report benchmark numbers in README.
|
|
|
|
---
|
|
|
|
# Tests
|
|
|
|
Implement table-driven tests covering every provider format.
|
|
|
|
Test:
|
|
|
|
```text
|
|
positive resource matches
|
|
generic provider endpoints rejected
|
|
uppercase handling
|
|
trailing dot
|
|
wildcards
|
|
invalid hostnames
|
|
very long hostnames
|
|
CNAME chains
|
|
CNAME loops
|
|
duplicate events
|
|
IPv4/IPv6
|
|
context cancellation
|
|
database retries
|
|
queue saturation
|
|
```
|
|
|
|
Particularly ensure these DO NOT produce resource findings:
|
|
|
|
```text
|
|
storage.googleapis.com
|
|
s3.eu-central-1.amazonaws.com
|
|
redshift.eu-central-1.amazonaws.com
|
|
fra1.digitaloceanspaces.com
|
|
```
|
|
|
|
while:
|
|
|
|
```text
|
|
bucket.storage.googleapis.com
|
|
bucket.s3.eu-central-1.amazonaws.com
|
|
cluster.id.eu-central-1.redshift.amazonaws.com
|
|
bucket.fra1.digitaloceanspaces.com
|
|
```
|
|
|
|
do.
|
|
|
|
---
|
|
|
|
# Wildcards
|
|
|
|
Handle certificate wildcards carefully.
|
|
|
|
Example:
|
|
|
|
```text
|
|
*.example.com
|
|
```
|
|
|
|
must not become a literal DNS query.
|
|
|
|
Do not interpret provider-wide wildcard certificates such as:
|
|
|
|
```text
|
|
*.blob.core.windows.net
|
|
```
|
|
|
|
as discovered customer resources.
|
|
|
|
Track wildcard certificates separately or ignore them for resource-specific classification unless a concrete hostname can be derived from another SAN.
|
|
|
|
---
|
|
|
|
# Graceful shutdown
|
|
|
|
Use contexts throughout.
|
|
|
|
On SIGTERM:
|
|
|
|
```text
|
|
stop accepting new enrichment
|
|
finish bounded in-flight enrichment
|
|
flush DB batch
|
|
save CT checkpoint
|
|
close DB
|
|
exit
|
|
```
|
|
|
|
Shutdown must have a maximum timeout.
|
|
|
|
Docker should be able to stop the container cleanly.
|
|
|
|
---
|
|
|
|
# Failure handling
|
|
|
|
The program must survive transient:
|
|
|
|
```text
|
|
DNS failures
|
|
network timeouts
|
|
CT connection failures
|
|
PostgreSQL connection resets
|
|
```
|
|
|
|
with bounded exponential backoff and jitter.
|
|
|
|
Never retry in a tight loop.
|
|
|
|
If PostgreSQL remains unavailable and queues approach capacity, prefer controlled backpressure/load shedding instead of unbounded memory growth.
|
|
|
|
---
|
|
|
|
# Security
|
|
|
|
Run as non-root.
|
|
|
|
No shell needed in final container.
|
|
|
|
No exposed debug endpoints.
|
|
|
|
Set HTTP server timeouts.
|
|
|
|
Validate all hostname lengths.
|
|
|
|
Treat all CT data as untrusted input.
|
|
|
|
Never execute certificate-controlled values.
|
|
|
|
Never construct shell commands.
|
|
|
|
Use parameterized SQL only.
|
|
|
|
---
|
|
|
|
# README
|
|
|
|
Document:
|
|
|
|
```text
|
|
what the application does
|
|
architecture
|
|
why CertSpotter is embedded/patched
|
|
why stdout/hooks/files are deliberately avoided
|
|
supported provider patterns
|
|
configuration
|
|
Docker startup
|
|
database schema
|
|
performance characteristics
|
|
benchmark results
|
|
limitations
|
|
```
|
|
|
|
Clearly explain:
|
|
|
|
```text
|
|
A detected provider endpoint does NOT automatically mean
|
|
the underlying data is publicly accessible.
|
|
```
|
|
|
|
---
|
|
|
|
# ARCHITECTURE.md
|
|
|
|
Include a concise data-flow diagram:
|
|
|
|
```text
|
|
CT
|
|
↓
|
|
CertSpotter
|
|
↓
|
|
Identifier callback
|
|
↓
|
|
Direct classifier ───────────────┐
|
|
↓ non-match │
|
|
DNS/CNAME │
|
|
↓ │
|
|
Provider classifier │
|
|
└───────────────┬───────────────┘
|
|
↓
|
|
dedupe
|
|
↓
|
|
enrichment
|
|
↓
|
|
optional probing
|
|
↓
|
|
DB batch
|
|
↓
|
|
PostgreSQL
|
|
```
|
|
|
|
Explain which operations are allowed in each stage.
|
|
|
|
---
|
|
|
|
# Implementation priorities
|
|
|
|
Implement in this order:
|
|
|
|
## Phase 1
|
|
|
|
```text
|
|
CertSpotter integration
|
|
in-memory identifier callback
|
|
provider classifier
|
|
classification tests
|
|
benchmarks
|
|
```
|
|
|
|
Do not continue until the classifier has zero unnecessary allocations.
|
|
|
|
## Phase 2
|
|
|
|
```text
|
|
bounded pipeline
|
|
dedup
|
|
DNS resolver
|
|
CNAME following
|
|
DNS cache
|
|
```
|
|
|
|
## Phase 3
|
|
|
|
```text
|
|
PostgreSQL schema
|
|
batch writer
|
|
Postgres CT checkpoint state
|
|
```
|
|
|
|
## Phase 4
|
|
|
|
```text
|
|
optional safe probing
|
|
metrics
|
|
health endpoints
|
|
```
|
|
|
|
## Phase 5
|
|
|
|
```text
|
|
Docker hardening
|
|
benchmarks
|
|
documentation
|
|
integration tests
|
|
```
|
|
|
|
---
|
|
|
|
# Important implementation rule
|
|
|
|
Do not blindly follow this design if inspection of the current CertSpotter source reveals a more direct or efficient integration point.
|
|
|
|
The goal is not to satisfy a predetermined class hierarchy.
|
|
|
|
The goal is:
|
|
|
|
```text
|
|
reuse CertSpotter
|
|
+
|
|
remove output/storage overhead
|
|
+
|
|
receive parsed DNS identifiers directly
|
|
+
|
|
classify with essentially negligible CPU cost
|
|
+
|
|
only enrich potentially useful data
|
|
+
|
|
persist final useful results efficiently
|
|
```
|
|
|
|
If CertSpotter already exposes an internal abstraction that accomplishes this cleanly, use it rather than creating another layer.
|
|
|
|
---
|
|
|
|
# Things you must NOT do
|
|
|
|
Do not:
|
|
|
|
```text
|
|
write certificates to disk
|
|
write JSON events to disk
|
|
read JSON event files
|
|
call CertSpotter through exec.Command
|
|
parse CertSpotter stdout
|
|
use hooks
|
|
fork a process per certificate
|
|
use regex in the classifier
|
|
use shell pipelines
|
|
store every CT certificate
|
|
store every SAN
|
|
create unbounded goroutines
|
|
create unbounded queues
|
|
create unbounded maps
|
|
write each result individually to PostgreSQL
|
|
introduce Kafka/Redis just for queues
|
|
build multiple microservices
|
|
```
|
|
|
|
---
|
|
|
|
# Definition of done
|
|
|
|
The project is complete when:
|
|
|
|
1. A single Dockerized Go process continuously consumes CT logs using CertSpotter internals.
|
|
2. CertSpotter never writes certificate/result files.
|
|
3. Parsed DNS identifiers are transferred directly into our classifier via Go calls.
|
|
4. The classifier covers AWS, Azure, GCP/Firebase, Cloudflare R2, DigitalOcean, Supabase and OCI patterns above.
|
|
5. Direct provider resources are detected immediately.
|
|
6. CNAME chains are asynchronously enriched and classified.
|
|
7. Duplicate processing is bounded and minimized.
|
|
8. Only useful resource findings reach PostgreSQL.
|
|
9. CT resume/checkpoint state lives in PostgreSQL rather than local files.
|
|
10. Classifier benchmarks demonstrate zero unnecessary allocations.
|
|
11. Resource usage remains bounded under overload.
|
|
12. Docker requires no application persistent volume.
|
|
13. `go test ./...` passes.
|
|
14. `go test -race ./...` passes.
|
|
15. `go vet ./...` passes.
|
|
16. Docker build succeeds.
|
|
17. The README explains exactly how to run and benchmark it.
|
|
|
|
Before declaring completion, inspect the resulting program under load and remove any unnecessary:
|
|
|
|
```text
|
|
allocations
|
|
copies
|
|
serialization
|
|
logging
|
|
locking
|
|
filesystem access
|
|
goroutine creation
|
|
database round trips
|
|
```
|
|
|
|
Favor simple, measurable performance over architectural complexity.
|