253 lines
7.6 KiB
Go
253 lines
7.6 KiB
Go
// Copyright (C) 2016, 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 main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
insecurerand "math/rand"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"runtime/debug"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"software.sslmate.com/src/certspotter/loglist"
|
|
"software.sslmate.com/src/certspotter/monitor"
|
|
)
|
|
|
|
var programName = os.Args[0]
|
|
var Version = ""
|
|
|
|
const defaultLogList = "https://loglist.certspotter.org/monitor.json"
|
|
|
|
func certspotterVersion() string {
|
|
if Version != "" {
|
|
return Version + "?"
|
|
}
|
|
info, ok := debug.ReadBuildInfo()
|
|
if !ok {
|
|
return "unknown"
|
|
}
|
|
if strings.HasPrefix(info.Main.Version, "v") {
|
|
return info.Main.Version
|
|
}
|
|
var vcs, vcsRevision, vcsModified string
|
|
for _, s := range info.Settings {
|
|
switch s.Key {
|
|
case "vcs":
|
|
vcs = s.Value
|
|
case "vcs.revision":
|
|
vcsRevision = s.Value
|
|
case "vcs.modified":
|
|
vcsModified = s.Value
|
|
}
|
|
}
|
|
if vcs == "git" && vcsRevision != "" && vcsModified == "true" {
|
|
return vcsRevision + "+"
|
|
} else if vcs == "git" && vcsRevision != "" {
|
|
return vcsRevision
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func fileExists(filename string) bool {
|
|
_, err := os.Lstat(filename)
|
|
return err == nil
|
|
}
|
|
func homedir() string {
|
|
homedir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
panic(fmt.Errorf("unable to determine home directory: %w", err))
|
|
}
|
|
return homedir
|
|
}
|
|
func defaultStateDir() string {
|
|
if envVar := os.Getenv("CERTSPOTTER_STATE_DIR"); envVar != "" {
|
|
return envVar
|
|
} else {
|
|
return filepath.Join(homedir(), ".certspotter")
|
|
}
|
|
}
|
|
func defaultConfigDir() string {
|
|
if envVar := os.Getenv("CERTSPOTTER_CONFIG_DIR"); envVar != "" {
|
|
return envVar
|
|
} else {
|
|
return filepath.Join(homedir(), ".certspotter")
|
|
}
|
|
}
|
|
func defaultWatchListPath() string {
|
|
return filepath.Join(defaultConfigDir(), "watchlist")
|
|
}
|
|
func defaultWatchListPathIfExists() string {
|
|
if fileExists(defaultWatchListPath()) {
|
|
return defaultWatchListPath()
|
|
} else {
|
|
return ""
|
|
}
|
|
}
|
|
func defaultScriptDir() string {
|
|
return filepath.Join(defaultConfigDir(), "hooks.d")
|
|
}
|
|
func defaultEmailFile() string {
|
|
return filepath.Join(defaultConfigDir(), "email_recipients")
|
|
}
|
|
|
|
func simplifyError(err error) error {
|
|
var pathErr *fs.PathError
|
|
if errors.As(err, &pathErr) {
|
|
return pathErr.Err
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func readWatchListFile(filename string) (monitor.WatchList, error) {
|
|
file, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, simplifyError(err)
|
|
}
|
|
defer file.Close()
|
|
return monitor.ReadWatchList(file)
|
|
}
|
|
|
|
func readEmailFile(filename string) ([]string, error) {
|
|
file, err := os.Open(filename)
|
|
if err != nil {
|
|
return nil, simplifyError(err)
|
|
}
|
|
defer file.Close()
|
|
|
|
var emails []string
|
|
scanner := bufio.NewScanner(file)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
continue
|
|
}
|
|
emails = append(emails, line)
|
|
}
|
|
return emails, err
|
|
}
|
|
|
|
func appendFunc(slice *[]string) func(string) error {
|
|
return func(value string) error {
|
|
*slice = append(*slice, value)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
insecurerand.Seed(time.Now().UnixNano()) // TODO: remove after upgrading to Go 1.20
|
|
|
|
loglist.UserAgent = fmt.Sprintf("certspotter/%s (%s; %s; %s)", certspotterVersion(), runtime.Version(), runtime.GOOS, runtime.GOARCH)
|
|
|
|
var flags struct {
|
|
batchSize int // TODO-4: respect this option
|
|
email []string
|
|
healthcheck time.Duration
|
|
logs string
|
|
noSave bool
|
|
script string
|
|
startAtEnd bool
|
|
stateDir string
|
|
stdout bool
|
|
verbose bool
|
|
version bool
|
|
watchlist string
|
|
}
|
|
flag.IntVar(&flags.batchSize, "batch_size", 1000, "Max number of entries to request per call to get-entries (advanced)")
|
|
flag.Func("email", "Email address to contact when matching certificate is discovered (repeatable)", appendFunc(&flags.email))
|
|
flag.DurationVar(&flags.healthcheck, "healthcheck", 24*time.Hour, "How frequently to perform a health check")
|
|
flag.StringVar(&flags.logs, "logs", defaultLogList, "File path or URL of JSON list of logs to monitor")
|
|
flag.BoolVar(&flags.noSave, "no_save", false, "Do not save a copy of matching certificates in state directory")
|
|
flag.StringVar(&flags.script, "script", "", "Program to execute when a matching certificate is discovered")
|
|
flag.BoolVar(&flags.startAtEnd, "start_at_end", false, "Start monitoring logs from the end rather than the beginning (saves considerable bandwidth)")
|
|
flag.StringVar(&flags.stateDir, "state_dir", defaultStateDir(), "Directory for storing log position and discovered certificates")
|
|
flag.BoolVar(&flags.stdout, "stdout", false, "Write matching certificates to stdout")
|
|
flag.BoolVar(&flags.verbose, "verbose", false, "Be verbose")
|
|
flag.BoolVar(&flags.version, "version", false, "Print version and exit")
|
|
flag.StringVar(&flags.watchlist, "watchlist", defaultWatchListPathIfExists(), "File containing domain names to watch")
|
|
flag.Parse()
|
|
|
|
if flags.version {
|
|
fmt.Fprintf(os.Stdout, "certspotter version %s\n", certspotterVersion())
|
|
os.Exit(0)
|
|
}
|
|
if flags.watchlist == "" {
|
|
fmt.Fprintf(os.Stderr, "%s: watch list not found: please create %s or specify alternative path using -watchlist\n", programName, defaultWatchListPath())
|
|
os.Exit(2)
|
|
}
|
|
|
|
config := &monitor.Config{
|
|
LogListSource: flags.logs,
|
|
StateDir: flags.stateDir,
|
|
SaveCerts: !flags.noSave,
|
|
StartAtEnd: flags.startAtEnd,
|
|
Verbose: flags.verbose,
|
|
Script: flags.script,
|
|
ScriptDir: defaultScriptDir(),
|
|
Email: flags.email,
|
|
Stdout: flags.stdout,
|
|
HealthCheckInterval: flags.healthcheck,
|
|
}
|
|
|
|
emailFileExists := false
|
|
if emailRecipients, err := readEmailFile(defaultEmailFile()); err == nil {
|
|
emailFileExists = true
|
|
config.Email = append(config.Email, emailRecipients...)
|
|
} else if !errors.Is(err, fs.ErrNotExist) {
|
|
fmt.Fprintf(os.Stderr, "%s: error reading email recipients file %q: %s\n", programName, defaultEmailFile(), err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
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, "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 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 the path to an executable script using the -script flag\n")
|
|
fmt.Fprintf(os.Stderr, " - Specify the -stdout flag\n")
|
|
os.Exit(2)
|
|
}
|
|
|
|
if flags.watchlist == "-" {
|
|
watchlist, err := monitor.ReadWatchList(os.Stdin)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: error reading watchlist from standard in: %s\n", programName, err)
|
|
os.Exit(1)
|
|
}
|
|
config.WatchList = watchlist
|
|
} else {
|
|
watchlist, err := readWatchListFile(flags.watchlist)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "%s: error reading watchlist from %q: %s\n", programName, flags.watchlist, err)
|
|
os.Exit(1)
|
|
}
|
|
config.WatchList = watchlist
|
|
}
|
|
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
if err := monitor.Run(ctx, config); err != nil && !errors.Is(err, context.Canceled) {
|
|
fmt.Fprintf(os.Stderr, "%s: %s\n", programName, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|