Repository: zmap/zlint
Pull request #372 (2020-01-23 16:02:06)

zlint: refactor lint reg., allow filtering lints used.
This replaces the exported `lint.LintMap` field (`map[string]*Lint`) that was used by `RegisterLint` with a more robust solution based around a `Registry` interface. This allows ZLint users to include/exclude lints by name or source.

## Top Level API

The `lint.RegisterLint` function remains the same, meaning individual lints are not changed. Lints that call this function are added to the global registry. Clients can access this registry with `lint.GlobalRegistry()`. Similarly the top level `zlint.LintCertificate` remains unchanged. It lints the provided cert with all lints in the global registry.

The old `zlint.LintCertificateFiltered` function that accepted a lint name regex to filter the lints applied is replaced by a new function `zlint.LintCertificateEx` that allows specifying a `lint.Registry` explicitly. The same regex filtering can be done by pre-filtering the provided registry (See notes below).

The `lint.Source` type was changed from an int enum to a string enum. This makes it easier to work with as a consumer (e.g. via command line flags, and JSON output) and since the number of lints (and sources) is small the benefits to using an int enum type are minimal. The serialized form of Lints now includes the `Source` field in the output as `"source"`.

## Registry

The `Registry` interface also allows finding all lint names with `Names()`, finding all lint sources with `Sources()`, finding a specific lint by name with `ByName()`, and finding all lints for a given source
with `BySource()`.

The `zlint.EncodeLintDescriptionsToJSON` function is now implemented by the `Registry` interface as `WriteJSON`. This makes it easier to encode a subset of the Registry's lints by filtering the global registry.

~Like before (with the exported `map[string]*Lint`) the registry is not safe for concurrent updates. That's fine for the current ZLint codebase but is something we may want to consider addressing in the future.~ _Edit: I decided it made sense to add locking to future proof the implementation for thread safety, see 6072e24 The implementation in this branch is now safe for concurrent access/registration_

## Registry Filtering

Filtering of lints to be run is now done with the `lint.Registry.Filter` function and corresponding `lint.FilterOptions` type. This allows filtering a registry to include/exclude lints by name (or using a name regex), and to include/exclude lints by source.

By filtering the global registry and then providing it explicitly to `zlint.LintCertificateEx` callers have control over exactly what lints will be applied.

Filtering operations are applied with the following precedence: excludes by source > includes by source > excludes by name > includes by name.

E.g. excluding a source and then trying to include a lint in that excluded source by name will not work. The source exclusion happens first.

## ZLint CMD Updates

The `zlint` command (`cmd/zlint/main.go`) is updated to add four new command line flags:

1. `-list-lints-sources` - Prints a list of lint sources, one per line.
2. `-excludeSources` - Comma-separated list of lint sources to exclude.
3. `-includeSources` - Comma-separated list of lint sources to include.
4. `-nameFilter` - Regex used to match lint names to include (cannot be used at the same time as `-excludeSources` or `-includeSources)

Two existing flags are renamed:

1. `-include` becomes `-includeNames`
2. `-exclude` becomes `-excludeNames`.

Notably all three list flags (`-list-lints-json`, `-list-lints-schema` and `-list-lints-sources`) now operate **after** applying the include/exclude filters, allowing an easy way to find which lints/sources will be run with the filtered command line flags in use. 

## Integration Test Updates

Matching the `zlint` command the integration test (`integration/integration_test.go`) command line flags are updated to allow including/excluding lints by source.

Resolves https://github.com/zmap/zlint/issues/344

cpu: Based on some out of band feedback I'm going to try and restructure the `Registry` into a `Linter` type and remove the `zlint.LintCertificate` / `zlint.LintCertificateEx` package functions, instead defining the lint operation as a function on the linter type, which can ben constructed similar to a registry in this branch. Will hopefully push another commit in the next week. If folks have additional feedback it may be best to wait until after that lands so we're all on the same page.
cardonator: Thanks for all your hard work on this, @cpu. It's looking really good so far. 

With the large amount of changes in this PR, would it also be possible to add a flag that tells LintCertificate to only return lints that met some condition? I.E. in my case, I only care about lints that had a status that wasn't NA or Pass. It would be nice if LintCertificate could be instructed to only return those lints and not every lint that ran. This would be particularly helpful when using the CLI as the output of the tool is pretty hard to parse within a terminal.
cpu: > Thanks for all your hard work on this, @cpu. It's looking really good so far.

Thanks for taking a look!

> would it also be possible to add a flag that tells LintCertificate to only return lints that met some condition?

@cardonator That seems like a reasonable request. I had to implement this sort of filtering in the CFSSL ZLint integration: https://github.com/cloudflare/cfssl/blob/644917271238216c94866f021e2b24ce54555848/signer/local/local.go#L183-L185

I'll see about rolling that in to this branch (or will file a follow-up issue if I don't get to it).
cpu: > Based on some out of band feedback I'm going to try and restructure the Registry into a Linter type and remove the zlint.LintCertificate / zlint.LintCertificateEx package functions, instead defining the lint operation as a function on the linter type, which can be constructed similar to a registry in this branch.

I spent some time fiddling with this (WIP/broken code here: https://github.com/cpu/zlint/tree/cpu-refactors-lint-registration-again). My experience is that the idea doesn't play nicely with the way lint registration is handled, and the split between the `zlint` and `lint` packages. Ultimately I couldn't get something working that wasn't a big re-architecture and worked well for consumers. The linked branch comes close but since consumers don't need to import the `zlint` package in that design (e.g. see `cmd/zlint/main.go`) no lints are actually registered.

I'm still happy with the design in this PR w/ the `Registry` type. It strikes me as a good compromise between having the features we want and maintaining the existing approach to registering lints and the existing subpackages. I vote we move forward with this branch. If folks disagree I'd be happy to close this PR and let someone else take a crack at a proposed redesign.
dadrian: Overall, I think this is really good @cpu. If we can clear up CI, I'm happy to merge.
cpu: > If we can clear up CI, I'm happy to merge.

@dadrian I think the CI failure was unrelated to the code. Yesterday I accidentally pushed my branch to the `zmap` repo first (this PR is from my fork). When I deleted the `zmap` copy of the branch I think [it confused Travis](https://travis-ci.org/zmap/zlint/builds/648569854?utm_source=github_status&utm_medium=notification):

```
$ git clone --depth=50 --branch=cpu-refactors-lint-registration https://github.com/zmap/zlint.git zmap/zlint
Cloning into 'zmap/zlint'...
warning: Could not find remote branch cpu-refactors-lint-registration to clone.
fatal: Remote branch cpu-refactors-lint-registration not found in upstream origin
```

It looks like CI is [happy again](https://travis-ci.org/zmap/zlint/builds/648958295) as of d4cbcd6.

cpu: > I'm happy to merge.

One other note: can I request this gets squash merged with the PR desc as the commit message? I think that will be the cleanest. Alternatively I can rebase and squash to one commit before merging but I think it's easier to let Github do the work.

Patch:
diff --git a/cmd/zlint/main.go b/cmd/zlint/main.go
index 7551a907..385c8bce 100644
--- a/cmd/zlint/main.go
+++ b/cmd/zlint/main.go
@@ -23,6 +23,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"os"
+	"regexp"
 	"sort"
 	"strings"
 
@@ -34,22 +35,28 @@ import (
 
 var ( // flags
 	listLintsJSON   bool
-	listLintsSchema bool
+	listLintSources bool
 	prettyprint     bool
 	format          string
-	include         string
-	exclude         string
+	nameFilter      string
+	includeNames    string
+	excludeNames    string
+	includeSources  string
+	excludeSources  string
 
 	// version is replaced by GoReleaser using an LDFlags option at release time.
 	version = "dev"
 )
 
 func init() {
-	flag.BoolVar(&listLintsJSON, "list-lints-json", false, "Print supported lints in JSON format, one per line")
-	flag.BoolVar(&listLintsSchema, "list-lints-schema", false, "Print supported lints as a ZSchema")
+	flag.BoolVar(&listLintsJSON, "list-lints-json", false, "Print lints in JSON format, one per line")
+	flag.BoolVar(&listLintSources, "list-lints-source", false, "Print list of lint sources, one per line")
 	flag.StringVar(&format, "format", "pem", "One of {pem, der, base64}")
-	flag.StringVar(&include, "include", "", "Comma-separated list of lints to include by name")
-	flag.StringVar(&exclude, "exclude", "", "Comma-separated list of lints to exclude by name")
+	flag.StringVar(&nameFilter, "nameFilter", "", "Only run lints with a name matching the provided regex. (Can not be used with -includeNames/-excludeNames)")
+	flag.StringVar(&includeNames, "includeNames", "", "Comma-separated list of lints to include by name")
+	flag.StringVar(&excludeNames, "excludeNames", "", "Comma-separated list of lints to exclude by name")
+	flag.StringVar(&includeSources, "includeSources", "", "Comma-separated list of lint sources to include")
+	flag.StringVar(&excludeSources, "excludeSources", "", "Comma-separated list of lint sources to exclude")
 
 	flag.BoolVar(&prettyprint, "pretty", false, "Pretty-print output")
 	flag.Usage = func() {
@@ -62,32 +69,30 @@ func init() {
 }
 
 func main() {
+	// Build a registry of lints using the include/exclude lint name and source
+	// flags.
+	registry, err := setLints()
+	if err != nil {
+		log.Fatalf("unable to configure included/exclude lints: %v\n", err)
+	}
 
 	if listLintsJSON {
-		zlint.EncodeLintDescriptionsToJSON(os.Stdout)
+		registry.WriteJSON(os.Stdout)
 		return
 	}
 
-	if listLintsSchema {
-		names := make([]string, 0, len(lint.Lints))
-		for lintName := range lint.Lints {
-			names = append(names, lintName)
-		}
-		sort.Strings(names)
-		fmt.Printf("Lints = SubRecord({\n")
-		for _, lintName := range names {
-			fmt.Printf("    \"%s\":LintBool(),\n", lintName)
+	if listLintSources {
+		sources := registry.Sources()
+		sort.Sort(sources)
+		for _, source := range sources {
+			fmt.Printf("    %s\n", source)
 		}
-		fmt.Printf("})\n")
 		return
 	}
 
-	// include/exclude lints based on flags
-	setLints()
-
 	var inform = strings.ToLower(format)
 	if flag.NArg() < 1 || flag.Arg(0) == "-" {
-		doLint(os.Stdin, inform)
+		doLint(os.Stdin, inform, registry)
 	} else {
 		for _, filePath := range flag.Args() {
 			var inputFile *os.File
@@ -104,13 +109,13 @@ func main() {
 				fileInform = "pem"
 			}
 
-			doLint(inputFile, fileInform)
+			doLint(inputFile, fileInform, registry)
 			inputFile.Close()
 		}
 	}
 }
 
-func doLint(inputFile *os.File, inform string) {
+func doLint(inputFile *os.File, inform string, registry lint.Registry) {
 	fileBytes, err := ioutil.ReadAll(inputFile)
 	if err != nil {
 		log.Fatalf("unable to read file %s: %s", inputFile.Name(), err)
@@ -140,7 +145,7 @@ func doLint(inputFile *os.File, inform string) {
 		log.Fatalf("unable to parse certificate: %s", err)
 	}
 
-	zlintResult := zlint.LintCertificate(c)
+	zlintResult := zlint.LintCertificateEx(c, registry)
 	jsonBytes, err := json.Marshal(zlintResult.Results)
 	if err != nil {
 		log.Fatalf("unable to encode lints JSON: %s", err)
@@ -158,58 +163,50 @@ func doLint(inputFile *os.File, inform string) {
 	os.Stdout.Sync()
 }
 
-func setLints() {
-	if include != "" && exclude != "" {
-		log.Fatal("unable to use include and exclude flag at the same time")
+// trimmedList takes a comma separated string argument in raw, splits it by
+// comma, and returns a list of the separated elements after trimming spaces
+// from each element.
+func trimmedList(raw string) []string {
+	var list []string
+	for _, item := range strings.Split(raw, ",") {
+		list = append(list, strings.TrimSpace(item))
 	}
-
-	includeLints()
-	excludeLints()
+	return list
 }
 
-func includeLints() {
-	if include == "" {
-		return
+// setLints returns a filtered registry to use based on the nameFilter,
+// includeNames, excludeNames, includeSources, and excludeSources flag values in
+// use.
+func setLints() (lint.Registry, error) {
+	// If there's no filter options set, use the global registry as-is
+	if nameFilter == "" && includeNames == "" && excludeNames == "" && includeSources == "" && excludeSources == "" {
+		return nil, nil
 	}
 
-	// parse includes to map for easier matching
-	var includes = strings.Split(include, ",")
-	var includesMap = make(map[string]struct{}, len(includes))
-	for _, includeName := range includes {
-		includeName = strings.TrimSpace(includeName)
-		if _, ok := lint.Lints[includeName]; !ok {
-			log.Fatalf("unknown lint %q in include list", includeName)
+	filterOpts := lint.FilterOptions{}
+	if nameFilter != "" {
+		r, err := regexp.Compile(nameFilter)
+		if err != nil {
+			return nil, fmt.Errorf("bad -nameFilter: %v", err)
 		}
-
-		includesMap[includeName] = struct{}{}
+		filterOpts.NameFilter = r
 	}
-
-	// clear all initialized lints except for includes
-	for lintName := range lint.Lints {
-		if _, ok := includesMap[lintName]; !ok {
-			delete(lint.Lints, lintName)
+	if excludeSources != "" {
+		if err := filterOpts.ExcludeSources.FromString(excludeSources); err != nil {
+			log.Fatalf("invalid -excludeSources: %v", err)
 		}
 	}
-}
-
-func excludeLints() {
-	if exclude == "" {
-		return
+	if includeSources != "" {
+		if err := filterOpts.IncludeSources.FromString(includeSources); err != nil {
+			log.Fatalf("invalid -includeSources: %v\n", err)
+		}
 	}
-
-	// parse excludes to map to get rid of duplicates
-	var excludes = strings.Split(exclude, ",")
-	var excludesMap = make(map[string]struct{}, len(excludes))
-	for _, excludeName := range excludes {
-		excludesMap[strings.TrimSpace(excludeName)] = struct{}{}
+	if excludeNames != "" {
+		filterOpts.ExcludeNames = trimmedList(excludeNames)
 	}
-
-	// exclude lints
-	for excludeName := range excludesMap {
-		if _, ok := lint.Lints[excludeName]; !ok {
-			log.Fatalf("unknown lint %q in exclude list", excludeName)
-		}
-
-		delete(lint.Lints, excludeName)
+	if includeNames != "" {
+		filterOpts.IncludeNames = trimmedList(includeNames)
 	}
+
+	return lint.GlobalRegistry().Filter(filterOpts)
 }
diff --git a/integration/README.md b/integration/README.md
index 593a78d4..0e01d8da 100644
--- a/integration/README.md
+++ b/integration/README.md
@@ -46,19 +46,23 @@ Config options
 
 * `-parallelism` - number of linting Go routines to spawn (_Default: 5_)
 
-* `-configFile` - integration test config file (_Default `integration/config.json`_)
+* `-config` - integration test config file (_Default `integration/config.json`_)
 
 * `-forceDownload` - ignore cached data files on disk forcing it to be downloaded fresh (_Default false_)
 
 * `-overwriteExpected` - overwrite the expected results map in the `-configFile` with the results of the test run. This is useful when new lints or bugfixes are added and the changes in the results map have been vetted and are ready to be committed to the repository. (_Default false_)
 
-* `-fingerprintSummarize` - print a summary of all certificate fingerprints that had lint findings. Can be quite spammy with the default data set. (_Default false_)
+* `-fingerprintSummary` - print a summary of all certificate fingerprints that had lint findings. Can be quite spammy with the default data set. (_Default false_)
 
-* `-fingerprintFilterString` - only lint certificates with hex encoded fingerprints that match the provided regular expression (_Default none_)
+* `-fingerprintFilter` - only lint certificates with hex encoded fingerprints that match the provided regular expression (_Default none_)
 
-* `-lintSummarize` - print a summary of result type counts by lint name. (_Default false_)
+* `-lintSummary` - print a summary of result type counts by lint name. (_Default false_)
 
-* `-lintFilterString` - only lint certificates with lints that have a name that matches the provided regular expression (_Default: none_)
+* `-lintFilter` - only lint certificates with lints that have a name that matches the provided regular expression (_Default: none_)
+
+* `-includeSources` - only lint certificates with lints that specify a Source present in the comma separated list provided (case sensitive) (_Default: none_)
+
+* `-excludeSources` - only lint certificates with lints that do not specify a Source present in the comma separated list provided (case sensitive) (_Default: none_)
 
 * `-outputTick` - number of certificates to lint before printing a "." marker to output (_Default 1000_)
 
diff --git a/lint/base.go b/lint/base.go
index 9798e2c4..dc435f49 100644
--- a/lint/base.go
+++ b/lint/base.go
@@ -21,12 +21,6 @@ import (
 	"github.com/zmap/zlint/util"
 )
 
-var (
-	// Lints is a map of all known lints by name. Add a Lint to the map by calling
-	// RegisterLint.
-	Lints = make(map[string]*Lint)
-)
-
 // LintInterface is implemented by each Lint.
 type LintInterface interface {
 	// Initialize runs once per-lint. It is called during RegisterLint().
@@ -43,23 +37,6 @@ type LintInterface interface {
 	Execute(c *x509.Certificate) *LintResult
 }
 
-// An Enum to programmatically represent the source of a lint
-type LintSource int
-
-const (
-	UnknownLintSource LintSource = iota
-	CABFBaselineRequirements
-	RFC5280
-	RFC5480
-	RFC5891
-	ZLint
-	AWSLabs
-	EtsiEsi // ETSI - Electronic Signatures and Infrastructures (ESI)
-	CABFEVGuidelines
-	AppleCTPolicy          // https://support.apple.com/en-us/HT205280
-	MozillaRootStorePolicy // https://github.com/mozilla/pkipolicy
-)
-
 // A Lint struct represents a single lint, e.g.
 // "e_basic_constraints_not_critical". It contains an implementation of LintInterface.
 type Lint struct {
@@ -77,7 +54,7 @@ type Lint struct {
 	Citation string `json:"citation,omitempty"`
 
 	// Programmatic source of the check, BRs, RFC5280, or ZLint
-	Source LintSource `json:"-"`
+	Source LintSource `json:"source"`
 
 	// Lints automatically returns NE for all certificates where CheckApplies() is
 	// true but with NotBefore < EffectiveDate. This check is bypassed if
@@ -117,12 +94,3 @@ func (l *Lint) Execute(cert *x509.Certificate) *LintResult {
 	res := l.Lint.Execute(cert)
 	return res
 }
-
-// RegisterLint must be called once for each lint to be excuted. Duplicate lint
-// names are squashed. Normally, RegisterLint is called during init().
-func RegisterLint(l *Lint) {
-	if err := l.Lint.Initialize(); err != nil {
-		panic("could not initialize lint: " + l.Name + ": " + err.Error())
-	}
-	Lints[l.Name] = l
-}
diff --git a/lint/registration.go b/lint/registration.go
new file mode 100644
index 00000000..a51f76d8
--- /dev/null
+++ b/lint/registration.go
@@ -0,0 +1,351 @@
+/*
+ * ZLint Copyright 2020 Regents of the University of Michigan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package lint
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"regexp"
+	"sort"
+	"strings"
+	"sync"
+)
+
+// FilterOptions is a struct used by Registry.Filter to create a sub registry
+// containing only lints that meet the filter options specified.
+//
+// Source based exclusion/inclusion is evaluated before Lint name based
+// exclusion/inclusion. In both cases exclusion is processed before inclusion.
+//
+// Only one of NameFilter or IncludeNames/ExcludeNames can be provided at
+// a time.
+type FilterOptions struct {
+	// NameFilter is a regexp used to filter lints by their name. It is mutually
+	// exclusive with IncludeNames and ExcludeNames.
+	NameFilter *regexp.Regexp
+	// IncludeNames is a case sensitive list of lint names to include in the
+	// registry being filtered.
+	IncludeNames []string
+	// ExcludeNames is a case sensitive list of lint names to exclude from the
+	// registry being filtered.
+	ExcludeNames []string
+	// IncludeSource is a SourceList of LintSource's to be included in the
+	// registry being filtered.
+	IncludeSources SourceList
+	// ExcludeSources is a SourceList of LintSources's to be excluded in the
+	// registry being filtered.
+	ExcludeSources SourceList
+}
+
+// Empty returns true if the FilterOptions is empty and does not specify any
+// elements to filter by.
+func (opts FilterOptions) Empty() bool {
+	return opts.NameFilter == nil &&
+		len(opts.IncludeNames) == 0 &&
+		len(opts.ExcludeNames) == 0 &&
+		len(opts.IncludeSources) == 0 &&
+		len(opts.ExcludeSources) == 0
+}
+
+// Registry is an interface describing a collection of registered lints.
+// A Registry instance can be given to zlint.LintCertificateEx() to control what
+// lints are run for a given certificate.
+//
+// Typically users will interact with the global Registry returned by
+// GlobalRegistry(), or a filtered Registry created by applying FilterOptions to
+// the GlobalRegistry()'s Filter function.
+type Registry interface {
+	// Names returns a list of all of the lint names that have been registered
+	// in string sorted order.
+	Names() []string
+	// Sources returns a SourceList of registered LintSources. The list is not
+	// sorted but can be sorted by the caller with sort.Sort() if required.
+	Sources() SourceList
+	// ByName returns a pointer to the registered lint with the given name, or nil
+	// if there is no such lint registered in the registry.
+	ByName(name string) *Lint
+	// BySource returns a list of registered lints that have the same LintSource as
+	// provided (or nil if there were no such lints in the registry).
+	BySource(s LintSource) []*Lint
+	// Filter returns a new Registry containing only lints that match the
+	// FilterOptions criteria.
+	Filter(opts FilterOptions) (Registry, error)
+	// WriteJSON writes a description of each registered lint as
+	// a JSON object, one object per line, to the provided writer.
+	WriteJSON(w io.Writer)
+}
+
+// registryImpl implements the Registry interface to provide a global collection
+// of Lints that have been registered.
+type registryImpl struct {
+	sync.RWMutex
+	// lintsByName is a map of all registered lints by name.
+	lintsByName map[string]*Lint
+	// lintNames is a sorted list of all of the registered lint names. It is
+	// equivalent to collecting the keys from lintsByName into a slice and sorting
+	// them lexicographically.
+	lintNames []string
+	// lintsBySource is a map of all registered lints by source category. Lints
+	// are added to the lintsBySource map by RegisterLint.
+	lintsBySource map[LintSource][]*Lint
+}
+
+var (
+	// errNilLint is returned from registry.Register if the provided lint was nil.
+	errNilLint = errors.New("can not register a nil lint")
+	// errNilLintPtr is returned from registry.Register if the provided lint had
+	// a nil Lint field.
+	errNilLintPtr = errors.New("can not register a lint with a nil Lint pointer")
+	// errEmptyName is returned from registry.Register if the provided lint had an
+	// empty Name field.
+	errEmptyName = errors.New("can not register a lint with an empty Name")
+)
+
+// errDuplicateName is returned from registry.Register if the provided lint had
+// a Name field matching a lint that was previously registered.
+type errDuplicateName struct {
+	lintName string
+}
+
+func (e errDuplicateName) Error() string {
+	return fmt.Sprintf(
+		"can not register lint with name %q - it has already been registered",
+		e.lintName)
+}
+
+// errBadInit is returned from registry.Register if the provided lint's
+// Initialize function returned an error.
+type errBadInit struct {
+	lintName string
+	err      error
+}
+
+func (e errBadInit) Error() string {
+	return fmt.Sprintf(
+		"failed to register lint with name %q - failed to Initialize: %q",
+		e.lintName, e.err)
+}
+
+// register adds the provided lint to the Registry. If initialize is true then
+// the lint's Initialize() function will be called before registering the lint.
+//
+// An error is returned if the lint or lint's Lint pointer is nil, if the Lint
+// has an empty Name or if the Name was previously registered.
+func (r *registryImpl) register(l *Lint, initialize bool) error {
+	if l == nil {
+		return errNilLint
+	}
+	if l.Lint == nil {
+		return errNilLintPtr
+	}
+	if l.Name == "" {
+		return errEmptyName
+	}
+	if existing := r.ByName(l.Name); existing != nil {
+		return &errDuplicateName{l.Name}
+	}
+	if initialize {
+		if err := l.Lint.Initialize(); err != nil {
+			return &errBadInit{l.Name, err}
+		}
+	}
+	r.Lock()
+	defer r.Unlock()
+	r.lintNames = append(r.lintNames, l.Name)
+	r.lintsByName[l.Name] = l
+	r.lintsBySource[l.Source] = append(r.lintsBySource[l.Source], l)
+	sort.Strings(r.lintNames)
+	return nil
+}
+
+// ByName returns the Lint previously registered under the given name with
+// Register, or nil if no matching lint name has been registered.
+func (r *registryImpl) ByName(name string) *Lint {
+	r.RLock()
+	defer r.RUnlock()
+	return r.lintsByName[name]
+}
+
+// Names returns a list of all of the lint names that have been registered
+// in string sorted order.
+func (r *registryImpl) Names() []string {
+	r.RLock()
+	defer r.RUnlock()
+	return r.lintNames
+}
+
+// BySource returns a list of registered lints that have the same LintSource as
+// provided (or nil if there were no such lints).
+func (r *registryImpl) BySource(s LintSource) []*Lint {
+	r.RLock()
+	defer r.RUnlock()
+	return r.lintsBySource[s]
+}
+
+// Sources returns a SourceList of registered LintSources. The list is not
+// sorted but can be sorted by the caller with sort.Sort() if required.
+func (r *registryImpl) Sources() SourceList {
+	r.RLock()
+	defer r.RUnlock()
+	var results SourceList
+	for k := range r.lintsBySource {
+		results = append(results, k)
+	}
+	return results
+}
+
+// lintNamesToMap converts a list of lit names into a bool hashmap useful for
+// filtering. If any of the lint names are not known by the registry an error is
+// returned.
+func (r *registryImpl) lintNamesToMap(names []string) (map[string]bool, error) {
+	if len(names) == 0 {
+		return nil, nil
+	}
+
+	namesMap := make(map[string]bool, len(names))
+	for _, n := range names {
+		n = strings.TrimSpace(n)
+		if l := r.ByName(n); l == nil {
+			return nil, fmt.Errorf("unknown lint name %q", n)
+		}
+		namesMap[n] = true
+	}
+	return namesMap, nil
+}
+
+func sourceListToMap(sources SourceList) map[LintSource]bool {
+	if len(sources) == 0 {
+		return nil
+	}
+	sourceMap := make(map[LintSource]bool, len(sources))
+	for _, s := range sources {
+		sourceMap[s] = true
+	}
+	return sourceMap
+}
+
+// Filter creates a new Registry with only the lints that meet the FilterOptions
+// criteria included.
+//
+// FilterOptions are applied in the following order of precedence:
+//   ExcludeSources > IncludeSources > NameFilter > ExcludeNames > IncludeNames
+func (r *registryImpl) Filter(opts FilterOptions) (Registry, error) {
+	// If there's no filtering to be done, return the existing Registry.
+	if opts.Empty() {
+		return r, nil
+	}
+
+	filteredRegistry := NewRegistry()
+
+	sourceExcludes := sourceListToMap(opts.ExcludeSources)
+	sourceIncludes := sourceListToMap(opts.IncludeSources)
+
+	nameExcludes, err := r.lintNamesToMap(opts.ExcludeNames)
+	if err != nil {
+		return nil, err
+	}
+	nameIncludes, err := r.lintNamesToMap(opts.IncludeNames)
+	if err != nil {
+		return nil, err
+	}
+
+	if opts.NameFilter != nil && (len(nameExcludes) != 0 || len(nameIncludes) != 0) {
+		return nil, errors.New(
+			"FilterOptions.NameFilter cannot be used at the same time as " +
+				"FilterOptions.ExcludeNames or FilterOptions.IncludeNames")
+	}
+
+	for _, name := range r.Names() {
+		l := r.ByName(name)
+
+		if sourceExcludes != nil && sourceExcludes[l.Source] {
+			continue
+		}
+		if sourceIncludes != nil && !sourceIncludes[l.Source] {
+			continue
+		}
+		if opts.NameFilter != nil && !opts.NameFilter.MatchString(name) {
+			continue
+		}
+		if nameExcludes != nil && nameExcludes[name] {
+			continue
+		}
+		if nameIncludes != nil && !nameIncludes[name] {
+			continue
+		}
+
+		// when adding lints to a filtered registry we do not want Initialize() to
+		// be called a second time, so provide false as the initialize argument.
+		if err := filteredRegistry.register(l, false); err != nil {
+			return nil, err
+		}
+	}
+
+	return filteredRegistry, nil
+}
+
+// WriteJSON writes a description of each registered lint as
+// a JSON object, one object per line, to the provided writer.
+func (r *registryImpl) WriteJSON(w io.Writer) {
+	enc := json.NewEncoder(w)
+	enc.SetEscapeHTML(false)
+	for _, name := range r.Names() {
+		_ = enc.Encode(r.ByName(name))
+	}
+}
+
+// NewRegistry constructs a Registry implementation that can be used to register
+// lints.
+func NewRegistry() *registryImpl {
+	return &registryImpl{
+		lintsByName:   make(map[string]*Lint),
+		lintsBySource: make(map[LintSource][]*Lint),
+	}
+}
+
+// globalRegistry is the Registry used by all loaded lints that call
+// RegisterLint().
+var globalRegistry *registryImpl = NewRegistry()
+
+// RegisterLint must be called once for each lint to be executed. Normally,
+// RegisterLint is called from the Go init() function of a lint implementation.
+//
+// RegsterLint will call l.Lint's Initialize() function as part of the
+// registration process.
+//
+// IMPORTANT: RegisterLint will panic if given a nil lint, or a lint with a nil
+// Lint pointer, or if the lint's Initialize function errors, or if the lint
+// name matches a previously registered lint's name. These conditions all
+// indicate a bug that should be addressed by a developer.
+func RegisterLint(l *Lint) {
+	// RegisterLint always sets initialize to true. It's assumed this is called by
+	// the package init() functions and therefore must be doing the first
+	// initialization of a lint.
+	if err := globalRegistry.register(l, true); err != nil {
+		panic(fmt.Sprintf("RegisterLint error: %v\n", err.Error()))
+	}
+}
+
+// GlobalRegistry is the Registry used by RegisterLint and contains all of the
+// lints that are loaded.
+//
+// If you want to run only a subset of the globally registered lints use
+// GloablRegistry().Filter with FilterOptions to create a filtered
+// Registry.
+func GlobalRegistry() Registry {
+	return globalRegistry
+}
diff --git a/lint/source.go b/lint/source.go
new file mode 100644
index 00000000..28834da9
--- /dev/null
+++ b/lint/source.go
@@ -0,0 +1,132 @@
+package lint
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+)
+
+/*
+ * ZLint Copyright 2020 Regents of the University of Michigan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+// LintSource is a type representing a known lint source that lints cite
+// requirements from.
+type LintSource string
+
+const (
+	UnknownLintSource        LintSource = "Unknown"
+	RFC5280                  LintSource = "RFC5280"
+	RFC5480                  LintSource = "RFC5480"
+	RFC5891                  LintSource = "RFC5891"
+	CABFBaselineRequirements LintSource = "CABF_BR"
+	CABFEVGuidelines         LintSource = "CABF_EV"
+	MozillaRootStorePolicy   LintSource = "Mozilla"
+	AppleCTPolicy            LintSource = "Apple"
+	ZLint                    LintSource = "ZLint"
+	AWSLabs                  LintSource = "AWSLabs"
+	EtsiEsi                  LintSource = "ETSI_ESI"
+)
+
+// UnmarshalJSON implements the json.Unmarshaler interface. It ensures that the
+// unmarshaled value is a known LintSource.
+func (s *LintSource) UnmarshalJSON(data []byte) error {
+	var throwAway string
+	if err := json.Unmarshal(data, &throwAway); err != nil {
+		return err
+	}
+
+	switch LintSource(throwAway) {
+	case RFC5280, RFC5480, RFC5891, CABFBaselineRequirements, CABFEVGuidelines, MozillaRootStorePolicy, AppleCTPolicy, ZLint, AWSLabs, EtsiEsi:
+		*s = LintSource(throwAway)
+		return nil
+	default:
+		*s = UnknownLintSource
+		return fmt.Errorf("unknown LintSource value %q", throwAway)
+	}
+}
+
+// FromString sets the LintSource value based on the source string provided
+// (case sensitive). If the src string does not match any of the known
+// LintSource's then s is set to the UnknownLintSource.
+func (s *LintSource) FromString(src string) {
+	// Start with the unknown lint source
+	*s = UnknownLintSource
+	// Trim space and try to match a known value
+	src = strings.TrimSpace(src)
+	switch LintSource(src) {
+	case RFC5280:
+		*s = RFC5280
+	case RFC5480:
+		*s = RFC5480
+	case RFC5891:
+		*s = RFC5891
+	case CABFBaselineRequirements:
+		*s = CABFBaselineRequirements
+	case CABFEVGuidelines:
+		*s = CABFEVGuidelines
+	case MozillaRootStorePolicy:
+		*s = MozillaRootStorePolicy
+	case AppleCTPolicy:
+		*s = AppleCTPolicy
+	case ZLint:
+		*s = ZLint
+	case AWSLabs:
+		*s = AWSLabs
+	case EtsiEsi:
+		*s = EtsiEsi
+	}
+}
+
+// SourceList is a slice of LintSources that can be sorted.
+type SourceList []LintSource
+
+// Len returns the length of the list.
+func (l SourceList) Len() int {
+	return len(l)
+}
+
+// Swap swaps the LintSource at index i and j in the list.
+func (l SourceList) Swap(i, j int) {
+	l[i], l[j] = l[j], l[i]
+}
+
+// Less compares the LintSources at index i and j lexicographically.
+func (l SourceList) Less(i, j int) bool {
+	return l[i] < l[j]
+}
+
+// FromString populates a SourceList (replacing any existing content) with the
+// comma separated list of sources provided in raw. If any of the comma
+// separated values are not known LintSource's an error is returned.
+func (l *SourceList) FromString(raw string) error {
+	// Start with an empty list
+	*l = SourceList{}
+
+	values := strings.Split(raw, ",")
+	for _, val := range values {
+		val = strings.TrimSpace(val)
+		if val == "" {
+			continue
+		}
+		// Populate the LintSource with the trimmed value.
+		var src LintSource
+		src.FromString(val)
+		// If the LintSource is UnknownLintSource then return an error.
+		if src == UnknownLintSource {
+			return fmt.Errorf("unknown lint source in list: %q", val)
+		}
+		*l = append(*l, src)
+	}
+	return nil
+}
diff --git a/makefile b/makefile
index 0a152c4b..e244162b 100644
--- a/makefile
+++ b/makefile
@@ -6,6 +6,8 @@ PARALLELISM := 5
 #   make integration INT_FLAGS="-overwriteExpected -config custom.config.json"
 #   make integration INT_FLAGS="-fingerprintSummary -lintSummary -fingerprintFilter='^[ea]' -lintFilter='^w_ext_cert_policy_explicit_text_not_utf8' -config small.config.json"
 #   make integration INT_FLAGS="-lintSummary -fingerprintSummary -lintFilter='^e_' -config small.config.json"
+#   make integration INT_FLAGS="-lintSummary -fingerprintSummary -excludeSources='Mozilla,ETSI_ESI' -config small.config.json"
+#   make integration INT_FLAGS="-includeSources='Mozilla,ETSI_ESI' -config small.config.json"
 INT_FLAGS :=
 
 CMDS = zlint zlint-gtld-update
diff --git a/resultset.go b/resultset.go
new file mode 100644
index 00000000..1e74b39e
--- /dev/null
+++ b/resultset.go
@@ -0,0 +1,58 @@
+/*
+ * ZLint Copyright 2020 Regents of the University of Michigan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package zlint
+
+import (
+	"github.com/zmap/zcrypto/x509"
+	"github.com/zmap/zlint/lint"
+)
+
+// ResultSet contains the output of running all lints in a registry against
+// a single certificate.
+type ResultSet struct {
+	Version         int64                       `json:"version"`
+	Timestamp       int64                       `json:"timestamp"`
+	Results         map[string]*lint.LintResult `json:"lints"`
+	NoticesPresent  bool                        `json:"notices_present"`
+	WarningsPresent bool                        `json:"warnings_present"`
+	ErrorsPresent   bool                        `json:"errors_present"`
+	FatalsPresent   bool                        `json:"fatals_present"`
+}
+
+// Execute lints the given certificate with all of the lints in the provided
+// registry. The ResultSet is mutated to trace the lint results obtained from
+// linting the certificate.
+func (z *ResultSet) execute(cert *x509.Certificate, registry lint.Registry) {
+	z.Results = make(map[string]*lint.LintResult, len(registry.Names()))
+	// Run each lints from the registry.
+	for _, name := range registry.Names() {
+		res := registry.ByName(name).Execute(cert)
+		z.Results[name] = res
+		z.updateErrorStatePresent(res)
+	}
+}
+
+func (z *ResultSet) updateErrorStatePresent(result *lint.LintResult) {
+	switch result.Status {
+	case lint.Notice:
+		z.NoticesPresent = true
+	case lint.Warn:
+		z.WarningsPresent = true
+	case lint.Error:
+		z.ErrorsPresent = true
+	case lint.Fatal:
+		z.FatalsPresent = true
+	}
+}
diff --git a/zlint.go b/zlint.go
index 1bc74f3c..4f4808ca 100644
--- a/zlint.go
+++ b/zlint.go
@@ -17,9 +17,6 @@
 package zlint
 
 import (
-	"encoding/json"
-	"io"
-	"regexp"
 	"time"
 
 	"github.com/zmap/zcrypto/x509"
@@ -35,74 +32,30 @@ import (
 
 const Version int64 = 3
 
-// ResultSet contains the output of running all lints against a single certificate.
-type ResultSet struct {
-	Version         int64                       `json:"version"`
-	Timestamp       int64                       `json:"timestamp"`
-	Results         map[string]*lint.LintResult `json:"lints"`
-	NoticesPresent  bool                        `json:"notices_present"`
-	WarningsPresent bool                        `json:"warnings_present"`
-	ErrorsPresent   bool                        `json:"errors_present"`
-	FatalsPresent   bool                        `json:"fatals_present"`
-}
-
-func (z *ResultSet) execute(cert *x509.Certificate, filter *regexp.Regexp) {
-	z.Results = make(map[string]*lint.LintResult, len(lint.Lints))
-	for name, l := range lint.Lints {
-		if filter != nil && !filter.MatchString(name) {
-			continue
-		}
-		res := l.Execute(cert)
-		z.Results[name] = res
-		z.updateErrorStatePresent(res)
-	}
-}
-
-func (z *ResultSet) updateErrorStatePresent(result *lint.LintResult) {
-	switch result.Status {
-	case lint.Notice:
-		z.NoticesPresent = true
-	case lint.Warn:
-		z.WarningsPresent = true
-	case lint.Error:
-		z.ErrorsPresent = true
-	case lint.Fatal:
-		z.FatalsPresent = true
-	}
-}
-
-// EncodeLintDescriptionsToJSON outputs a description of each lint as JSON
-// object, one object per line.
-func EncodeLintDescriptionsToJSON(w io.Writer) {
-	enc := json.NewEncoder(w)
-	enc.SetEscapeHTML(false)
-	for _, lint := range lint.Lints {
-		_ = enc.Encode(lint)
-	}
-}
-
-// LintCertificate runs all registered lints on c, producing a ZLint.
+// LintCertificate runs all registered lints on c using default options,
+// producing a ResultSet.
+//
+// Using LintCertificate(c) is equivalent to calling LintCertificateEx(c, nil).
 func LintCertificate(c *x509.Certificate) *ResultSet {
-	// Instead of panicing on nil certificate, just returns nil and let the client
-	// panic when accessing ZLint, if they're into panicing.
-	if c == nil {
-		return nil
-	}
-
-	// Run all tests
-	return LintCertificateFiltered(c, nil)
+	// Run all lints from the global registry
+	return LintCertificateEx(c, nil)
 }
 
-// LintCertificateFiltered runs all lints with names matching the provided
-// regexp on c, producing a ResultSet.
-func LintCertificateFiltered(c *x509.Certificate, filter *regexp.Regexp) *ResultSet {
+// LintCertificateEx runs lints from the provided registry on c producing
+// a ResultSet. Providing an explicit registry allows the caller to filter the
+// lints that will be run. (See lint.Registry.Filter())
+//
+// If registry is nil then the global registry of all lints is used and this
+// function is equivalent to calling LintCertificate(c).
+func LintCertificateEx(c *x509.Certificate, registry lint.Registry) *ResultSet {
 	if c == nil {
 		return nil
 	}
-
-	// Run tests with provided filter
+	if registry == nil {
+		registry = lint.GlobalRegistry()
+	}
 	res := new(ResultSet)
-	res.execute(c, filter)
+	res.execute(c, registry)
 	res.Version = Version
 	res.Timestamp = time.Now().Unix()
 	return res


Test patch:
diff --git a/benchmarks_test.go b/benchmarks_test.go
index beb8bd8f..f801cd66 100644
--- a/benchmarks_test.go
+++ b/benchmarks_test.go
@@ -109,18 +109,20 @@ func BenchmarkZlint(b *testing.B) {
 		globalLintResult = lintResult
 	})
 
+	names := lint.GlobalRegistry().Names()
+
 	b.Run("Fast lints", func(b *testing.B) {
 		globalLintResult = &ResultSet{}
-		globalLintResult.Results = make(map[string]*lint.LintResult, len(lint.Lints))
+		globalLintResult.Results = make(map[string]*lint.LintResult, len(names))
 		b.ResetTimer()
 		for i := 0; i < b.N; i++ {
-			for key, value := range lint.Lints {
+			for _, key := range names {
 				switch key {
 				case "w_dnsname_underscore_in_trd", "e_dnsname_underscore_in_sld", "e_dnsname_hyphen_in_sld",
 					"w_dnsname_wildcard_left_of_public_suffix", "w_san_iana_pub_suffix_empty":
 					continue
 				}
-
+				value := lint.GlobalRegistry().ByName(key)
 				if !value.Lint.CheckApplies(x509Cert) {
 					continue
 				}
@@ -131,10 +133,10 @@ func BenchmarkZlint(b *testing.B) {
 
 	b.Run("Fastest lints", func(b *testing.B) {
 		globalLintResult = &ResultSet{}
-		globalLintResult.Results = make(map[string]*lint.LintResult, len(lint.Lints))
+		globalLintResult.Results = make(map[string]*lint.LintResult, len(names))
 		b.ResetTimer()
 		for i := 0; i < b.N; i++ {
-			for key, value := range lint.Lints {
+			for _, key := range names {
 				switch key {
 				case "w_dnsname_underscore_in_trd", "e_dnsname_underscore_in_sld", "e_dnsname_hyphen_in_sld",
 					"w_dnsname_wildcard_left_of_public_suffix", "w_san_iana_pub_suffix_empty",
@@ -148,7 +150,7 @@ func BenchmarkZlint(b *testing.B) {
 					"e_path_len_constraint_zero_or_less", "e_san_dns_name_includes_null_char":
 					continue
 				}
-
+				value := lint.GlobalRegistry().ByName(key)
 				if !value.Lint.CheckApplies(x509Cert) {
 					continue
 				}
@@ -157,8 +159,9 @@ func BenchmarkZlint(b *testing.B) {
 		}
 	})
 
-	for key, value := range lint.Lints {
+	for _, key := range names {
 		b.Run(key, func(b *testing.B) {
+			value := lint.GlobalRegistry().ByName(key)
 			if !value.Lint.CheckApplies(x509Cert) {
 				b.Skip("Check doesn't apply")
 			}
diff --git a/integration/corpus_test.go b/integration/corpus_test.go
index 7907fd1d..6798c959 100644
--- a/integration/corpus_test.go
+++ b/integration/corpus_test.go
@@ -24,7 +24,7 @@ func lintCertificate(work workItem) certResult {
 		Fingerprint: work.Fingerprint,
 		LintSummary: make(map[string]lint.LintStatus),
 	}
-	resultSet := zlint.LintCertificateFiltered(work.Certificate, lintFilter)
+	resultSet := zlint.LintCertificateEx(work.Certificate, registry)
 	for lintName, r := range resultSet.Results {
 		cr.LintSummary[lintName] = r.Status
 		cr.Result.Inc(r.Status)
diff --git a/integration/integration_test.go b/integration/integration_test.go
index 59e4f180..88b4a589 100644
--- a/integration/integration_test.go
+++ b/integration/integration_test.go
@@ -8,6 +8,8 @@ import (
 	"os"
 	"regexp"
 	"testing"
+
+	"github.com/zmap/zlint/lint"
 )
 
 var (
@@ -37,6 +39,8 @@ var (
 	// lintFilterString is a flag for controlling which lints are run against the test
 	// corpus.
 	lintFilterString = flag.String("lintFilter", "", "if not-empty only lints with a name that match the provided regexp will be run")
+	includeSources   = flag.String("includeSources", "", "Comma-separated list of lint sources to include")
+	excludeSources   = flag.String("excludeSources", "", "Comma-separated list of lint sources to exclude")
 	// outputTick is a flag for controlling the number of certificates that are
 	// linted before a '.' is printed in the console. This controls the mechanism
 	// used to keep Travis from thinking the job is dead because there hasn't been
@@ -52,6 +56,9 @@ var (
 	// fpFilter and lintFilter are regexps for filtering certificate fingerprints
 	// to be linted and lints to be run.
 	fpFilter, lintFilter *regexp.Regexp
+
+	// registry is the lint registry used. It may be filtered based on command line flags.
+	registry = lint.GlobalRegistry()
 )
 
 // TestMain loads the integration test config, validates it, and prepares the
@@ -84,6 +91,31 @@ func TestMain(m *testing.M) {
 		log.Fatalf("error processing config file %q: %v", *configFile, err)
 	}
 
+	// Configure filter options
+	filterOpts := lint.FilterOptions{}
+	if *excludeSources != "" {
+		if err := filterOpts.ExcludeSources.FromString(*excludeSources); err != nil {
+			log.Fatalf("invalid -excludeSources: %v", err)
+		}
+	}
+	if *includeSources != "" {
+		if err := filterOpts.IncludeSources.FromString(*includeSources); err != nil {
+			log.Fatalf("invalid -includeSources: %v\n", err)
+		}
+	}
+	if lintFilter != nil {
+		filterOpts.NameFilter = lintFilter
+	}
+
+	// If there were filter options configured apply them and update the registry
+	if !filterOpts.Empty() {
+		r, err := registry.Filter(filterOpts)
+		if err != nil {
+			log.Fatalf("failed to filter lint registry: %v\n", err)
+		}
+		registry = r
+	}
+
 	// Prepare cache, downloading data files if required (or if forced by user
 	// request with forceDownload)
 	if err := c.PrepareCache(*forceDownload); err != nil {
diff --git a/lint/base_test.go b/lint/base_test.go
index c1de2c80..20fe7221 100644
--- a/lint/base_test.go
+++ b/lint/base_test.go
@@ -21,28 +21,6 @@ import (
 	"github.com/zmap/zcrypto/x509"
 )
 
-func TestAllLintsHaveNameDescriptionSource(t *testing.T) {
-	for name, lint := range Lints {
-		if lint.Name == "" {
-			t.Errorf("lint %s has empty name", name)
-		}
-		if lint.Description == "" {
-			t.Errorf("lint %s has empty description", name)
-		}
-		if lint.Citation == "" {
-			t.Errorf("lint %s has empty citation", name)
-		}
-	}
-}
-
-func TestAllLintsHaveSource(t *testing.T) {
-	for name, lint := range Lints {
-		if lint.Source == UnknownLintSource {
-			t.Errorf("lint %s has unknown source", name)
-		}
-	}
-}
-
 func TestLintCheckEffective(t *testing.T) {
 	c := &x509.Certificate{
 		NotBefore: time.Now(),
diff --git a/lint/registration_test.go b/lint/registration_test.go
new file mode 100644
index 00000000..4b36bd19
--- /dev/null
+++ b/lint/registration_test.go
@@ -0,0 +1,389 @@
+package lint
+
+/*
+ * ZLint Copyright 2020 Regents of the University of Michigan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+import (
+	"errors"
+	"reflect"
+	"regexp"
+	"sort"
+	"testing"
+
+	"github.com/zmap/zcrypto/x509"
+)
+
+func TestAllLintsHaveNameDescriptionSource(t *testing.T) {
+	for _, name := range GlobalRegistry().Names() {
+		lint := GlobalRegistry().ByName(name)
+		if lint.Name == "" {
+			t.Errorf("lint %s has empty name", name)
+		}
+		if lint.Description == "" {
+			t.Errorf("lint %s has empty description", name)
+		}
+		if lint.Citation == "" {
+			t.Errorf("lint %s has empty citation", name)
+		}
+	}
+}
+
+func TestAllLintsHaveSource(t *testing.T) {
+	for _, name := range globalRegistry.Names() {
+		lint := GlobalRegistry().ByName(name)
+		if lint.Source == UnknownLintSource {
+			t.Errorf("lint %s has unknown source", name)
+		}
+	}
+}
+
+func TestFilterOptionsEmpty(t *testing.T) {
+	opts := FilterOptions{}
+	if !opts.Empty() {
+		t.Errorf("Empty FilterOptions wasn't Empty()")
+	}
+	opts.IncludeNames = []string{"whatever"}
+	if opts.Empty() {
+		t.Errorf("Non-empty FilterOptions was Empty()")
+	}
+}
+
+type mockLint struct {
+	initErr error
+}
+
+func (m mockLint) Initialize() error {
+	return m.initErr
+}
+
+func (m mockLint) CheckApplies(c *x509.Certificate) bool {
+	return true
+}
+
+func (m mockLint) Execute(c *x509.Certificate) *LintResult {
+	return nil
+}
+
+func TestRegister(t *testing.T) {
+	egLint := &Lint{
+		Name:   "mockLint",
+		Lint:   &mockLint{},
+		Source: ZLint,
+	}
+	dupeReg := NewRegistry()
+	_ = dupeReg.register(egLint, true)
+
+	badInitErr := errors.New("mock init error")
+	badInitLint := &Lint{
+		Name:   "badInitLint",
+		Lint:   &mockLint{badInitErr},
+		Source: ZLint,
+	}
+
+	testCases := []struct {
+		name          string
+		lint          *Lint
+		init          bool
+		registry      *registryImpl
+		expectErr     error
+		expectNames   []string
+		expectSources SourceList
+	}{
+		{
+			name:      "nil lint",
+			lint:      nil,
+			expectErr: errNilLint,
+		},
+		{
+			name:      "nil lint ptr",
+			lint:      &Lint{},
+			expectErr: errNilLintPtr,
+		},
+		{
+			name: "empty name",
+			lint: &Lint{
+				Lint: &mockLint{},
+			},
+			expectErr: errEmptyName,
+		},
+		{
+			name:      "duplicate name",
+			lint:      egLint,
+			registry:  dupeReg,
+			expectErr: &errDuplicateName{egLint.Name},
+		},
+		{
+			name:      "bad init with initialize",
+			lint:      badInitLint,
+			init:      true,
+			expectErr: &errBadInit{badInitLint.Name, badInitErr},
+		},
+		{
+			name:          "bad init with no initialize",
+			lint:          badInitLint,
+			init:          false,
+			expectNames:   []string{badInitLint.Name},
+			expectSources: SourceList{badInitLint.Source},
+		},
+		{
+			name: "good lint register",
+			lint: &Lint{
+				Name:   "goodLint",
+				Lint:   &mockLint{},
+				Source: MozillaRootStorePolicy,
+			},
+			registry:      dupeReg,
+			expectNames:   []string{"goodLint", egLint.Name},
+			expectSources: SourceList{MozillaRootStorePolicy, egLint.Source},
+		},
+	}
+
+	for _, tc := range testCases {
+		var reg *registryImpl
+		if tc.registry == nil {
+			reg = NewRegistry()
+		} else {
+			reg = tc.registry
+		}
+
+		err := reg.register(tc.lint, tc.init)
+		if err == nil && tc.expectErr != nil {
+			t.Errorf("expected err %v, got nil", tc.expectErr)
+		} else if err != nil && err.Error() != tc.expectErr.Error() {
+			t.Errorf("expected err %v got %v", tc.expectErr, err)
+		} else if err == nil {
+			if !reflect.DeepEqual(reg.Names(), tc.expectNames) {
+				t.Errorf("expected names %v, got %v", tc.expectNames, reg.Names())
+			}
+			sources := reg.Sources()
+			sort.Sort(sources)
+			if !reflect.DeepEqual(sources, tc.expectSources) {
+				t.Errorf("expected sources %v, got %v", tc.expectSources, sources)
+			}
+		}
+	}
+}
+
+func TestRegistryFilter(t *testing.T) {
+	testLint := func(name string, source LintSource) *Lint {
+		return &Lint{
+			Name:   name,
+			Source: source,
+			Lint:   &mockLint{},
+		}
+	}
+	mustRegister := func(r *registryImpl, l *Lint) {
+		if err := r.register(l, true); err != nil {
+			t.Fatalf("failed to register %v", err)
+		}
+	}
+
+	// Create a registry and add some test lints
+	registry := NewRegistry()
+
+	mustRegister(registry, testLint("e_mp_example1", MozillaRootStorePolicy))
+	mustRegister(registry, testLint("w_mp_example2", MozillaRootStorePolicy))
+	mustRegister(registry, testLint("n_mp_example3", MozillaRootStorePolicy))
+	mustRegister(registry, testLint("e_z_example1", ZLint))
+	mustRegister(registry, testLint("e_rfc_example1", RFC5280))
+	mustRegister(registry, testLint("w_rfc_example2", RFC5280))
+
+	onlyWarnRegex := regexp.MustCompile(`^w\_.*`)
+
+	// Up front, test that invalid FilterOptions provokes an err
+	_, err := registry.Filter(FilterOptions{
+		NameFilter:   onlyWarnRegex,
+		IncludeNames: []string{"e_mp_example_1"},
+	})
+	if err == nil {
+		t.Errorf("expected err from invalid FilterOptions, got nil")
+	}
+
+	testCases := []struct {
+		name              string
+		opts              FilterOptions
+		expectedLintNames []string
+		expectedSources   SourceList
+	}{
+		{
+			name: "Empty filter options",
+			expectedLintNames: []string{
+				"e_mp_example1", "e_rfc_example1", "e_z_example1", "n_mp_example3", "w_mp_example2", "w_rfc_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, RFC5280, ZLint,
+			},
+		},
+		{
+			name: "Filter by NameFilter only",
+			opts: FilterOptions{
+				NameFilter: onlyWarnRegex,
+			},
+			expectedLintNames: []string{
+				"w_mp_example2", "w_rfc_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, RFC5280,
+			},
+		},
+		{
+			name: "Filter by IncludeNames only",
+			opts: FilterOptions{
+				IncludeNames: []string{
+					"e_rfc_example1", "w_mp_example2",
+				},
+			},
+			expectedLintNames: []string{
+				"e_rfc_example1", "w_mp_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, RFC5280,
+			},
+		},
+		{
+			name: "Filter by ExcludeNames only",
+			opts: FilterOptions{
+				ExcludeNames: []string{
+					"e_rfc_example1", "w_mp_example2",
+				},
+			},
+			expectedLintNames: []string{
+				"e_mp_example1", "e_z_example1", "n_mp_example3", "w_rfc_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, RFC5280, ZLint,
+			},
+		},
+		{
+			name: "Filter by ExcludeNames and IncludeNames",
+			opts: FilterOptions{
+				ExcludeNames: []string{
+					"e_rfc_example1", "w_mp_example2",
+				},
+				IncludeNames: []string{
+					"e_rfc_example1", "e_z_example1",
+				},
+			},
+			expectedLintNames: []string{
+				"e_z_example1",
+			},
+			expectedSources: SourceList{
+				ZLint,
+			},
+		},
+		{
+			name: "Filter by IncludeSources only",
+			opts: FilterOptions{
+				IncludeSources: SourceList{
+					ZLint, RFC5280,
+				},
+			},
+			expectedLintNames: []string{
+				"e_rfc_example1", "e_z_example1", "w_rfc_example2",
+			},
+			expectedSources: SourceList{
+				RFC5280, ZLint,
+			},
+		},
+		{
+			name: "Filter by ExcludeSources only",
+			opts: FilterOptions{
+				ExcludeSources: SourceList{
+					RFC5280,
+				},
+			},
+			expectedLintNames: []string{
+				"e_mp_example1", "e_z_example1", "n_mp_example3", "w_mp_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, ZLint,
+			},
+		},
+		{
+			name: "Filter by IncludeSources and ExcludeSources",
+			opts: FilterOptions{
+				ExcludeSources: SourceList{
+					RFC5280,
+				},
+				IncludeSources: SourceList{
+					ZLint,
+				},
+			},
+			expectedLintNames: []string{
+				"e_z_example1",
+			},
+			expectedSources: SourceList{
+				ZLint,
+			},
+		},
+		{
+			name: "Filter by IncludeSources, ExcludeSources and NameFilter",
+			opts: FilterOptions{
+				NameFilter: onlyWarnRegex,
+				ExcludeSources: SourceList{
+					ZLint,
+				},
+				IncludeSources: SourceList{
+					MozillaRootStorePolicy,
+					RFC5280,
+				},
+			},
+			expectedLintNames: []string{
+				"w_mp_example2", "w_rfc_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, RFC5280,
+			},
+		},
+		{
+			name: "Filter by IncludeSources, ExcludeSources, IncludeNames and ExcludeNames",
+			opts: FilterOptions{
+				ExcludeSources: SourceList{
+					ZLint,
+				},
+				IncludeSources: SourceList{
+					MozillaRootStorePolicy,
+					RFC5280,
+				},
+				ExcludeNames: []string{"e_mp_example1"},
+				IncludeNames: []string{"e_rfc_example1", "w_mp_example2"},
+			},
+			expectedLintNames: []string{
+				"e_rfc_example1", "w_mp_example2",
+			},
+			expectedSources: SourceList{
+				MozillaRootStorePolicy, RFC5280,
+			},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := registry.Filter(tc.opts)
+			if err != nil {
+				t.Fatalf("Filter returned err for %v", tc.opts)
+			}
+
+			if !reflect.DeepEqual(result.Names(), tc.expectedLintNames) {
+				t.Errorf("expected post-Filter Names %v got %v", tc.expectedLintNames, result.Names())
+			}
+
+			sources := result.Sources()
+			sort.Sort(sources)
+			if !reflect.DeepEqual(sources, tc.expectedSources) {
+				t.Errorf("expected post-Filter Sources %v got %v", tc.expectedSources, sources)
+			}
+		})
+	}
+}
diff --git a/lint/source_test.go b/lint/source_test.go
new file mode 100644
index 00000000..857bc40f
--- /dev/null
+++ b/lint/source_test.go
@@ -0,0 +1,59 @@
+/*
+ * ZLint Copyright 2020 Regents of the University of Michigan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package lint
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"testing"
+)
+
+// TestLintSourceMarshal tests that a LintSource can be correctly marshaled and
+// unmarshalled.
+func TestLintSourceMarshal(t *testing.T) {
+	throwAway := struct {
+		Source LintSource
+	}{
+		Source: ZLint,
+	}
+
+	jsonBytes, err := json.Marshal(&throwAway)
+	if err != nil {
+		t.Fatalf("failed to marshal LintSource: %v", err)
+	}
+
+	expectedJSON := fmt.Sprintf(`{"Source":%q}`, ZLint)
+	if !bytes.Equal(jsonBytes, []byte(expectedJSON)) {
+		t.Fatalf("expected JSON %q got %q", expectedJSON, string(jsonBytes))
+	}
+
+	err = json.Unmarshal(jsonBytes, &throwAway)
+	if err != nil {
+		t.Fatalf("err unmarshalling prev. marshaled LintSource: %v", err)
+	}
+	if throwAway.Source != ZLint {
+		t.Fatalf("expected post-unmarshal value of %q got %q", ZLint, throwAway.Source)
+	}
+
+	badJSON := []byte(`{"Source":"cpu"}`)
+	err = json.Unmarshal(badJSON, &throwAway)
+	if err == nil {
+		t.Fatalf("expected err unmarshalling bad LintSource value. Got nil")
+	}
+	if throwAway.Source != UnknownLintSource {
+		t.Fatalf("expected Source to be %q after bad unmarshal, got %q\n", UnknownLintSource, throwAway.Source)
+	}
+}
diff --git a/test/helpers.go b/test/helpers.go
index 1c7d567b..e0611b64 100644
--- a/test/helpers.go
+++ b/test/helpers.go
@@ -44,11 +44,7 @@ func TestLint(lintName string, testCertFilename string) *lint.LintResult {
 // Important: TestLintCert is only appropriate for unit tests. It will panic if
 // the lintName is not known or if the lint result is nil.
 func TestLintCert(lintName string, cert *x509.Certificate) *lint.LintResult {
-	// NOTE(@cpu): Once `lint.Lints` is not exported this will have to be
-	// changed, likely to use a function like `lint.LintByName`. For now use the
-	// exported map directly to consolidate access to this one function instead of
-	// many individual lint unit tests.
-	l := lint.Lints[lintName]
+	l := lint.GlobalRegistry().ByName(lintName)
 	if l == nil {
 		panic(fmt.Sprintf(
 			"Lint name %q does not exist in lint.Lints. "+
diff --git a/zlint_test.go b/zlint_test.go
index 53a3d7fa..82d137a0 100644
--- a/zlint_test.go
+++ b/zlint_test.go
@@ -14,7 +14,7 @@ func TestLintNames(t *testing.T) {
 		"e_", // lints.Error
 	}
 
-	for name := range lint.Lints {
+	for _, name := range lint.GlobalRegistry().Names() {
 		var valid bool
 		for _, prefix := range allowedPrefixes {
 			if strings.HasPrefix(name, prefix) {