Compare commits

..

No commits in common. "93ca622a379515fcb2ed3b483610dcf5506f0d12" and "658e3206381f46b0684bb03f5e8d71696e8838c9" have entirely different histories.

15 changed files with 362 additions and 448 deletions

View File

@ -190,36 +190,33 @@ func main() {
os.Exit(2) os.Exit(2)
} }
fsstate := &monitor.FilesystemState{ config := &monitor.Config{
LogListSource: flags.logs,
StateDir: flags.stateDir, StateDir: flags.stateDir,
SaveCerts: !flags.noSave, SaveCerts: !flags.noSave,
StartAtEnd: flags.startAtEnd,
Verbose: flags.verbose,
Script: flags.script, Script: flags.script,
ScriptDir: defaultScriptDir(), ScriptDir: defaultScriptDir(),
Email: flags.email, Email: flags.email,
Stdout: flags.stdout, Stdout: flags.stdout,
}
config := &monitor.Config{
LogListSource: flags.logs,
State: fsstate,
StartAtEnd: flags.startAtEnd,
Verbose: flags.verbose,
HealthCheckInterval: flags.healthcheck, HealthCheckInterval: flags.healthcheck,
} }
emailFileExists := false emailFileExists := false
if emailRecipients, err := readEmailFile(defaultEmailFile()); err == nil { if emailRecipients, err := readEmailFile(defaultEmailFile()); err == nil {
emailFileExists = true emailFileExists = true
fsstate.Email = append(fsstate.Email, emailRecipients...) config.Email = append(config.Email, emailRecipients...)
} else if !errors.Is(err, fs.ErrNotExist) { } else if !errors.Is(err, fs.ErrNotExist) {
fmt.Fprintf(os.Stderr, "%s: error reading email recipients file %q: %s\n", programName, defaultEmailFile(), err) fmt.Fprintf(os.Stderr, "%s: error reading email recipients file %q: %s\n", programName, defaultEmailFile(), err)
os.Exit(1) os.Exit(1)
} }
if len(fsstate.Email) == 0 && !emailFileExists && fsstate.Script == "" && !fileExists(fsstate.ScriptDir) && fsstate.Stdout == false { if len(config.Email) == 0 && !emailFileExists && config.Script == "" && !fileExists(config.ScriptDir) && config.Stdout == false {
fmt.Fprintf(os.Stderr, "%s: no notification methods were specified\n", programName) fmt.Fprintf(os.Stderr, "%s: no notification methods were specified\n", programName)
fmt.Fprintf(os.Stderr, "Please specify at least one of the following notification methods:\n") fmt.Fprintf(os.Stderr, "Please specify at least one of the following notification methods:\n")
fmt.Fprintf(os.Stderr, " - Place one or more email addresses in %s (one address per line)\n", defaultEmailFile()) fmt.Fprintf(os.Stderr, " - Place one or more email addresses in %s (one address per line)\n", defaultEmailFile())
fmt.Fprintf(os.Stderr, " - Place one or more executable scripts in the %s directory\n", fsstate.ScriptDir) fmt.Fprintf(os.Stderr, " - Place one or more executable scripts in the %s directory\n", config.ScriptDir)
fmt.Fprintf(os.Stderr, " - Specify an email address using the -email flag\n") fmt.Fprintf(os.Stderr, " - Specify an email address using the -email flag\n")
fmt.Fprintf(os.Stderr, " - Specify the path to an executable script using the -script flag\n") fmt.Fprintf(os.Stderr, " - Specify the path to an executable script using the -script flag\n")
fmt.Fprintf(os.Stderr, " - Specify the -stdout flag\n") fmt.Fprintf(os.Stderr, " - Specify the -stdout flag\n")

View File

@ -15,9 +15,14 @@ import (
type Config struct { type Config struct {
LogListSource string LogListSource string
State StateProvider StateDir string
StartAtEnd bool StartAtEnd bool
WatchList WatchList WatchList WatchList
Verbose bool Verbose bool
SaveCerts bool
Script string
ScriptDir string
Email []string
Stdout bool
HealthCheckInterval time.Duration HealthCheckInterval time.Duration
} }

View File

@ -16,6 +16,7 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"log" "log"
insecurerand "math/rand" insecurerand "math/rand"
"path/filepath"
"software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/loglist"
"time" "time"
) )
@ -50,13 +51,18 @@ type daemon struct {
func (daemon *daemon) healthCheck(ctx context.Context) error { func (daemon *daemon) healthCheck(ctx context.Context) error {
if time.Since(daemon.logsLoadedAt) >= daemon.config.HealthCheckInterval { if time.Since(daemon.logsLoadedAt) >= daemon.config.HealthCheckInterval {
info := &StaleLogListInfo{ textPath := filepath.Join(daemon.config.StateDir, "healthchecks", healthCheckFilename())
event := &staleLogListEvent{
Source: daemon.config.LogListSource, Source: daemon.config.LogListSource,
LastSuccess: daemon.logsLoadedAt, LastSuccess: daemon.logsLoadedAt,
LastError: daemon.logListError, LastError: daemon.logListError,
LastErrorTime: daemon.logListErrorAt, LastErrorTime: daemon.logListErrorAt,
TextPath: textPath,
} }
if err := daemon.config.State.NotifyHealthCheckFailure(ctx, nil, info); err != nil { if err := event.save(); err != nil {
return fmt.Errorf("error saving stale log list event: %w", err)
}
if err := notify(ctx, daemon.config, event); err != nil {
return fmt.Errorf("error notifying about stale log list: %w", err) return fmt.Errorf("error notifying about stale log list: %w", err)
} }
} }
@ -123,8 +129,8 @@ func (daemon *daemon) loadLogList(ctx context.Context) error {
} }
func (daemon *daemon) run(ctx context.Context) error { func (daemon *daemon) run(ctx context.Context) error {
if err := daemon.config.State.Prepare(ctx); err != nil { if err := prepareStateDir(daemon.config.StateDir); err != nil {
return fmt.Errorf("error preparing state: %w", err) return fmt.Errorf("error preparing state directory: %w", err)
} }
if err := daemon.loadLogList(ctx); err != nil { if err := daemon.loadLogList(ctx); err != nil {
@ -144,7 +150,7 @@ func (daemon *daemon) run(ctx context.Context) error {
if err := daemon.loadLogList(ctx); err != nil { if err := daemon.loadLogList(ctx); err != nil {
daemon.logListError = err.Error() daemon.logListError = err.Error()
daemon.logListErrorAt = time.Now() daemon.logListErrorAt = time.Now()
recordError(ctx, daemon.config, nil, fmt.Errorf("error reloading log list (will try again later): %w", err)) recordError(fmt.Errorf("error reloading log list (will try again later): %w", err))
} }
reloadLogListTicker.Reset(reloadLogListInterval()) reloadLogListTicker.Reset(reloadLogListInterval())
case <-healthCheckTicker.C: case <-healthCheckTicker.C:

View File

@ -21,24 +21,21 @@ import (
"software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/ct"
) )
type DiscoveredCert struct { type discoveredCert struct {
WatchItem WatchItem WatchItem WatchItem
LogEntry *LogEntry LogEntry *logEntry
Info *certspotter.CertInfo Info *certspotter.CertInfo
Chain []ct.ASN1Cert // first entry is the leaf certificate or precertificate Chain []ct.ASN1Cert // first entry is the leaf certificate or precertificate
TBSSHA256 [32]byte // computed over Info.TBS.Raw TBSSHA256 [32]byte // computed over Info.TBS.Raw
SHA256 [32]byte // computed over Chain[0] SHA256 [32]byte // computed over Chain[0]
PubkeySHA256 [32]byte // computed over Info.TBS.PublicKey.FullBytes PubkeySHA256 [32]byte // computed over Info.TBS.PublicKey.FullBytes
Identifiers *certspotter.Identifiers Identifiers *certspotter.Identifiers
CertPath string // empty if not saved on the filesystem
JSONPath string // empty if not saved on the filesystem
TextPath string // empty if not saved on the filesystem
} }
type certPaths struct { func (cert *discoveredCert) pemChain() []byte {
certPath string
jsonPath string
textPath string
}
func (cert *DiscoveredCert) pemChain() []byte {
var buffer bytes.Buffer var buffer bytes.Buffer
for _, certBytes := range cert.Chain { for _, certBytes := range cert.Chain {
if err := pem.Encode(&buffer, &pem.Block{ if err := pem.Encode(&buffer, &pem.Block{
@ -51,7 +48,7 @@ func (cert *DiscoveredCert) pemChain() []byte {
return buffer.Bytes() return buffer.Bytes()
} }
func (cert *DiscoveredCert) json() any { func (cert *discoveredCert) json() any {
object := map[string]any{ object := map[string]any{
"tbs_sha256": hex.EncodeToString(cert.TBSSHA256[:]), "tbs_sha256": hex.EncodeToString(cert.TBSSHA256[:]),
"pubkey_sha256": hex.EncodeToString(cert.PubkeySHA256[:]), "pubkey_sha256": hex.EncodeToString(cert.PubkeySHA256[:]),
@ -70,23 +67,23 @@ func (cert *DiscoveredCert) json() any {
return object return object
} }
func writeCertFiles(cert *DiscoveredCert, paths *certPaths) error { func (cert *discoveredCert) save() error {
if err := writeFile(paths.certPath, cert.pemChain(), 0666); err != nil { if err := writeFile(cert.CertPath, cert.pemChain(), 0666); err != nil {
return err return err
} }
if err := writeJSONFile(paths.jsonPath, cert.json(), 0666); err != nil { if err := writeJSONFile(cert.JSONPath, cert.json(), 0666); err != nil {
return err return err
} }
if err := writeTextFile(paths.textPath, certNotificationText(cert, paths), 0666); err != nil { if err := writeTextFile(cert.TextPath, cert.Text(), 0666); err != nil {
return err return err
} }
return nil return nil
} }
func certNotificationEnviron(cert *DiscoveredCert, paths *certPaths) []string { func (cert *discoveredCert) Environ() []string {
env := []string{ env := []string{
"EVENT=discovered_cert", "EVENT=discovered_cert",
"SUMMARY=" + certNotificationSummary(cert), "SUMMARY=" + cert.Summary(),
"CERT_PARSEABLE=yes", // backwards compat with pre-0.15.0; not documented "CERT_PARSEABLE=yes", // backwards compat with pre-0.15.0; not documented
"LOG_URI=" + cert.LogEntry.Log.URL, "LOG_URI=" + cert.LogEntry.Log.URL,
"ENTRY_INDEX=" + fmt.Sprint(cert.LogEntry.Index), "ENTRY_INDEX=" + fmt.Sprint(cert.LogEntry.Index),
@ -96,12 +93,9 @@ func certNotificationEnviron(cert *DiscoveredCert, paths *certPaths) []string {
"FINGERPRINT=" + hex.EncodeToString(cert.SHA256[:]), // backwards compat with pre-0.15.0; not documented "FINGERPRINT=" + hex.EncodeToString(cert.SHA256[:]), // backwards compat with pre-0.15.0; not documented
"PUBKEY_SHA256=" + hex.EncodeToString(cert.PubkeySHA256[:]), "PUBKEY_SHA256=" + hex.EncodeToString(cert.PubkeySHA256[:]),
"PUBKEY_HASH=" + hex.EncodeToString(cert.PubkeySHA256[:]), // backwards compat with pre-0.15.0; not documented "PUBKEY_HASH=" + hex.EncodeToString(cert.PubkeySHA256[:]), // backwards compat with pre-0.15.0; not documented
} "CERT_FILENAME=" + cert.CertPath,
"JSON_FILENAME=" + cert.JSONPath,
if paths != nil { "TEXT_FILENAME=" + cert.TextPath,
env = append(env, "CERT_FILENAME="+paths.certPath)
env = append(env, "JSON_FILENAME="+paths.jsonPath)
env = append(env, "TEXT_FILENAME="+paths.textPath)
} }
if cert.Info.ValidityParseError == nil { if cert.Info.ValidityParseError == nil {
@ -136,7 +130,7 @@ func certNotificationEnviron(cert *DiscoveredCert, paths *certPaths) []string {
return env return env
} }
func certNotificationText(cert *DiscoveredCert, paths *certPaths) string { func (cert *discoveredCert) Text() string {
// TODO-4: improve the output: include WatchItem, indicate hash algorithm used for fingerprints, ... (look at SSLMate email for inspiration) // TODO-4: improve the output: include WatchItem, indicate hash algorithm used for fingerprints, ... (look at SSLMate email for inspiration)
text := new(strings.Builder) text := new(strings.Builder)
@ -164,13 +158,13 @@ func certNotificationText(cert *DiscoveredCert, paths *certPaths) string {
} }
writeField("Log Entry", fmt.Sprintf("%d @ %s", cert.LogEntry.Index, cert.LogEntry.Log.URL)) writeField("Log Entry", fmt.Sprintf("%d @ %s", cert.LogEntry.Index, cert.LogEntry.Log.URL))
writeField("crt.sh", "https://crt.sh/?sha256="+hex.EncodeToString(cert.SHA256[:])) writeField("crt.sh", "https://crt.sh/?sha256="+hex.EncodeToString(cert.SHA256[:]))
if paths != nil { if cert.CertPath != "" {
writeField("Filename", paths.certPath) writeField("Filename", cert.CertPath)
} }
return text.String() return text.String()
} }
func certNotificationSummary(cert *DiscoveredCert) string { func (cert *discoveredCert) Summary() string {
return fmt.Sprintf("Certificate Discovered for %s", cert.WatchItem) return fmt.Sprintf("Certificate Discovered for %s", cert.WatchItem)
} }

View File

@ -10,19 +10,9 @@
package monitor package monitor
import ( import (
"context"
"log" "log"
"software.sslmate.com/src/certspotter/loglist"
) )
func recordError(ctx context.Context, config *Config, ctlog *loglist.Log, errToRecord error) { func recordError(err error) {
if err := config.State.NotifyError(ctx, ctlog, errToRecord); err != nil { log.Print(err)
log.Printf("unable to notify about error: ", err)
if ctlog == nil {
log.Print(errToRecord)
} else {
log.Print(ctlog.URL, ": ", errToRecord)
}
}
} }

View File

@ -1,239 +0,0 @@
// Copyright (C) 2024 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 monitor
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"software.sslmate.com/src/certspotter/ct"
"software.sslmate.com/src/certspotter/loglist"
)
type FilesystemState struct {
StateDir string
SaveCerts bool
Script string
ScriptDir string
Email []string
Stdout bool
}
func (s *FilesystemState) logStateDir(logID LogID) string {
return filepath.Join(s.StateDir, "logs", logID.Base64URLString())
}
func (s *FilesystemState) Prepare(ctx context.Context) error {
return prepareStateDir(s.StateDir)
}
func (s *FilesystemState) PrepareLog(ctx context.Context, logID LogID) error {
var (
stateDirPath = s.logStateDir(logID)
sthsDirPath = filepath.Join(stateDirPath, "unverified_sths")
malformedDirPath = filepath.Join(stateDirPath, "malformed_entries")
healthchecksDirPath = filepath.Join(stateDirPath, "healthchecks")
)
for _, dirPath := range []string{stateDirPath, sthsDirPath, malformedDirPath, healthchecksDirPath} {
if err := os.Mkdir(dirPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return err
}
}
return nil
}
func (s *FilesystemState) LoadLogState(ctx context.Context, logID LogID) (*LogState, error) {
filePath := filepath.Join(s.logStateDir(logID), "state.json")
fileBytes, err := os.ReadFile(filePath)
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
} else if err != nil {
return nil, err
}
state := new(LogState)
if err := json.Unmarshal(fileBytes, state); err != nil {
return nil, fmt.Errorf("error parsing %s: %w", filePath, err)
}
return state, nil
}
func (s *FilesystemState) StoreLogState(ctx context.Context, logID LogID, state *LogState) error {
filePath := filepath.Join(s.logStateDir(logID), "state.json")
return writeJSONFile(filePath, state, 0666)
}
func (s *FilesystemState) StoreSTH(ctx context.Context, logID LogID, sth *ct.SignedTreeHead) error {
sthsDirPath := filepath.Join(s.logStateDir(logID), "unverified_sths")
return storeSTHInDir(sthsDirPath, sth)
}
func (s *FilesystemState) LoadSTHs(ctx context.Context, logID LogID) ([]*ct.SignedTreeHead, error) {
sthsDirPath := filepath.Join(s.logStateDir(logID), "unverified_sths")
return loadSTHsFromDir(sthsDirPath)
}
func (s *FilesystemState) RemoveSTH(ctx context.Context, logID LogID, sth *ct.SignedTreeHead) error {
sthsDirPath := filepath.Join(s.logStateDir(logID), "unverified_sths")
return removeSTHFromDir(sthsDirPath, sth)
}
func (s *FilesystemState) NotifyCert(ctx context.Context, cert *DiscoveredCert) error {
var notifiedPath string
var paths *certPaths
if s.SaveCerts {
hexFingerprint := hex.EncodeToString(cert.SHA256[:])
prefixPath := filepath.Join(s.StateDir, "certs", hexFingerprint[0:2])
var (
notifiedFilename = "." + hexFingerprint + ".notified"
certFilename = hexFingerprint + ".pem"
jsonFilename = hexFingerprint + ".v1.json"
textFilename = hexFingerprint + ".txt"
legacyCertFilename = hexFingerprint + ".cert.pem"
legacyPrecertFilename = hexFingerprint + ".precert.pem"
)
for _, filename := range []string{notifiedFilename, legacyCertFilename, legacyPrecertFilename} {
if fileExists(filepath.Join(prefixPath, filename)) {
return nil
}
}
if err := os.Mkdir(prefixPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("error creating directory in which to save certificate %x: %w", cert.SHA256, err)
}
notifiedPath = filepath.Join(prefixPath, notifiedFilename)
paths = &certPaths{
certPath: filepath.Join(prefixPath, certFilename),
jsonPath: filepath.Join(prefixPath, jsonFilename),
textPath: filepath.Join(prefixPath, textFilename),
}
if err := writeCertFiles(cert, paths); err != nil {
return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err)
}
} else {
// TODO-4: save cert to temporary files, and defer their unlinking
}
if err := s.notify(ctx, &notification{
summary: certNotificationSummary(cert),
environ: certNotificationEnviron(cert, paths),
text: certNotificationText(cert, paths),
}); err != nil {
return fmt.Errorf("error notifying about discovered certificate for %s (%x): %w", cert.WatchItem, cert.SHA256, err)
}
if notifiedPath != "" {
if err := os.WriteFile(notifiedPath, nil, 0666); err != nil {
return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err)
}
}
return nil
}
func (s *FilesystemState) NotifyMalformedEntry(ctx context.Context, entry *LogEntry, parseError error) error {
var (
dirPath = filepath.Join(s.logStateDir(entry.Log.LogID), "malformed_entries")
entryPath = filepath.Join(dirPath, fmt.Sprintf("%d.json", entry.Index))
textPath = filepath.Join(dirPath, fmt.Sprintf("%d.txt", entry.Index))
)
summary := fmt.Sprintf("Unable to Parse Entry %d in %s", entry.Index, entry.Log.URL)
entryJSON := struct {
LeafInput []byte `json:"leaf_input"`
ExtraData []byte `json:"extra_data"`
}{
LeafInput: entry.LeafInput,
ExtraData: entry.ExtraData,
}
text := new(strings.Builder)
writeField := func(name string, value any) { fmt.Fprintf(text, "\t%13s = %s\n", name, value) }
fmt.Fprintf(text, "Unable to determine if log entry matches your watchlist. Please file a bug report at https://github.com/SSLMate/certspotter/issues/new with the following details:\n")
writeField("Log Entry", fmt.Sprintf("%d @ %s", entry.Index, entry.Log.URL))
writeField("Leaf Hash", entry.LeafHash.Base64String())
writeField("Error", parseError.Error())
if err := writeJSONFile(entryPath, entryJSON, 0666); err != nil {
return fmt.Errorf("error saving JSON file: %w", err)
}
if err := writeTextFile(textPath, text.String(), 0666); err != nil {
return fmt.Errorf("error saving texT file: %w", err)
}
environ := []string{
"EVENT=malformed_cert",
"SUMMARY=" + summary,
"LOG_URI=" + entry.Log.URL,
"ENTRY_INDEX=" + fmt.Sprint(entry.Index),
"LEAF_HASH=" + entry.LeafHash.Base64String(),
"PARSE_ERROR=" + parseError.Error(),
"ENTRY_FILENAME=" + entryPath,
"TEXT_FILENAME=" + textPath,
"CERT_PARSEABLE=no", // backwards compat with pre-0.15.0; not documented
}
if err := s.notify(ctx, &notification{
environ: environ,
summary: summary,
text: text.String(),
}); err != nil {
return err
}
return nil
}
func (s *FilesystemState) healthCheckDir(ctlog *loglist.Log) string {
if ctlog == nil {
return filepath.Join(s.StateDir, "healthchecks")
} else {
return filepath.Join(s.logStateDir(ctlog.LogID), "healthchecks")
}
}
func (s *FilesystemState) NotifyHealthCheckFailure(ctx context.Context, ctlog *loglist.Log, info HealthCheckFailure) error {
textPath := filepath.Join(s.healthCheckDir(ctlog), healthCheckFilename())
environ := []string{
"EVENT=error",
"SUMMARY=" + info.Summary(),
"TEXT_FILENAME=" + textPath,
}
text := info.Text()
if err := writeTextFile(textPath, text, 0666); err != nil {
return fmt.Errorf("error saving text file: %w", err)
}
if err := s.notify(ctx, &notification{
environ: environ,
summary: info.Summary(),
text: text,
}); err != nil {
return err
}
return nil
}
func (s *FilesystemState) NotifyError(ctx context.Context, ctlog *loglist.Log, err error) error {
if ctlog == nil {
log.Print(err)
} else {
log.Print(ctlog.URL, ":", err)
}
return nil
}

View File

@ -11,7 +11,10 @@ package monitor
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs"
"path/filepath"
"strings" "strings"
"time" "time"
@ -24,38 +27,52 @@ func healthCheckFilename() string {
} }
func healthCheckLog(ctx context.Context, config *Config, ctlog *loglist.Log) error { func healthCheckLog(ctx context.Context, config *Config, ctlog *loglist.Log) error {
state, err := config.State.LoadLogState(ctx, ctlog.LogID) var (
if err != nil { stateDirPath = filepath.Join(config.StateDir, "logs", ctlog.LogID.Base64URLString())
return fmt.Errorf("error loading log state: %w", err) stateFilePath = filepath.Join(stateDirPath, "state.json")
} else if state == nil { sthsDirPath = filepath.Join(stateDirPath, "unverified_sths")
textPath = filepath.Join(stateDirPath, "healthchecks", healthCheckFilename())
)
state, err := loadStateFile(stateFilePath)
if errors.Is(err, fs.ErrNotExist) {
return nil return nil
} else if err != nil {
return fmt.Errorf("error loading state file: %w", err)
} }
if time.Since(state.LastSuccess) < config.HealthCheckInterval { if time.Since(state.LastSuccess) < config.HealthCheckInterval {
return nil return nil
} }
sths, err := config.State.LoadSTHs(ctx, ctlog.LogID) sths, err := loadSTHsFromDir(sthsDirPath)
if err != nil { if err != nil {
return fmt.Errorf("error loading STHs: %w", err) return fmt.Errorf("error loading STHs directory: %w", err)
} }
if len(sths) == 0 { if len(sths) == 0 {
info := &StaleSTHInfo{ event := &staleSTHEvent{
Log: ctlog, Log: ctlog,
LastSuccess: state.LastSuccess, LastSuccess: state.LastSuccess,
LatestSTH: state.VerifiedSTH, LatestSTH: state.VerifiedSTH,
TextPath: textPath,
} }
if err := config.State.NotifyHealthCheckFailure(ctx, ctlog, info); err != nil { if err := event.save(); err != nil {
return fmt.Errorf("error saving stale STH event: %w", err)
}
if err := notify(ctx, config, event); err != nil {
return fmt.Errorf("error notifying about stale STH: %w", err) return fmt.Errorf("error notifying about stale STH: %w", err)
} }
} else { } else {
info := &BacklogInfo{ event := &backlogEvent{
Log: ctlog, Log: ctlog,
LatestSTH: sths[len(sths)-1], LatestSTH: sths[len(sths)-1],
Position: state.DownloadPosition.Size(), Position: state.DownloadPosition.Size(),
TextPath: textPath,
} }
if err := config.State.NotifyHealthCheckFailure(ctx, ctlog, info); err != nil { if err := event.save(); err != nil {
return fmt.Errorf("error saving backlog event: %w", err)
}
if err := notify(ctx, config, event); err != nil {
return fmt.Errorf("error notifying about backlog: %w", err) return fmt.Errorf("error notifying about backlog: %w", err)
} }
} }
@ -63,45 +80,63 @@ func healthCheckLog(ctx context.Context, config *Config, ctlog *loglist.Log) err
return nil return nil
} }
type HealthCheckFailure interface { type staleSTHEvent struct {
Summary() string
Text() string
}
type StaleSTHInfo struct {
Log *loglist.Log Log *loglist.Log
LastSuccess time.Time LastSuccess time.Time
LatestSTH *ct.SignedTreeHead // may be nil LatestSTH *ct.SignedTreeHead // may be nil
TextPath string
} }
type backlogEvent struct {
type BacklogInfo struct {
Log *loglist.Log Log *loglist.Log
LatestSTH *ct.SignedTreeHead LatestSTH *ct.SignedTreeHead
Position uint64 Position uint64
TextPath string
} }
type staleLogListEvent struct {
type StaleLogListInfo struct {
Source string Source string
LastSuccess time.Time LastSuccess time.Time
LastError string LastError string
LastErrorTime time.Time LastErrorTime time.Time
TextPath string
} }
func (e *BacklogInfo) Backlog() uint64 { func (e *backlogEvent) Backlog() uint64 {
return e.LatestSTH.TreeSize - e.Position return e.LatestSTH.TreeSize - e.Position
} }
func (e *StaleSTHInfo) Summary() string { func (e *staleSTHEvent) Environ() []string {
return []string{
"EVENT=error",
"SUMMARY=" + e.Summary(),
"TEXT_FILENAME=" + e.TextPath,
}
}
func (e *backlogEvent) Environ() []string {
return []string{
"EVENT=error",
"SUMMARY=" + e.Summary(),
"TEXT_FILENAME=" + e.TextPath,
}
}
func (e *staleLogListEvent) Environ() []string {
return []string{
"EVENT=error",
"SUMMARY=" + e.Summary(),
"TEXT_FILENAME=" + e.TextPath,
}
}
func (e *staleSTHEvent) Summary() string {
return fmt.Sprintf("Unable to contact %s since %s", e.Log.URL, e.LastSuccess) return fmt.Sprintf("Unable to contact %s since %s", e.Log.URL, e.LastSuccess)
} }
func (e *BacklogInfo) Summary() string { func (e *backlogEvent) Summary() string {
return fmt.Sprintf("Backlog of size %d from %s", e.Backlog(), e.Log.URL) return fmt.Sprintf("Backlog of size %d from %s", e.Backlog(), e.Log.URL)
} }
func (e *StaleLogListInfo) Summary() string { func (e *staleLogListEvent) Summary() string {
return fmt.Sprintf("Unable to retrieve log list since %s", e.LastSuccess) return fmt.Sprintf("Unable to retrieve log list since %s", e.LastSuccess)
} }
func (e *StaleSTHInfo) Text() string { func (e *staleSTHEvent) Text() string {
text := new(strings.Builder) text := new(strings.Builder)
fmt.Fprintf(text, "certspotter has been unable to contact %s since %s. Consequentially, certspotter may fail to notify you about certificates in this log.\n", e.Log.URL, e.LastSuccess) fmt.Fprintf(text, "certspotter has been unable to contact %s since %s. Consequentially, certspotter may fail to notify you about certificates in this log.\n", e.Log.URL, e.LastSuccess)
fmt.Fprintf(text, "\n") fmt.Fprintf(text, "\n")
@ -114,7 +149,7 @@ func (e *StaleSTHInfo) Text() string {
} }
return text.String() return text.String()
} }
func (e *BacklogInfo) Text() string { func (e *backlogEvent) Text() string {
text := new(strings.Builder) text := new(strings.Builder)
fmt.Fprintf(text, "certspotter has been unable to download entries from %s in a timely manner. Consequentially, certspotter may be slow to notify you about certificates in this log.\n", e.Log.URL) fmt.Fprintf(text, "certspotter has been unable to download entries from %s in a timely manner. Consequentially, certspotter may be slow to notify you about certificates in this log.\n", e.Log.URL)
fmt.Fprintf(text, "\n") fmt.Fprintf(text, "\n")
@ -125,7 +160,7 @@ func (e *BacklogInfo) Text() string {
fmt.Fprintf(text, " Backlog = %d\n", e.Backlog()) fmt.Fprintf(text, " Backlog = %d\n", e.Backlog())
return text.String() return text.String()
} }
func (e *StaleLogListInfo) Text() string { func (e *staleLogListEvent) Text() string {
text := new(strings.Builder) text := new(strings.Builder)
fmt.Fprintf(text, "certspotter has been unable to retrieve the log list from %s since %s.\n", e.Source, e.LastSuccess) fmt.Fprintf(text, "certspotter has been unable to retrieve the log list from %s since %s.\n", e.Source, e.LastSuccess)
fmt.Fprintf(text, "\n") fmt.Fprintf(text, "\n")
@ -135,4 +170,14 @@ func (e *StaleLogListInfo) Text() string {
return text.String() return text.String()
} }
func (e *staleSTHEvent) save() error {
return writeTextFile(e.TextPath, e.Text(), 0666)
}
func (e *backlogEvent) save() error {
return writeTextFile(e.TextPath, e.Text(), 0666)
}
func (e *staleLogListEvent) save() error {
return writeTextFile(e.TextPath, e.Text(), 0666)
}
// TODO-3: make the errors more actionable // TODO-3: make the errors more actionable

72
monitor/malformed.go Normal file
View File

@ -0,0 +1,72 @@
// Copyright (C) 2023 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 monitor
import (
"fmt"
"strings"
)
type malformedLogEntry struct {
Entry *logEntry
Error string
EntryPath string
TextPath string
}
func (malformed *malformedLogEntry) entryJSON() any {
return struct {
LeafInput []byte `json:"leaf_input"`
ExtraData []byte `json:"extra_data"`
}{
LeafInput: malformed.Entry.LeafInput,
ExtraData: malformed.Entry.ExtraData,
}
}
func (malformed *malformedLogEntry) save() error {
if err := writeJSONFile(malformed.EntryPath, malformed.entryJSON(), 0666); err != nil {
return err
}
if err := writeTextFile(malformed.TextPath, malformed.Text(), 0666); err != nil {
return err
}
return nil
}
func (malformed *malformedLogEntry) Environ() []string {
return []string{
"EVENT=malformed_cert",
"SUMMARY=" + malformed.Summary(),
"LOG_URI=" + malformed.Entry.Log.URL,
"ENTRY_INDEX=" + fmt.Sprint(malformed.Entry.Index),
"LEAF_HASH=" + malformed.Entry.LeafHash.Base64String(),
"PARSE_ERROR=" + malformed.Error,
"ENTRY_FILENAME=" + malformed.EntryPath,
"TEXT_FILENAME=" + malformed.TextPath,
"CERT_PARSEABLE=no", // backwards compat with pre-0.15.0; not documented
}
}
func (malformed *malformedLogEntry) Text() string {
text := new(strings.Builder)
writeField := func(name string, value any) { fmt.Fprintf(text, "\t%13s = %s\n", name, value) }
fmt.Fprintf(text, "Unable to determine if log entry matches your watchlist. Please file a bug report at https://github.com/SSLMate/certspotter/issues/new with the following details:\n")
writeField("Log Entry", fmt.Sprintf("%d @ %s", malformed.Entry.Index, malformed.Entry.Log.URL))
writeField("Leaf Hash", malformed.Entry.LeafHash.Base64String())
writeField("Error", malformed.Error)
return text.String()
}
func (malformed *malformedLogEntry) Summary() string {
return fmt.Sprintf("Unable to Parse Entry %d in %s", malformed.Entry.Index, malformed.Entry.Log.URL)
}

View File

@ -14,7 +14,10 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log" "log"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -70,8 +73,17 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
if err := config.State.PrepareLog(ctx, ctlog.LogID); err != nil { var (
return fmt.Errorf("error preparing state: %w", err) stateDirPath = filepath.Join(config.StateDir, "logs", ctlog.LogID.Base64URLString())
stateFilePath = filepath.Join(stateDirPath, "state.json")
sthsDirPath = filepath.Join(stateDirPath, "unverified_sths")
malformedDirPath = filepath.Join(stateDirPath, "malformed_entries")
healthchecksDirPath = filepath.Join(stateDirPath, "healthchecks")
)
for _, dirPath := range []string{stateDirPath, sthsDirPath, malformedDirPath, healthchecksDirPath} {
if err := os.Mkdir(dirPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("error creating state directory: %w", err)
}
} }
startTime := time.Now() startTime := time.Now()
@ -79,35 +91,32 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
if isFatalLogError(err) { if isFatalLogError(err) {
return err return err
} else if err != nil { } else if err != nil {
recordError(ctx, config, ctlog, fmt.Errorf("error fetching latest STH: %w", err)) recordError(fmt.Errorf("error fetching latest STH for %s: %w", ctlog.URL, err))
return nil return nil
} }
latestSTH.LogID = ctlog.LogID latestSTH.LogID = ctlog.LogID
if err := config.State.StoreSTH(ctx, ctlog.LogID, latestSTH); err != nil { if err := storeSTHInDir(sthsDirPath, latestSTH); err != nil {
return fmt.Errorf("error storing latest STH: %w", err) return fmt.Errorf("error storing latest STH: %w", err)
} }
state, err := config.State.LoadLogState(ctx, ctlog.LogID) state, err := loadStateFile(stateFilePath)
if err != nil { if errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("error loading log state: %w", err)
}
if state == nil {
if config.StartAtEnd { if config.StartAtEnd {
tree, err := reconstructTree(ctx, logClient, latestSTH) tree, err := reconstructTree(ctx, logClient, latestSTH)
if isFatalLogError(err) { if isFatalLogError(err) {
return err return err
} else if err != nil { } else if err != nil {
recordError(ctx, config, ctlog, fmt.Errorf("error reconstructing tree of size %d: %w", latestSTH.TreeSize, err)) recordError(fmt.Errorf("error reconstructing tree of size %d for %s: %w", latestSTH.TreeSize, ctlog.URL, err))
return nil return nil
} }
state = &LogState{ state = &stateFile{
DownloadPosition: tree, DownloadPosition: tree,
VerifiedPosition: tree, VerifiedPosition: tree,
VerifiedSTH: latestSTH, VerifiedSTH: latestSTH,
LastSuccess: startTime.UTC(), LastSuccess: startTime.UTC(),
} }
} else { } else {
state = &LogState{ state = &stateFile{
DownloadPosition: merkletree.EmptyCollapsedTree(), DownloadPosition: merkletree.EmptyCollapsedTree(),
VerifiedPosition: merkletree.EmptyCollapsedTree(), VerifiedPosition: merkletree.EmptyCollapsedTree(),
VerifiedSTH: nil, VerifiedSTH: nil,
@ -117,19 +126,21 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
if config.Verbose { if config.Verbose {
log.Printf("brand new log %s (starting from %d)", ctlog.URL, state.DownloadPosition.Size()) log.Printf("brand new log %s (starting from %d)", ctlog.URL, state.DownloadPosition.Size())
} }
if err := config.State.StoreLogState(ctx, ctlog.LogID, state); err != nil { if err := state.store(stateFilePath); err != nil {
return fmt.Errorf("error storing log state: %w", err) return fmt.Errorf("error storing state file: %w", err)
} }
} else if err != nil {
return fmt.Errorf("error loading state file: %w", err)
} }
sths, err := config.State.LoadSTHs(ctx, ctlog.LogID) sths, err := loadSTHsFromDir(sthsDirPath)
if err != nil { if err != nil {
return fmt.Errorf("error loading STHs: %w", err) return fmt.Errorf("error loading STHs directory: %w", err)
} }
for len(sths) > 0 && sths[0].TreeSize <= state.DownloadPosition.Size() { for len(sths) > 0 && sths[0].TreeSize <= state.DownloadPosition.Size() {
// TODO-4: audit sths[0] against state.VerifiedSTH // TODO-4: audit sths[0] against state.VerifiedSTH
if err := config.State.RemoveSTH(ctx, ctlog.LogID, sths[0]); err != nil { if err := removeSTHFromDir(sthsDirPath, sths[0]); err != nil {
return fmt.Errorf("error removing STH: %w", err) return fmt.Errorf("error removing STH: %w", err)
} }
sths = sths[1:] sths = sths[1:]
@ -139,8 +150,8 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
if config.Verbose { if config.Verbose {
log.Printf("saving state in defer for %s", ctlog.URL) log.Printf("saving state in defer for %s", ctlog.URL)
} }
if err := config.State.StoreLogState(ctx, ctlog.LogID, state); err != nil && returnedErr == nil { if err := state.store(stateFilePath); err != nil && returnedErr == nil {
returnedErr = fmt.Errorf("error storing log state: %w", err) returnedErr = fmt.Errorf("error storing state file: %w", err)
} }
}() }()
@ -163,7 +174,7 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
downloadErr = downloadEntries(ctx, logClient, entries, downloadBegin, downloadEnd) downloadErr = downloadEntries(ctx, logClient, entries, downloadBegin, downloadEnd)
}() }()
for rawEntry := range entries { for rawEntry := range entries {
entry := &LogEntry{ entry := &logEntry{
Log: ctlog, Log: ctlog,
Index: state.DownloadPosition.Size(), Index: state.DownloadPosition.Size(),
LeafInput: rawEntry.LeafInput, LeafInput: rawEntry.LeafInput,
@ -180,11 +191,11 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
for len(sths) > 0 && state.DownloadPosition.Size() == sths[0].TreeSize { for len(sths) > 0 && state.DownloadPosition.Size() == sths[0].TreeSize {
if merkletree.Hash(sths[0].SHA256RootHash) != rootHash { if merkletree.Hash(sths[0].SHA256RootHash) != rootHash {
recordError(ctx, config, ctlog, fmt.Errorf("error verifying at tree size %d: the STH root hash (%x) does not match the entries returned by the log (%x)", sths[0].TreeSize, sths[0].SHA256RootHash, rootHash)) recordError(fmt.Errorf("error verifying %s at tree size %d: the STH root hash (%x) does not match the entries returned by the log (%x)", ctlog.URL, sths[0].TreeSize, sths[0].SHA256RootHash, rootHash))
state.DownloadPosition = state.VerifiedPosition state.DownloadPosition = state.VerifiedPosition
if err := config.State.StoreLogState(ctx, ctlog.LogID, state); err != nil { if err := state.store(stateFilePath); err != nil {
return fmt.Errorf("error storing log state: %w", err) return fmt.Errorf("error storing state file: %w", err)
} }
return nil return nil
} }
@ -192,7 +203,7 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
state.VerifiedPosition = state.DownloadPosition state.VerifiedPosition = state.DownloadPosition
state.VerifiedSTH = sths[0] state.VerifiedSTH = sths[0]
shouldSaveState = true shouldSaveState = true
if err := config.State.RemoveSTH(ctx, ctlog.LogID, sths[0]); err != nil { if err := removeSTHFromDir(sthsDirPath, sths[0]); err != nil {
return fmt.Errorf("error removing verified STH: %w", err) return fmt.Errorf("error removing verified STH: %w", err)
} }
@ -200,7 +211,7 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
} }
if shouldSaveState { if shouldSaveState {
if err := config.State.StoreLogState(ctx, ctlog.LogID, state); err != nil { if err := state.store(stateFilePath); err != nil {
return fmt.Errorf("error storing state file: %w", err) return fmt.Errorf("error storing state file: %w", err)
} }
} }
@ -209,7 +220,7 @@ func monitorLog(ctx context.Context, config *Config, ctlog *loglist.Log, logClie
if isFatalLogError(downloadErr) { if isFatalLogError(downloadErr) {
return downloadErr return downloadErr
} else if downloadErr != nil { } else if downloadErr != nil {
recordError(ctx, config, ctlog, fmt.Errorf("error downloading entries: %w", downloadErr)) recordError(fmt.Errorf("error downloading entries from %s: %w", ctlog.URL, downloadErr))
return nil return nil
} }

View File

@ -25,31 +25,31 @@ import (
var stdoutMu sync.Mutex var stdoutMu sync.Mutex
type notification struct { type notification interface {
environ []string Environ() []string
summary string Summary() string
text string Text() string
} }
func (s *FilesystemState) notify(ctx context.Context, notif *notification) error { func notify(ctx context.Context, config *Config, notif notification) error {
if s.Stdout { if config.Stdout {
writeToStdout(notif) writeToStdout(notif)
} }
if len(s.Email) > 0 { if len(config.Email) > 0 {
if err := sendEmail(ctx, s.Email, notif); err != nil { if err := sendEmail(ctx, config.Email, notif); err != nil {
return err return err
} }
} }
if s.Script != "" { if config.Script != "" {
if err := execScript(ctx, s.Script, notif); err != nil { if err := execScript(ctx, config.Script, notif); err != nil {
return err return err
} }
} }
if s.ScriptDir != "" { if config.ScriptDir != "" {
if err := execScriptDir(ctx, s.ScriptDir, notif); err != nil { if err := execScriptDir(ctx, config.ScriptDir, notif); err != nil {
return err return err
} }
} }
@ -57,25 +57,25 @@ func (s *FilesystemState) notify(ctx context.Context, notif *notification) error
return nil return nil
} }
func writeToStdout(notif *notification) { func writeToStdout(notif notification) {
stdoutMu.Lock() stdoutMu.Lock()
defer stdoutMu.Unlock() defer stdoutMu.Unlock()
os.Stdout.WriteString(notif.text + "\n") os.Stdout.WriteString(notif.Text() + "\n")
} }
func sendEmail(ctx context.Context, to []string, notif *notification) error { func sendEmail(ctx context.Context, to []string, notif notification) error {
stdin := new(bytes.Buffer) stdin := new(bytes.Buffer)
stderr := new(bytes.Buffer) stderr := new(bytes.Buffer)
fmt.Fprintf(stdin, "To: %s\n", strings.Join(to, ", ")) fmt.Fprintf(stdin, "To: %s\n", strings.Join(to, ", "))
fmt.Fprintf(stdin, "Subject: [certspotter] %s\n", notif.summary) fmt.Fprintf(stdin, "Subject: [certspotter] %s\n", notif.Summary())
fmt.Fprintf(stdin, "Date: %s\n", time.Now().Format(mailDateFormat)) fmt.Fprintf(stdin, "Date: %s\n", time.Now().Format(mailDateFormat))
fmt.Fprintf(stdin, "Message-ID: <%s>\n", generateMessageID()) fmt.Fprintf(stdin, "Message-ID: <%s>\n", generateMessageID())
fmt.Fprintf(stdin, "Mime-Version: 1.0\n") fmt.Fprintf(stdin, "Mime-Version: 1.0\n")
fmt.Fprintf(stdin, "Content-Type: text/plain; charset=US-ASCII\n") fmt.Fprintf(stdin, "Content-Type: text/plain; charset=US-ASCII\n")
fmt.Fprintf(stdin, "X-Mailer: certspotter\n") fmt.Fprintf(stdin, "X-Mailer: certspotter\n")
fmt.Fprintf(stdin, "\n") fmt.Fprintf(stdin, "\n")
fmt.Fprint(stdin, notif.text) fmt.Fprint(stdin, notif.Text())
args := []string{"-i", "--"} args := []string{"-i", "--"}
args = append(args, to...) args = append(args, to...)
@ -95,12 +95,12 @@ func sendEmail(ctx context.Context, to []string, notif *notification) error {
} }
} }
func execScript(ctx context.Context, scriptName string, notif *notification) error { func execScript(ctx context.Context, scriptName string, notif notification) error {
stderr := new(bytes.Buffer) stderr := new(bytes.Buffer)
cmd := exec.CommandContext(ctx, scriptName) cmd := exec.CommandContext(ctx, scriptName)
cmd.Env = os.Environ() cmd.Env = os.Environ()
cmd.Env = append(cmd.Env, notif.environ...) cmd.Env = append(cmd.Env, notif.Environ()...)
cmd.Stderr = stderr cmd.Stderr = stderr
if err := cmd.Run(); err == nil { if err := cmd.Run(); err == nil {
@ -116,7 +116,7 @@ func execScript(ctx context.Context, scriptName string, notif *notification) err
} }
} }
func execScriptDir(ctx context.Context, dirPath string, notif *notification) error { func execScriptDir(ctx context.Context, dirPath string, notif notification) error {
dirents, err := os.ReadDir(dirPath) dirents, err := os.ReadDir(dirPath)
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return nil return nil

View File

@ -13,14 +13,19 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex"
"errors"
"fmt" "fmt"
"io/fs"
"os"
"path/filepath"
"software.sslmate.com/src/certspotter" "software.sslmate.com/src/certspotter"
"software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/ct"
"software.sslmate.com/src/certspotter/loglist" "software.sslmate.com/src/certspotter/loglist"
"software.sslmate.com/src/certspotter/merkletree" "software.sslmate.com/src/certspotter/merkletree"
) )
type LogEntry struct { type logEntry struct {
Log *loglist.Log Log *loglist.Log
Index uint64 Index uint64
LeafInput []byte LeafInput []byte
@ -28,7 +33,7 @@ type LogEntry struct {
LeafHash merkletree.Hash LeafHash merkletree.Hash
} }
func processLogEntry(ctx context.Context, config *Config, entry *LogEntry) error { func processLogEntry(ctx context.Context, config *Config, entry *logEntry) error {
leaf, err := ct.ReadMerkleTreeLeaf(bytes.NewReader(entry.LeafInput)) leaf, err := ct.ReadMerkleTreeLeaf(bytes.NewReader(entry.LeafInput))
if err != nil { if err != nil {
return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing Merkle Tree Leaf: %w", err)) return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing Merkle Tree Leaf: %w", err))
@ -43,7 +48,7 @@ func processLogEntry(ctx context.Context, config *Config, entry *LogEntry) error
} }
} }
func processX509LogEntry(ctx context.Context, config *Config, entry *LogEntry, cert ct.ASN1Cert) error { func processX509LogEntry(ctx context.Context, config *Config, entry *logEntry, cert ct.ASN1Cert) error {
certInfo, err := certspotter.MakeCertInfoFromRawCert(cert) certInfo, err := certspotter.MakeCertInfoFromRawCert(cert)
if err != nil { if err != nil {
return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing X.509 certificate: %w", err)) return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing X.509 certificate: %w", err))
@ -64,7 +69,7 @@ func processX509LogEntry(ctx context.Context, config *Config, entry *LogEntry, c
return processCertificate(ctx, config, entry, certInfo, chain) return processCertificate(ctx, config, entry, certInfo, chain)
} }
func processPrecertLogEntry(ctx context.Context, config *Config, entry *LogEntry, precert ct.PreCert) error { func processPrecertLogEntry(ctx context.Context, config *Config, entry *logEntry, precert ct.PreCert) error {
certInfo, err := certspotter.MakeCertInfoFromRawTBS(precert.TBSCertificate) certInfo, err := certspotter.MakeCertInfoFromRawTBS(precert.TBSCertificate)
if err != nil { if err != nil {
return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing precert TBSCertificate: %w", err)) return processMalformedLogEntry(ctx, config, entry, fmt.Errorf("error parsing precert TBSCertificate: %w", err))
@ -82,7 +87,7 @@ func processPrecertLogEntry(ctx context.Context, config *Config, entry *LogEntry
return processCertificate(ctx, config, entry, certInfo, chain) return processCertificate(ctx, config, entry, certInfo, chain)
} }
func processCertificate(ctx context.Context, config *Config, entry *LogEntry, certInfo *certspotter.CertInfo, chain []ct.ASN1Cert) error { func processCertificate(ctx context.Context, config *Config, entry *logEntry, certInfo *certspotter.CertInfo, chain []ct.ASN1Cert) error {
identifiers, err := certInfo.ParseIdentifiers() identifiers, err := certInfo.ParseIdentifiers()
if err != nil { if err != nil {
return processMalformedLogEntry(ctx, config, entry, err) return processMalformedLogEntry(ctx, config, entry, err)
@ -92,7 +97,7 @@ func processCertificate(ctx context.Context, config *Config, entry *LogEntry, ce
return nil return nil
} }
cert := &DiscoveredCert{ cert := &discoveredCert{
WatchItem: watchItem, WatchItem: watchItem,
LogEntry: entry, LogEntry: entry,
Info: certInfo, Info: certInfo,
@ -103,15 +108,68 @@ func processCertificate(ctx context.Context, config *Config, entry *LogEntry, ce
Identifiers: identifiers, Identifiers: identifiers,
} }
if err := config.State.NotifyCert(ctx, cert); err != nil { var notifiedPath string
return fmt.Errorf("error notifying about certificate %x: %w", cert.SHA256, err) if config.SaveCerts {
hexFingerprint := hex.EncodeToString(cert.SHA256[:])
prefixPath := filepath.Join(config.StateDir, "certs", hexFingerprint[0:2])
var (
notifiedFilename = "." + hexFingerprint + ".notified"
certFilename = hexFingerprint + ".pem"
jsonFilename = hexFingerprint + ".v1.json"
textFilename = hexFingerprint + ".txt"
legacyCertFilename = hexFingerprint + ".cert.pem"
legacyPrecertFilename = hexFingerprint + ".precert.pem"
)
for _, filename := range []string{notifiedFilename, legacyCertFilename, legacyPrecertFilename} {
if fileExists(filepath.Join(prefixPath, filename)) {
return nil
}
}
if err := os.Mkdir(prefixPath, 0777); err != nil && !errors.Is(err, fs.ErrExist) {
return fmt.Errorf("error creating directory in which to save certificate %x: %w", cert.SHA256, err)
}
notifiedPath = filepath.Join(prefixPath, notifiedFilename)
cert.CertPath = filepath.Join(prefixPath, certFilename)
cert.JSONPath = filepath.Join(prefixPath, jsonFilename)
cert.TextPath = filepath.Join(prefixPath, textFilename)
if err := cert.save(); err != nil {
return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err)
}
} else {
// TODO-4: save cert to temporary files, and defer their unlinking
}
if err := notify(ctx, config, cert); err != nil {
return fmt.Errorf("error notifying about discovered certificate for %s (%x): %w", cert.WatchItem, cert.SHA256, err)
}
if notifiedPath != "" {
if err := os.WriteFile(notifiedPath, nil, 0666); err != nil {
return fmt.Errorf("error saving certificate %x: %w", cert.SHA256, err)
}
} }
return nil return nil
} }
func processMalformedLogEntry(ctx context.Context, config *Config, entry *LogEntry, parseError error) error { func processMalformedLogEntry(ctx context.Context, config *Config, entry *logEntry, parseError error) error {
if err := config.State.NotifyMalformedEntry(ctx, entry, parseError); err != nil { dirPath := filepath.Join(config.StateDir, "logs", entry.Log.LogID.Base64URLString(), "malformed_entries")
malformed := &malformedLogEntry{
Entry: entry,
Error: parseError.Error(),
EntryPath: filepath.Join(dirPath, fmt.Sprintf("%d.json", entry.Index)),
TextPath: filepath.Join(dirPath, fmt.Sprintf("%d.txt", entry.Index)),
}
if err := malformed.save(); err != nil {
return fmt.Errorf("error saving malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err)
}
if err := notify(ctx, config, malformed); err != nil {
return fmt.Errorf("error notifying about malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err) return fmt.Errorf("error notifying about malformed log entry %d in %s (%q): %w", entry.Index, entry.Log.URL, parseError, err)
} }
return nil return nil

View File

@ -1,67 +0,0 @@
// Copyright (C) 2024 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 monitor
import (
"context"
"software.sslmate.com/src/certspotter/ct"
"software.sslmate.com/src/certspotter/loglist"
"software.sslmate.com/src/certspotter/merkletree"
"time"
)
type LogState struct {
DownloadPosition *merkletree.CollapsedTree `json:"download_position"`
VerifiedPosition *merkletree.CollapsedTree `json:"verified_position"`
VerifiedSTH *ct.SignedTreeHead `json:"verified_sth"`
LastSuccess time.Time `json:"last_success"`
}
type StateProvider interface {
// Initialize the state. Called before any other method in this interface.
// Idempotent: returns nil if the state is already initialized.
Prepare(context.Context) error
// Initialize the state for the given log. Called before any other method
// with the log ID. Idempotent: returns nil if log state already initialized.
PrepareLog(context.Context, LogID) error
// Store log state for retrieval by LoadLogState.
StoreLogState(context.Context, LogID, *LogState) error
// Load log state that was previously stored with StoreLogState.
// Returns nil, nil if StoreLogState has not been called yet for this log.
LoadLogState(context.Context, LogID) (*LogState, error)
// Store STH for retrieval by LoadSTHs. If an STH with the same
// timestamp and root hash is already stored, this STH can be ignored.
StoreSTH(context.Context, LogID, *ct.SignedTreeHead) error
// Load all STHs for this log previously stored with StoreSTH.
// The returned slice must be sorted by tree size.
LoadSTHs(context.Context, LogID) ([]*ct.SignedTreeHead, error)
// Remove an STH so it is no longer returned by LoadSTHs.
RemoveSTH(context.Context, LogID, *ct.SignedTreeHead) error
// Called when a certificate matching the watch list is discovered.
NotifyCert(context.Context, *DiscoveredCert) error
// Called when certspotter fails to parse a log entry.
NotifyMalformedEntry(context.Context, *LogEntry, error) error
// Called when a health check fails. The log is nil if the
// feailure is not associated with a log.
NotifyHealthCheckFailure(context.Context, *loglist.Log, HealthCheckFailure) error
// Called when an error occurs. The log is nil if the error is
// not associated with a log. Note that most errors are transient.
NotifyError(context.Context, *loglist.Log, error) error
}

View File

@ -76,13 +76,13 @@ func migrateLogStateDirV1(dir string) error {
return fmt.Errorf("error unmarshaling %s: %w", treePath, err) return fmt.Errorf("error unmarshaling %s: %w", treePath, err)
} }
stateFile := LogState{ stateFile := stateFile{
DownloadPosition: &tree, DownloadPosition: &tree,
VerifiedPosition: &tree, VerifiedPosition: &tree,
VerifiedSTH: &sth, VerifiedSTH: &sth,
LastSuccess: time.Now().UTC(), LastSuccess: time.Now().UTC(),
} }
if err := writeJSONFile(filepath.Join(dir, "state.json"), stateFile, 0666); err != nil { if stateFile.store(filepath.Join(dir, "state.json")); err != nil {
return err return err
} }

42
monitor/statefile.go Normal file
View File

@ -0,0 +1,42 @@
// Copyright (C) 2023 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 monitor
import (
"encoding/json"
"fmt"
"os"
"software.sslmate.com/src/certspotter/ct"
"software.sslmate.com/src/certspotter/merkletree"
"time"
)
type stateFile struct {
DownloadPosition *merkletree.CollapsedTree `json:"download_position"`
VerifiedPosition *merkletree.CollapsedTree `json:"verified_position"`
VerifiedSTH *ct.SignedTreeHead `json:"verified_sth"`
LastSuccess time.Time `json:"last_success"`
}
func loadStateFile(filePath string) (*stateFile, error) {
fileBytes, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
file := new(stateFile)
if err := json.Unmarshal(fileBytes, file); err != nil {
return nil, fmt.Errorf("error parsing %s: %w", filePath, err)
}
return file, nil
}
func (file *stateFile) store(filePath string) error {
return writeJSONFile(filePath, file, 0666)
}

View File

@ -17,10 +17,10 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"slices"
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"software.sslmate.com/src/certspotter/ct" "software.sslmate.com/src/certspotter/ct"
"strconv" "strconv"
"strings" "strings"