// 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, ¬ification{ 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, ¬ification{ 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, ¬ification{ 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 }