ccadb2OneCRL/main.go (548 lines of code) (raw):

/* 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/. */ package main // import "github.com/mozilla/OneCRL-Tools/ccadb2OneCRL" import ( "encoding/csv" "encoding/json" "fmt" "path/filepath" "strings" "os" "time" "github.com/joho/godotenv" "github.com/mozilla/OneCRL-Tools/kinto/api/auth" bugzAuth "github.com/mozilla/OneCRL-Tools/bugzilla/api/auth" "github.com/pkg/errors" "github.com/mozilla/OneCRL-Tools/bugzilla/api/attachments" bugzilla "github.com/mozilla/OneCRL-Tools/bugzilla/client" "github.com/mozilla/OneCRL-Tools/bugzilla/api/bugs" "github.com/mozilla/OneCRL-Tools/transaction" "github.com/mozilla/OneCRL-Tools/ccadb2OneCRL/onecrl" "github.com/mozilla/OneCRL-Tools/kinto" "github.com/mozilla/OneCRL-Tools/ccadb2OneCRL/ccadb" log "github.com/sirupsen/logrus" ) const ( // Base URL for Kinto production [default: "https://remote-settings.mozilla.org/v1"] OneCRLProduction = "ONECRL_PRODUCTION" oneCRLProductionDefault = "https://remote-settings.mozilla.org/v1" // User account for Kinto production. Requires OneCRLProductionPassword to be set. Mutually exclusive with OneCRLProductionToken. OneCRLProductionUser = "ONECRL_PRODUCTION_USER" // User password for Kinto production. Requires OneCRLProductionUser to be set. Mutually exclusive with OneCRLProductionToken. OneCRLProductionPassword = "ONECRL_PRODUCTION_PASSWORD" // Auth token for Kinto production. Mutually exclusive with OneCRLProductionUser and OneCRLProductionPassword. OneCRLProductionToken = "ONECRL_PRODUCTION_TOKEN" // Target production bucket [default: "security-state-staging"] OneCRLProductionBucket = "ONECRL_PRODUCTION_BUCKET" // Target production collection [default: "onecrl"] // Default is likely what you want as this is mostly configurable for testing purposes. OneCRLProductionCollection = "ONECRL_PRODUCTION_COLLECTION" // Base URL for Kinto production [default: "https://firefox.settings.services.allizom.org/v1"] OneCRLStaging = "ONECRL_STAGING" oneCRLStagingDefault = "https://firefox.settings.services.allizom.org/v1" // User account for Kinto staging. Requires OneCRLStagingPassword to be set. Mutually exclusive with OneCRLStagingToken. OneCRLStagingUser = "ONECRL_STAGING_USER" // User password for Kinto staging. Requires OneCRLStagingUser to be set. Mutually exclusive with OneCRLStagingToken. OneCRLStagingPassword = "ONECRL_STAGING_PASSWORD" // Auth token for Kinto staging. Mutually exclusive with OneCRLStagingUser and OneCRLStagingPassword. OneCRLStagingToken = "ONECRL_STAGING_TOKEN" // Target staging bucket [default: "security-state-staging"]. // Default is likely what you want as this is mostly configurable for testing purposes. OneCRLStagingBucket = "ONECRL_STAGING_BUCKET" // Target staging collection [default: "onecrl"] // Default is likely what you want as this is mostly configurable for testing purposes. OneCRLStagingCollection = "ONECRL_STAGING_COLLECTION" // Base URL for Bugzilla [default: "https://bugzilla.mozilla.org"] Bugzilla = "BUGZILLA" bugzillaDefault = "https://bugzilla.mozilla.org" // Mandatory API key for posting to Bugzilla. This key MUST have write permissions. BugzillaApiKey = "BUGZILLA_API_KEY" // Optional. A comma separated list of of email accounts to put on CC for new bugs. If these accounts are not // registered with the configured Bugzilla, then a runtime error will occur when creating new bugs. BugzillaCcAccounts = "BUGZILLA_CC_ACCOUNTS" // Target logging level for this tool. // Available: panic, fatal, error, warn, warning info, debug, trace // Default: info LogLevel = "LOG_LEVEL" // Target directory for logs. Each run of the tool will be logged to the timestamp // of when it was ran. [default: stdout/stderr] LogDir = "LOG_DIR" ) func main() { config := filepath.Join(filepath.Dir(os.Args[0]), "config.env") if len(os.Args) > 1 { config = os.Args[1] } err := godotenv.Load(config) if err != nil { fmt.Fprintf(os.Stderr, "config.env appears to be malformed, err: %v\n", err) os.Exit(1) } _main() } // _main is just a unit testable main (since main is looking at command line args // and loading configs from the filesystem it's not a great target for testing). func _main() { err := SetLogOut() if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to set logging out file, err: %v\n", err) os.Exit(1) } level, err := ParseLogLevel() if err != nil { _, _ = fmt.Fprintf(os.Stderr, "unexpected logging level %s\n", os.Getenv(LogLevel)) _, _ = fmt.Fprint(os.Stderr, "expected one of either panic, fatal, error, warn, warning info, debug, trace") os.Exit(1) } log.SetLevel(level) // This gets us call site information, which is rather useful. It is also the reason // why we need to compile with go >= 1.15 (logrus needed a newer runtime API in order to pull it off, apparently). log.SetReportCaller(true) log.SetFormatter(&log.JSONFormatter{PrettyPrint: true}) production, err := Production() if err != nil { log.WithField("production", os.Getenv(OneCRLProduction)). WithError(err). Fatal("failed to construct OneCRL production client") } staging, err := Staging() if err != nil { log.WithError(err). Fatal("failed to construct OneCRL staging client") } bugz := BugzillaClient() updater := NewUpdate(staging, production, bugz) err = updater.Update() if err != nil { log.WithError(err).Error("update failed") os.Exit(1) } log.Info("update completed") } // Production returns a Kinto client that is configured to target // the OneCRLProduction class of environment variable. func Production() (*kinto.Client, error) { production := oneCRLProductionDefault if os.Getenv(OneCRLProduction) != "" { production = os.Getenv(OneCRLProduction) } c, err := kinto.NewClientFromStr(production) if err != nil { return nil, errors.Wrap(err, "failed to construct OneCRL production client from URL") } principal, err := KintoPrincipal( os.Getenv(OneCRLProductionUser), os.Getenv(OneCRLProductionPassword), os.Getenv(OneCRLProductionToken)) if err != nil { return nil, errors.Wrap(err, "failed to set OneCRL production credentials") } return c.WithAuthenticator(principal), nil } // Staging returns a Kinto client that is configured to target // the OneCRLStaging class of environment variable. func Staging() (*kinto.Client, error) { staging := oneCRLStagingDefault if os.Getenv(OneCRLStaging) != "" { staging = os.Getenv(OneCRLStaging) } c, err := kinto.NewClientFromStr(staging) if err != nil { return nil, errors.Wrap(err, "failed to construct OneCRL staging client from URL") } principal, err := KintoPrincipal( os.Getenv(OneCRLStagingUser), os.Getenv(OneCRLStagingPassword), os.Getenv(OneCRLStagingToken)) if err != nil { return nil, errors.Wrap(err, "failed to set OneCRL staging credentials") } return c.WithAuthenticator(principal), nil } func ProductionCollection() *onecrl.OneCRL { return Collection(os.Getenv(OneCRLProductionBucket), os.Getenv(OneCRLProductionCollection)) } func StagingCollection() *onecrl.OneCRL { return Collection(os.Getenv(OneCRLStagingBucket), os.Getenv(OneCRLStagingCollection)) } func Collection(bucket, collection string) *onecrl.OneCRL { o := onecrl.NewOneCRL() if bucket != "" { o.Bucket.ID = bucket } if collection != "" { o.ID = collection } return o } // KintoPrincipal returns an appropriate authenticator based on the input. // // If a username and password is provided, then an auth.User will be returned. // If a token is provided, then an auth.Token will be returned. // // All other combinations will result in an error. func KintoPrincipal(user, password, token string) (auth.Authenticator, error) { if user == "" && password == "" && token == "" { return &auth.Unauthenticated{}, nil } if user != "" && password != "" && token != "" || user == "" && password != "" || user != "" && password == "" { return nil, fmt.Errorf("an invalid combination of 'user', 'password', and 'token' was set") } if token != "" { return &auth.Token{Token: token}, nil } return &auth.User{Username: user, Password: password}, nil } func BugzillaClient() *bugzilla.Client { bugz := bugzillaDefault if os.Getenv(Bugzilla) != "" { bugz = os.Getenv(Bugzilla) } return bugzilla.NewClient(bugz). WithAuth(&bugzAuth.ApiKey{ApiKey: os.Getenv(BugzillaApiKey)}) } func ParseLogLevel() (log.Level, error) { l := os.Getenv(LogLevel) if l == "" { return log.InfoLevel, nil } return log.ParseLevel(l) } func SetLogOut() error { logDir := os.Getenv(LogDir) if logDir == "" { // Use stdout/stderr return nil } err := os.MkdirAll(logDir, 0755) if err != nil { return err } out, err := os.Create(filepath.Join(logDir, time.Now().UTC().Format(time.RFC3339))) if err != nil { return err } log.SetOutput(out) return nil } // Updater is all of the state necessary to keep track of a SINGLE round of updates. // It is not intended to be reused (although it could be with a bit of modification). type Updater struct { changes []*onecrl.Record bugID int staging *kinto.Client production *kinto.Client bugzilla *bugzilla.Client } func NewUpdate(staging, production *kinto.Client, bugz *bugzilla.Client) *Updater { return &Updater{ staging: staging, production: production, bugzilla: bugz, } } // Update is the main entry point to the core business logic. func (u *Updater) Update() error { // Do some canary tests against Kinto to make sure that // we are properly authenticated for both production and // staging before we move on with anything. err := u.TryAuth() if err != nil { return err } // Policy is that if staging or prod (or both) are in review then we bail // out of this operation early and send out emails. inReview, err := u.AnySignerInReview() if err != nil { return err } if inReview { log.Info("changes at staging or production (or both) are in review") // We want to find the intersection between the CCADB // and OneCRL as those are the revocations that are still // in review. Once we find them we would like to post // gentle reminders to the associated Bugzilla tickets. intersection, err := u.FindIntersection() if err != nil { return err } u.BlastEmails(intersection) return nil } err = u.FindDiffs() if err != nil { return err } if u.NoDiffs() { log.Info("no differences found between the CCADB and OneCRL staging/production") return nil } // From here on we begin mutating datasets (OneCRL staging/production and Bugzilla) // so we would like to put these actions into a transactional context. Ideally, // each step should be able to undo itself if necessary. err = transaction.Start(). Then(u.PushToStaging()). Then(u.OpenBug()). Then(u.UpdateRecordsWithBugID()). Then(u.PutStagingIntoReview()). Then(u.PushToProduction()). Then(u.PutProductionIntoReview()). AutoRollbackOnError(true). AutoClose(true). Commit() if err == nil { log.WithField("bugzilla", u.bugzilla.ShowBug(u.bugID)).Info("successfully completed update") } return err } // TryAuth attempts the "try_authentication" Kinto API for first staging and then production. // // For more information on the Kinto API, please see https://docs.kinto-storage.org/en/stable/api/1.x/authentication.html#try-authentication func (u *Updater) TryAuth() error { var err error = nil ok, e := u.staging.TryAuth() if e != nil { err = e } else if !ok { err = fmt.Errorf("authentication for staging Kinto failed") } ok, e = u.production.TryAuth() if e != nil { if err != nil { err = errors.Wrap(err, e.Error()) } else { err = e } } else if !ok { if err != nil { err = errors.Wrap(err, "authentication for production Kinto failed") } else { err = fmt.Errorf("authentication for production Kinto failed") } } // If err == nil then WithStack returns nil. return errors.WithStack(err) } // FindDiffs finds all entries that are within the CCADB // that are not within OneCRL. Each entry found constructs // an appropriate onecrl.Record entry and emplaces it in // u.records for future reference. func (u *Updater) FindDiffs() error { oneCRL, c, err := u.getDataSets() if err != nil { return err } diffs := c.Difference(oneCRL) u.changes = make([]*onecrl.Record, 0) for diff := range diffs.Iter() { record, err := onecrl.FromCCADB(diff.(*ccadb.Certificate)) if err != nil { return errors.WithStack(err) } u.changes = append(u.changes, record) } return nil } // FindIntersection finds the intersection between // union(oneCRLProd, oneCRLStag) and the CCADB. func (u *Updater) FindIntersection() (*onecrl.Set, error) { oneCRL, ccadb, err := u.getDataSets() if err != nil { return nil, err } return oneCRL.Intersection(ccadb).(*onecrl.Set), nil } // getDataSets return the union(oneCRLProd, oneCRLStag) and the CCADB. func (u *Updater) getDataSets() (*onecrl.Set, *ccadb.Set, error) { production := ProductionCollection() err := u.production.AllRecords(production) if err != nil { return nil, nil, errors.WithStack(err) } productionSet := onecrl.NewSetFrom(production) ///////// staging := StagingCollection() err = u.staging.AllRecords(staging) if err != nil { return nil, nil, errors.WithStack(err) } stagingSet := onecrl.NewSetFrom(staging) ////// oneCRLUnion := productionSet.Union(stagingSet).(*onecrl.Set) ////// ccadbRecords, err := ccadb.Default() if err != nil { return nil, nil, errors.WithStack(err) } ccadbSet := ccadb.NewSetFrom(ccadbRecords) return oneCRLUnion, ccadbSet, nil } func (u *Updater) NoDiffs() bool { return len(u.changes) == 0 } func (u *Updater) AnySignerInReview() (bool, error) { stagingStatus, err := u.staging.SignerStatusFor(StagingCollection()) if err != nil { return false, errors.WithStack(err) } prodStatus, err := u.production.SignerStatusFor(ProductionCollection()) if err != nil { return false, errors.WithStack(err) } return stagingStatus.InReview() || prodStatus.InReview(), nil } func (u *Updater) PushToStaging() transaction.Transactor { committed := 0 return transaction.NewTransaction().WithCommit(func() error { collection := StagingCollection() for _, record := range u.changes { err := u.staging.NewRecord(collection, record) if err != nil { return errors.WithStack(err) } committed += 1 } return nil }).WithRollback(func(_ error) error { // Try to delete as many of the entries that we can that // WERE successfully inserted. Single error while deleting // does not fail out the entire rollback, so it is possible // for this rollback to leave orphaned data on staging // the service is degraded and only sporadically failing. var err error = nil collection := StagingCollection() for i := 0; i < committed; i++ { _, e := u.staging.Delete(collection, u.changes[i]) if e != nil { if err == nil { err = e } else { err = errors.Wrap(err, e.Error()) } } } return errors.WithStack(err) }) } // I think this speaks for itself, it's a crumby little integration complication. const attachmentWarning = "received an error while uploading an attachment to BugzillaClient, however " + "a 'Failed to fetch attachment ID <ID> from S3' error always occurs when attaching a bug. This is " + "likely just a synchronization bug wherein BugzillaClient saves a record to S3 and then immediately attempts " + "to retrieve it, however S3 has not published the ID yet. If that is this error, then please " + "ignore it." // OpenBug creates a new ticket in Bugzilla. The bug will have attached to it // a file containing line delimited issuer:serial pairs, a file the proposed // JSON insertion into OneCRL, and a file which shows the CCADB representation // as well as the OneCRL representation side-by-side. // // If configured, then emails in BugzillaCcAccounts will be put on CC. func (u *Updater) OpenBug() transaction.Transactor { u.bugID = -1 return transaction.NewTransaction().WithCommit(func() error { // Human readable, line delimited, "issuer: %s serial: %s" issuerSerialPairs := "" proposedAdditions := make([]*onecrl.Record, 0) for _, record := range u.changes { issuerSerialPairs += fmt.Sprintf("issuer: %s serial: %s\n", record.IssuerName, record.SerialNumber) proposedAdditions = append(proposedAdditions, record) } // Try to read the environment variable that declares a list of Bugzilla accounts to put on CC. _cc, err := csv.NewReader(strings.NewReader(os.Getenv(BugzillaCcAccounts))).ReadAll() if err != nil { log.WithError(err). WithField("BugzillaCcAccounts", os.Getenv(BugzillaCcAccounts)). Error("the CC environment variable appears to be malformed") return err } // The CSV parser is always going to return a [][]string, but really // we only want the first "row". var cc []string = nil if len(_cc) > 0 { cc = _cc[0] log.WithField("CC", cc).Debug("using CC environment variable") } bug := &bugs.Create{ Product: "Core", Component: "Security Block-lists, Allow-lists, and other State", Summary: fmt.Sprintf("CCADB entries generated %s", time.Now().UTC().Format(time.RFC3339)), Version: "unspecified", Severity: "normal", Type: "enhancement", Description: "Adding entries to OneCRL based on revoked intermediate certificates reported in the CCADB.", Cc: cc, } log.WithField("payload", bug).Debug("sending bugzilla creation payload") resp, err := u.bugzilla.CreateBug(bug) if err != nil { log.WithError(err).Error("bugzilla create failed") return errors.WithStack(err) } log.WithField("id", resp.Id). WithField("url", u.bugzilla.ShowBug(resp.Id)). Debug("created bugzilla ticket") u.bugID = resp.Id for _, record := range u.changes { record.Details.Bug = u.bugzilla.ShowBug(u.bugID) } log.WithField("issuerSerialPairs", issuerSerialPairs).Debug("attempting to post issuer/serial pairs") _, err = u.bugzilla.CreateAttachment((&attachments.Create{ BugId: resp.Id, Data: []byte(issuerSerialPairs), FileName: "BugData.txt", Summary: "Line delimited issuer/serial pairs", ContentType: "text/plain", }).AddBug(resp.Id)) if err != nil { log.WithError(err).WithField("attachment", "BugData.txt").Warn(attachmentWarning) } additions, err := json.MarshalIndent(proposedAdditions, "", " ") log.WithField("additions", proposedAdditions).Debug("attempting to post proposed OneCRL additions") if err != nil { return errors.WithStack(err) } _, err = u.bugzilla.CreateAttachment((&attachments.Create{ BugId: resp.Id, Data: additions, FileName: "OneCRLAdditions.txt", Summary: "The additions to OneCRL proposed by this bug.", ContentType: "text/plain", }).AddBug(resp.Id)) if err != nil { log.WithError(err).WithField("attachment", "OneCRLAdditions.txt").Warn(attachmentWarning) } comparisons := make([]interface{}, 0) for _, record := range u.changes { d, err := record.ToComparison() if err != nil { log.WithField("record", record). WithError(err). Error("failed to generate a OneCRL/CCADB comparison") return errors.WithStack(err) } comparisons = append(comparisons, d) } d, err := json.MarshalIndent(comparisons, "", " ") if err != nil { return errors.WithStack(err) } log.WithField("comparison", comparisons).Debug("attempting to post OneCRL/CCADB comparison") _, err = u.bugzilla.CreateAttachment((&attachments.Create{ BugId: resp.Id, Data: d, FileName: "DecodedEntries.txt", Summary: "Entries with their names decoded to plain text and hexadecimal serials/hashes.", ContentType: "text/plain", }).AddBug(resp.Id)) if err != nil { log.WithError(err).WithField("attachment", "DecodedEntries.txt").Warn(attachmentWarning) } return nil }).WithRollback(func(cause error) error { if u.bugID == -1 { return nil } report := &strings.Builder{} logger := log.New() logger.SetFormatter(&log.JSONFormatter{PrettyPrint: true}) logger.SetOutput(report) logger.WithError(cause). WithField("stacktrace", fmt.Sprintf("%+v", cause)). // "%+v" gets us a stack trace printed out Error("This tool experienced a fatal error downstream of posting this bug. This bug will be " + "closed. Please review the provided cause and call site of the cause for more information.") log.WithError(cause).WithField("bugzilla", u.bugzilla.ShowBug(u.bugID)).Error("closing the listed " + "bug due to a critical failure") _, err := u.bugzilla.UpdateBug(bugs.Invalidate(u.bugID, report.String())) return errors.WithStack(err) }) } // After we have created the bug in question on Bugzilla, we need to go back to // staging and update the records with the Bugzilla ID. func (u *Updater) UpdateRecordsWithBugID() transaction.Transactor { return transaction.NewTransaction().WithCommit(func() error { collection := StagingCollection() for _, record := range u.changes { if record == nil { continue } err := u.staging.UpdateRecord(collection, record) if err != nil { return errors.WithStack(err) } } return nil }).WithRollback(func(_ error) error { // Upstream transactions are going to delete these changes // anyways, so I don't really see much of anything to do here. return nil }) } func (u *Updater) PutStagingIntoReview() transaction.Transactor { return transaction.NewTransaction().WithCommit(func() error { return errors.WithStack(u.staging.ToReview(StagingCollection())) }).WithRollback(func(_ error) error { return errors.WithStack(u.staging.ToRollBack(StagingCollection())) }) } func (u *Updater) PushToProduction() transaction.Transactor { return transaction.NewTransaction().WithCommit(func() error { collection := ProductionCollection() for _, record := range u.changes { // If we do not set the ID back to default then production will // end up having IDs that were generated by staging rather than itself. record.Id = "" err := u.production.NewRecord(collection, record) if err != nil { return errors.WithStack(err) } } return nil }) } func (u *Updater) PutProductionIntoReview() transaction.Transactor { return transaction.NewTransaction().WithCommit(func() error { return errors.WithStack(u.production.ToReview(ProductionCollection())) }).WithRollback(func(_ error) error { return errors.WithStack(u.production.ToRollBack(ProductionCollection())) }) } func (u *Updater) BlastEmails(intersection *onecrl.Set) { bugIDs := make(map[int]bool, 0) builder := strings.Builder{} builder.WriteString("Changes are still in review. The following bugs appear to require resolution.\n") for e := range intersection.Iter() { entry := e.(*onecrl.Record) id, err := u.bugzilla.IDFromShowBug(entry.Details.Bug) if err != nil { log.WithError(err). WithField("url", entry.Details.Bug). Error("failed to retrieve bugzilla ID number from URL") continue } if bugIDs[id] { continue } builder.WriteByte('\t') builder.WriteString(entry.Details.Bug) bugIDs[id] = true } for id := range bugIDs { _, err := u.bugzilla.UpdateBug(&bugs.Update{ Id: id, Ids: []int{id}, Comment: &bugs.Comment{Body: builder.String()}, }) if err != nil { log.WithError(err).WithField("ID", id).Warn("failed to ping blocking bug") } } }