Repository: zmap/zlint
Pull request #278 (2019-04-06 17:37:30)

lints: count embedded SCTs, warn for Apple CT policy.
A new `ct_sct_policy_count_unsatisfied` lint is added that checks if subscriber certificates issued after October 15th 2018 have embedded SCTs from a sufficient number of unique CT logs to meet [Apple's CT log policy](https://support.apple.com/en-us/HT205280).

The number of required SCTs from different logs is calculated based on the Certificate's lifetime. If the number of required SCTs are not present a `Notice` level `LintResult` is returned.

| Certificate lifetime | # of SCTs from separate logs |
| ------------- | ------------- |
| Less than 15 months  | 2                            |
| 15 to 27 months      | 3                            |
| 27 to 39 months      | 4                            |
| More than 39 months  | 5                            |
 ------------------------------------------------------

**Lint note 1:** We can't know whether additional SCTs were presented alongside the certificate in some out of band manner. The new linter assumes only embedded SCTs are used and ignores the portion of the Apple policy related to SCTs delivered via OCSP or anywhere other than embedded in the certificate. This is one limitation that restricts the linter's findings to Notice level. See more background discussion in https://github.com/zmap/zlint/issues/226.

**Lint note 2:** The linter doesn't maintain a list of Apple's trusted logs. The SCTs embedded in the certificate may not be from logs Apple actually trusts. Similarly the embedded SCT signatures are not validated in any way.

 ------------------------------------------------------

**Note to reviewers**: 
To generate the PEM encoded test certificates I used in the unit tests I wrote a small stand-alone Go program: https://gist.github.com/cpu/6d26b2718f29e184ff88a90f02d7cbcb I think providing that in a Gist and linking to it in the testcase comments is better than putting it in-tree and should still be a fairly durable way to make sure the testcases are reproduceable. If you'd rather see a different approach taken I'm open to suggestions.

It's possible to take this work & develop it further by building out a pipeline similar to the one used in `zlint-gtld-update` that would process Apple and Chromium's CT policy JSON. That's likely to be a fair amount of work and I think should be sketched out in some follow-up issues.

Updates https://github.com/zmap/zlint/issues/226 

cpu: @zakird Are you comfortable with me merging this based on @dadrian's :+1: :mag: ? Generally speaking I'm not sure what the review requirements are for this repo. Should I hold this for your review?

Thanks!

Patch:
diff --git a/lints/base.go b/lints/base.go
index 678bb501..f4357115 100644
--- a/lints/base.go
+++ b/lints/base.go
@@ -55,6 +55,7 @@ const (
 	AWSLabs
 	EtsiEsi // ETSI - Electronic Signatures and Infrastructures (ESI)
 	CABFEVGuidelines
+	AppleCTPolicy // https://support.apple.com/en-us/HT205280
 )
 
 // A Lint struct represents a single lint, e.g.
diff --git a/lints/lint_ct_sct_policy_count_unsatisfied.go b/lints/lint_ct_sct_policy_count_unsatisfied.go
new file mode 100644
index 00000000..ef2d72af
--- /dev/null
+++ b/lints/lint_ct_sct_policy_count_unsatisfied.go
@@ -0,0 +1,154 @@
+/*
+ * ZLint Copyright 2019 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 lints
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/zmap/zcrypto/x509"
+	"github.com/zmap/zcrypto/x509/ct"
+	"github.com/zmap/zlint/util"
+)
+
+type sctPolicyCount struct{}
+
+// Initialize for a sctPolicyCount instance does nothing.
+func (l *sctPolicyCount) Initialize() error {
+	return nil
+}
+
+// CheckApplies returns true for any subscriber certificates.
+func (l *sctPolicyCount) CheckApplies(c *x509.Certificate) bool {
+	return util.IsSubscriberCert(c)
+}
+
+// Execute checks if the provided certificate has embedded SCTs from
+// a sufficient number of unique CT logs to meet Apple's CT log policy[0],
+// effective Oct 15th, 2018.
+//
+// The number of required SCTs from different logs is calculated based on the
+// Certificate's lifetime. If the number of required SCTs are not embedded in
+// the certificate a Notice level LintResult is returned.
+//
+// | Certificate lifetime | # of SCTs from separate logs |
+// -------------------------------------------------------
+// | Less than 15 months  | 2                            |
+// | 15 to 27 months      | 3                            |
+// | 27 to 39 months      | 4                            |
+// | More than 39 months  | 5                            |
+// -------------------------------------------------------
+//
+// Important note 1: We can't know whether additional SCTs were presented
+// alongside the certificate via OCSP stapling. This linter assumes only
+// embedded SCTs are used and ignores the portion of the Apple policy related to
+// SCTs delivered via OCSP. This is one limitation that restricts the linter's
+// findings to Notice level. See more background discussion in Issue 226[1].
+//
+// Important note 2: The linter doesn't maintain a list of Apple's trusted
+// logs. The SCTs embedded in the certificate may not be from log's Apple
+// actually trusts. Similarly the embedded SCT signatures are not validated
+// in any way.
+//
+// [0]: https://support.apple.com/en-us/HT205280
+// [1]: https://github.com/zmap/zlint/issues/226
+func (l *sctPolicyCount) Execute(c *x509.Certificate) *LintResult {
+	// Determine the required number of SCTs from separate logs
+	expected := appleCTPolicyExpectedSCTs(c)
+
+	// If there are no SCTs then the job is easy. We can return a Notice
+	// LintResult immediately.
+	if len(c.SignedCertificateTimestampList) == 0 && expected > 0 {
+		return &LintResult{
+			Status: Notice,
+			Details: fmt.Sprintf(
+				"Certificate had 0 embedded SCTs. Browser policy may require %d for this certificate.",
+				expected),
+		}
+	}
+
+	// Build a map from LogID to SCT so that we can count embedded SCTs by unique
+	// log.
+	sctsByLogID := make(map[ct.SHA256Hash]*ct.SignedCertificateTimestamp)
+	for _, sct := range c.SignedCertificateTimestampList {
+		sctsByLogID[sct.LogID] = sct
+	}
+
+	// If the number of embedded SCTs from separate logs meets expected return
+	// a Pass result.
+	if len(sctsByLogID) >= expected {
+		return &LintResult{Status: Pass}
+	}
+
+	// Otherwise return a Notice result - there weren't enough SCTs embedded in
+	// the certificate. More must be provided by OCSP stapling if the certificate
+	// is to meet Apple's CT policy.
+	return &LintResult{
+		Status: Notice,
+		Details: fmt.Sprintf(
+			"Certificate had %d embedded SCTs from distinct log IDs. "+
+				"Browser policy may require %d for this certificate.",
+			len(sctsByLogID), expected),
+	}
+}
+
+// appleCTPolicyExpectedSCTs returns a count of the number of SCTs expected to
+// be embedded in the given certificate based on its lifetime.
+//
+// For this function the relevant portion of Apple's policy is the table
+// "Number of embedded SCTs based on certificate lifetime" (Also reproduced in
+// the `Execute` godoc comment).
+func appleCTPolicyExpectedSCTs(cert *x509.Certificate) int {
+	// Lifetime is relative to the certificate's NotBefore date.
+	start := cert.NotBefore
+
+	// Thresholds is an ordered array of lifetime periods and their expected # of
+	// SCTs. A lifetime period is defined by the cutoff date relative to the
+	// start of the certificate's lifetime.
+	thresholds := []struct {
+		CutoffDate time.Time
+		Expected   int
+	}{
+		// Start date ... 15 months
+		{CutoffDate: start.AddDate(0, 15, 0), Expected: 2},
+		// Start date ... 27 months
+		{CutoffDate: start.AddDate(0, 27, 0), Expected: 3},
+		// Start date ... 39 months
+		{CutoffDate: start.AddDate(0, 39, 0), Expected: 4},
+	}
+
+	// If the certificate's lifetime falls into any of the cutoff date ranges then
+	// we expect that range's expected # of SCTs for this certificate. This loop
+	// assumes the `thresholds` list is sorted in ascending order.
+	for _, threshold := range thresholds {
+		if cert.NotAfter.Before(threshold.CutoffDate) {
+			return threshold.Expected
+		}
+	}
+
+	// The certificate had a validity > 39 months.
+	return 5
+}
+
+func init() {
+	RegisterLint(&Lint{
+		Name:          "ct_sct_policy_count_unsatisfied",
+		Description:   "Check if certificate has enough embedded SCTs to meet Apple CT Policy",
+		Citation:      "https://support.apple.com/en-us/HT205280",
+		Source:        AppleCTPolicy,
+		EffectiveDate: util.AppleCTPolicyDate,
+		Lint:          &sctPolicyCount{},
+	})
+}
diff --git a/util/time.go b/util/time.go
index 14b14648..f5e52596 100644
--- a/util/time.go
+++ b/util/time.go
@@ -53,6 +53,7 @@ var (
 	EtsiEn319_412_5_V2_2_1_Date = time.Date(2017, time.November, 1, 0, 0, 0, 0, time.UTC)
 	OnionOnlyEVDate             = time.Date(2015, time.May, 1, 0, 0, 0, 0, time.UTC)
 	CABV201Date                 = time.Date(2017, time.July, 28, 0, 0, 0, 0, time.UTC)
+	AppleCTPolicyDate           = time.Date(2018, time.October, 15, 0, 0, 0, 0, time.UTC)
 )
 
 func FindTimeType(firstDate, secondDate asn1.RawValue) (int, int) {


Test patch:
diff --git a/lints/lint_ct_sct_policy_count_unsatisfied_test.go b/lints/lint_ct_sct_policy_count_unsatisfied_test.go
new file mode 100644
index 00000000..ffa03408
--- /dev/null
+++ b/lints/lint_ct_sct_policy_count_unsatisfied_test.go
@@ -0,0 +1,105 @@
+/*
+ * ZLint Copyright 2019 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 lints
+
+import (
+	"fmt"
+	"testing"
+)
+
+func TestSCTCountPolicyUnsatisified(t *testing.T) {
+	// NOTE(@cpu): Hello future human. If you need to recreate any of the
+	// Filenames referenced in this test you will need the `sctTestCerts.go`
+	// program[0]. Each test case has a comment that includes the invocation
+	// arguments that were used to create the test file.
+	//
+	// [0]: https://gist.github.com/cpu/6d26b2718f29e184ff88a90f02d7cbcb
+	testCases := []struct {
+		Name           string
+		Filename       string
+		ExpectedResult LintStatus
+	}{
+		{
+			Name: "No SCTs",
+			// go run sctTestCerts.go -lifetime 3 -scts 0 > testlint/testCerts/ctNoSCTs.pem
+			Filename:       "ctNoSCTs.pem",
+			ExpectedResult: Notice,
+		},
+		{
+			Name: "Lifetime <15mo, 1 SCT",
+			// go run sctTestCerts.go -lifetime 3 -scts 1 > testlint/testCerts/ct3mo1SCTs.pem
+			Filename:       "ct3mo1SCTs.pem",
+			ExpectedResult: Notice,
+		},
+		{
+			Name: "Lifetime <15mo, 2 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 3 -scts 2 > testlint/testCerts/ct3mo2SCTs.pem
+			Filename:       "ct3mo2SCTs.pem",
+			ExpectedResult: Pass,
+		},
+		{
+			Name: "Lifetime <15mo, 2 SCTs same logs",
+			// go run sctTestCerts.go -lifetime 3 -scts 2 -differentLogs=false > testlint/testCerts/ct3mo2DupeSCTs.pem
+			Filename:       "ct3mo2DupeSCTs.pem",
+			ExpectedResult: Notice,
+		},
+		{
+			Name: "Lifetime >15mo <27mo, 2 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 18 -scts 2 > testlint/testCerts/ct18mo2SCTs.pem
+			Filename:       "ct18mo2SCTs.pem",
+			ExpectedResult: Notice,
+		},
+		{
+			Name: "Lifetime >15mo <27mo, 3 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 18 -scts 3 > testlint/testCerts/ct18mo3SCTs.pem
+			Filename:       "ct18mo3SCTs.pem",
+			ExpectedResult: Pass,
+		},
+		{
+			Name: "Lifetime >27mo <39mo, 3 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 38 -scts 3 > testlint/testCerts/ct38mo3SCTs.pem
+			Filename:       "ct38mo3SCTs.pem",
+			ExpectedResult: Notice,
+		},
+		{
+			Name: "Lifetime >27mo <39mo, 4 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 38 -scts 4 > testlint/testCerts/ct38mo4SCTs.pem
+			Filename:       "ct38mo4SCTs.pem",
+			ExpectedResult: Pass,
+		},
+		{
+			Name: "Lifetime >39mo, 4 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 666 -scts 4 > testlint/testCerts/ct666mo4SCTs.pem
+			Filename:       "ct666mo4SCTs.pem",
+			ExpectedResult: Notice,
+		},
+		{
+			Name: "Lifetime >39mo, 5 SCTs diff logs",
+			// go run sctTestCerts.go -lifetime 666 -scts 5 > testlint/testCerts/ct666mo5SCTs.pem
+			Filename:       "ct666mo5SCTs.pem",
+			ExpectedResult: Pass,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Name, func(t *testing.T) {
+			inputPath := fmt.Sprintf("%s%s", testCaseDir, tc.Filename)
+			result := Lints["ct_sct_policy_count_unsatisfied"].Execute(ReadCertificate(inputPath))
+			if result.Status != tc.ExpectedResult {
+				t.Errorf("expected result %v was %v", tc.ExpectedResult, result.Status)
+			}
+		})
+	}
+}
diff --git a/testlint/testCerts/ct18mo2SCTs.pem b/testlint/testCerts/ct18mo2SCTs.pem
new file mode 100644
index 00000000..0811d6ab
--- /dev/null
+++ b/testlint/testCerts/ct18mo2SCTs.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICUjCCAfygAwIBAgIILZ2xP0rZJewwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2MzUyNFoXDTIwMTAwNjE2MzUyNFowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC5jfsgWwqKm8UhBxj0t9Bi7obd
++KTx1l8s/X4is9nRikMdwuKtveW3d3SUK1gZRz488z6w9RiZRx5qq4Sbi++3AgMB
+AAGjggEOMIIBCjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
+CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwEgYDVR0RBAswCYIHem1hcC5pbzCBtgYK
+KwYBBAHWeQIEAgSBpwSBpACiAE8AsPdexUrNBMO9f9XyJd3u4jdA0lgOwiXKKAxb
+qRK6uNEAAAFp84EzoQAABAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nS
+N3unAE8AsP1iNoosyPVFkF16ep407bj2hpyz/owbB7T9Pqh/iBwAAAFp84EzoQAA
+BAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nSN3unMA0GCSqGSIb3DQEB
+CwUAA0EAiLGpOBv/D+vYQAk3K/WOV/tR1h23LaR7wbIQkl3BcL1akBXyUmk0eYAm
+1ScF9yz7Nxgq32g0IehqPPRLsr5Zow==
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct18mo3SCTs.pem b/testlint/testCerts/ct18mo3SCTs.pem
new file mode 100644
index 00000000..183e6ab3
--- /dev/null
+++ b/testlint/testCerts/ct18mo3SCTs.pem
@@ -0,0 +1,17 @@
+-----BEGIN CERTIFICATE-----
+MIICpDCCAk6gAwIBAgIIIsEfd7NO1qEwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2MzcyNFoXDTIwMTAwNjE2MzcyNFowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCzHHnFDPE5q3PitnPDDz88DNBV
+sGJ6/vBdQfBtj4BOYqO+VKiOcX703gluMrko8OYoueNbk1/BiLWDwRPVIMirAgMB
+AAGjggFgMIIBXDAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
+CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwEgYDVR0RBAswCYIHem1hcC5pbzCCAQcG
+CisGAQQB1nkCBAIEgfgEgfUA8wBPALD3XsVKzQTDvX/V8iXd7uI3QNJYDsIlyigM
+W6kSurjRAAABafODBqgAAAQDACDzho8fogKyO4INgs16OSSX6yrg2g6XeYVmiq/5
+0jd7pwBPALD9YjaKLMj1RZBdenqeNO249oacs/6MGwe0/T6of4gcAAABafODBqgA
+AAQDACDzho8fogKyO4INgs16OSSX6yrg2g6XeYVmiq/50jd7pwBPADhzRdXIuFZ2
+gvKqIRirNpqYwOo/RT6tNSgKKSrulnvoAAABafODBqgAAAQDACDzho8fogKyO4IN
+gs16OSSX6yrg2g6XeYVmiq/50jd7pzANBgkqhkiG9w0BAQsFAANBAJMJIFSX65B+
+QBqTzikONe/BiUXQyQ6U0KJ3luXynhaBM/uJj2Sy9fTl/Mg3fzkUxP1UXJjHeW5A
+aj2EvGbKd60=
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct38mo3SCTs.pem b/testlint/testCerts/ct38mo3SCTs.pem
new file mode 100644
index 00000000..604ad72c
--- /dev/null
+++ b/testlint/testCerts/ct38mo3SCTs.pem
@@ -0,0 +1,17 @@
+-----BEGIN CERTIFICATE-----
+MIICpDCCAk6gAwIBAgIIAfaHEbxZ90wwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2MzgyOVoXDTIyMDYwNjE2MzgyOVowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCW7ewamhvbbfcRVcTqpB98Ec7o
+FVag17llOVJcdZSqiOcRPIg6OOAWHTj39I+c5J6wRBsDh15ApukvpMY0OS+JAgMB
+AAGjggFgMIIBXDAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
+CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwEgYDVR0RBAswCYIHem1hcC5pbzCCAQcG
+CisGAQQB1nkCBAIEgfgEgfUA8wBPALD3XsVKzQTDvX/V8iXd7uI3QNJYDsIlyigM
+W6kSurjRAAABafOEB1oAAAQDACDzho8fogKyO4INgs16OSSX6yrg2g6XeYVmiq/5
+0jd7pwBPALD9YjaKLMj1RZBdenqeNO249oacs/6MGwe0/T6of4gcAAABafOEB1oA
+AAQDACDzho8fogKyO4INgs16OSSX6yrg2g6XeYVmiq/50jd7pwBPADhzRdXIuFZ2
+gvKqIRirNpqYwOo/RT6tNSgKKSrulnvoAAABafOEB1oAAAQDACDzho8fogKyO4IN
+gs16OSSX6yrg2g6XeYVmiq/50jd7pzANBgkqhkiG9w0BAQsFAANBAPHRfDPzvdkS
+0uxVq5KagAeaSgbOhdt5Zz52lOpYrmGcsgWIY+We/7eibXX8GkWnvlHLwb7HOL11
+Quj9+0BZuKY=
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct38mo4SCTs.pem b/testlint/testCerts/ct38mo4SCTs.pem
new file mode 100644
index 00000000..7fc6dc94
--- /dev/null
+++ b/testlint/testCerts/ct38mo4SCTs.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC9zCCAqGgAwIBAgIIAPSFE0f/oR8wDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2MzkwN1oXDTIyMDYwNjE2MzkwN1owEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCrYQHiv7V2FX9KJVtLCDpgGXkl
+tDWoCCBe+KnuoRuZa13/vzx6nv5d9v2ejDYR97T2UPcpWULsBzRg+ntOnw/PAgMB
+AAGjggGzMIIBrzAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
+CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwEgYDVR0RBAswCYIHem1hcC5pbzCCAVoG
+CisGAQQB1nkCBAIEggFKBIIBRgFEAE8AsPdexUrNBMO9f9XyJd3u4jdA0lgOwiXK
+KAxbqRK6uNEAAAFp84Sa8wAABAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaK
+r/nSN3unAE8AsP1iNoosyPVFkF16ep407bj2hpyz/owbB7T9Pqh/iBwAAAFp84Sa
+8wAABAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nSN3unAE8AOHNF1ci4
+VnaC8qohGKs2mpjA6j9FPq01KAopKu6We+gAAAFp84Sa8wAABAMAIPOGjx+iArI7
+gg2CzXo5JJfrKuDaDpd5hWaKr/nSN3unAE8AbzguHhv6NTk2PCr1F+xgLBuwQ0eS
+jBmt56R5+32ICOAAAAFp84Sa8wAABAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5
+hWaKr/nSN3unMA0GCSqGSIb3DQEBCwUAA0EAXCj9J2uOprtCqY3bv2LLyJWaQWOu
+ODjunWxUazEjpx8BmNIEWaFl2JTFmz3Ni5ESQn9B+C7KDdrv9cciah4Mzg==
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct3mo1SCTs.pem b/testlint/testCerts/ct3mo1SCTs.pem
new file mode 100644
index 00000000..08c7171c
--- /dev/null
+++ b/testlint/testCerts/ct3mo1SCTs.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB/DCCAaagAwIBAgIIQZjMoUfYc+MwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2MjE0NloXDTE5MDcwNjE2MjE0NlowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCddPAmci3K/pLmsMmuIhIo7ztY
+Mav85gk1ccxpLcqaQxYNBrB15K8GDXnxJnk/XY0Xk9DdfIOi7dLpak1t089hAgMB
+AAGjgbkwgbYwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggr
+BgEFBQcDAjAMBgNVHRMBAf8EAjAAMBIGA1UdEQQLMAmCB3ptYXAuaW8wYwYKKwYB
+BAHWeQIEAgRVBFMAUQBPALD3XsVKzQTDvX/V8iXd7uI3QNJYDsIlyigMW6kSurjR
+AAABafN0tw4AAAQDACDzho8fogKyO4INgs16OSSX6yrg2g6XeYVmiq/50jd7pzAN
+BgkqhkiG9w0BAQsFAANBAKGpPzoSoghp4TRGtsGrK5qOmDt6vh8bXjpHUk8GblH9
+zV41dXWrJSEc8BKlrrnoQkcfmwj/Hg68r0xKmBQc38w=
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct3mo2DupeSCTs.pem b/testlint/testCerts/ct3mo2DupeSCTs.pem
new file mode 100644
index 00000000..cd6515a0
--- /dev/null
+++ b/testlint/testCerts/ct3mo2DupeSCTs.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICUjCCAfygAwIBAgIIODvpuV6YxbMwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2NTMzNVoXDTE5MDcwNjE2NTMzNVowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQDZG9DipUoX8AMyA67VVWmL97jT
+wHGiOQbYzbcJbtn1YNu+ZEuXJYO2j+fwl6ugFoaHHZ7GVv5MaTJkiXCl+CrvAgMB
+AAGjggEOMIIBCjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
+CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwEgYDVR0RBAswCYIHem1hcC5pbzCBtgYK
+KwYBBAHWeQIEAgSBpwSBpACiAE8AsPdexUrNBMO9f9XyJd3u4jdA0lgOwiXKKAxb
+qRK6uNEAAAFp85HZogAABAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nS
+N3unAE8AsPdexUrNBMO9f9XyJd3u4jdA0lgOwiXKKAxbqRK6uNEAAAFp85HZogAA
+BAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nSN3unMA0GCSqGSIb3DQEB
+CwUAA0EAG7Oxc+8T98l8zBx/eeV4G/5MCmpYwYl+/oaPhP/f8Gw0ac7foRbtK8N4
+JSZwAt6I5p1+tSFNFlnkuUYucfI1nQ==
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct3mo2SCTs.pem b/testlint/testCerts/ct3mo2SCTs.pem
new file mode 100644
index 00000000..94e6c09d
--- /dev/null
+++ b/testlint/testCerts/ct3mo2SCTs.pem
@@ -0,0 +1,15 @@
+-----BEGIN CERTIFICATE-----
+MIICUjCCAfygAwIBAgIIXrlTjvQ5vE8wDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE2MjUwNVoXDTE5MDcwNjE2MjUwNVowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQDYNPvqhV4IqPeNeBoL3yRq2so/
+910nUDJALlteZYApn0HkeEC3+fouW6Sp2IdHdFh42Kiqw1cLK/QehvunU/yvAgMB
+AAGjggEOMIIBCjAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEG
+CCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAwEgYDVR0RBAswCYIHem1hcC5pbzCBtgYK
+KwYBBAHWeQIEAgSBpwSBpACiAE8AsPdexUrNBMO9f9XyJd3u4jdA0lgOwiXKKAxb
+qRK6uNEAAAFp83fAlwAABAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nS
+N3unAE8AsP1iNoosyPVFkF16ep407bj2hpyz/owbB7T9Pqh/iBwAAAFp83fAlwAA
+BAMAIPOGjx+iArI7gg2CzXo5JJfrKuDaDpd5hWaKr/nSN3unMA0GCSqGSIb3DQEB
+CwUAA0EAFBTSRbLsVxWfcxO+J7kYIcJiDSELM6LcRu81a+LeWMW9PkuFX5szVVT/
++eoMEIMNyxcc+4qYUuEU8qlAQm1uXg==
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct666mo4SCTs.pem b/testlint/testCerts/ct666mo4SCTs.pem
new file mode 100644
index 00000000..dff9b3f9
--- /dev/null
+++ b/testlint/testCerts/ct666mo4SCTs.pem
@@ -0,0 +1,18 @@
+-----BEGIN CERTIFICATE-----
+MIIC+TCCAqOgAwIBAgIIdN3sLYZZjngwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MCAXDTE5MDQwNjE2NDAwN1oYDzIwNzQxMDA2MTc0MDA3WjASMRAwDgYDVQQDEwd6
+bWFwLmlvMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAPBlcW4OXSwGD08rjPBCP9ub
+51kKTdb3uO1CqZxatvGKfWmYTbDnXh7VKY7NIXtwl2hVr+GsNyLvPGdYYPBXS0cC
+AwEAAaOCAbMwggGvMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
+AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADASBgNVHREECzAJggd6bWFwLmlvMIIB
+WgYKKwYBBAHWeQIEAgSCAUoEggFGAUQATwCw917FSs0Ew71/1fIl3e7iN0DSWA7C
+JcooDFupErq40QAAAWnzhYRrAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoOl3mF
+Zoqv+dI3e6cATwCw/WI2iizI9UWQXXp6njTtuPaGnLP+jBsHtP0+qH+IHAAAAWnz
+hYRrAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoOl3mFZoqv+dI3e6cATwA4c0XV
+yLhWdoLyqiEYqzaamMDqP0U+rTUoCikq7pZ76AAAAWnzhYRrAAAEAwAg84aPH6IC
+sjuCDYLNejkkl+sq4NoOl3mFZoqv+dI3e6cATwBvOC4eG/o1OTY8KvUX7GAsG7BD
+R5KMGa3npHn7fYgI4AAAAWnzhYRrAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoO
+l3mFZoqv+dI3e6cwDQYJKoZIhvcNAQELBQADQQCVCWVXVarV9gHn5h0H9cW1o8Jq
+QkHla6h15ud+wgJFx2jfdLZU0qAQzDleW5WmqyoEt8+iGEy7Fm6/XsNkMYMO
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ct666mo5SCTs.pem b/testlint/testCerts/ct666mo5SCTs.pem
new file mode 100644
index 00000000..e5c9311f
--- /dev/null
+++ b/testlint/testCerts/ct666mo5SCTs.pem
@@ -0,0 +1,20 @@
+-----BEGIN CERTIFICATE-----
+MIIDSjCCAvSgAwIBAgIIVK5UCjZ3F0QwDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MCAXDTE5MDQwNjE2NDE0OFoYDzIwNzQxMDA2MTc0MTQ4WjASMRAwDgYDVQQDEwd6
+bWFwLmlvMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKb1QIoxyJ4HhpD9RDYfFlmQ
+KPX66MJmdR7DZhzroIDdw07Ap1d81oBva9xthSmelWJHUgv8syVZihMWWulMWtkC
+AwEAAaOCAgQwggIAMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD
+AQYIKwYBBQUHAwIwDAYDVR0TAQH/BAIwADASBgNVHREECzAJggd6bWFwLmlvMIIB
+qwYKKwYBBAHWeQIEAgSCAZsEggGXAZUATwCw917FSs0Ew71/1fIl3e7iN0DSWA7C
+JcooDFupErq40QAAAWnzhw8eAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoOl3mF
+Zoqv+dI3e6cATwCw/WI2iizI9UWQXXp6njTtuPaGnLP+jBsHtP0+qH+IHAAAAWnz
+hw8eAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoOl3mFZoqv+dI3e6cATwA4c0XV
+yLhWdoLyqiEYqzaamMDqP0U+rTUoCikq7pZ76AAAAWnzhw8eAAAEAwAg84aPH6IC
+sjuCDYLNejkkl+sq4NoOl3mFZoqv+dI3e6cATwBvOC4eG/o1OTY8KvUX7GAsG7BD
+R5KMGa3npHn7fYgI4AAAAWnzhw8eAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoO
+l3mFZoqv+dI3e6cATwD7BYrPKKZFEmYbaoqFsoTZ6UvOBVpIkqgXzbyMvMeFzgAA
+AWnzhw8eAAAEAwAg84aPH6ICsjuCDYLNejkkl+sq4NoOl3mFZoqv+dI3e6cwDQYJ
+KoZIhvcNAQELBQADQQBJz7/CCb8rksPMVniIN/g7oCb8/don5E0JhYbWt10j2mAU
+qk27pbeNnCEenCc96ebr8akWikMennOZ7Aq+gua4
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/ctNoSCTs.pem b/testlint/testCerts/ctNoSCTs.pem
new file mode 100644
index 00000000..4ba48f49
--- /dev/null
+++ b/testlint/testCerts/ctNoSCTs.pem
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBlTCCAT+gAwIBAgIIcPVBqJBS42swDQYJKoZIhvcNAQELBQAwODE2MDQGA1UE
+AwwtbGludF9jdF9zY3RfcG9saWN5X2NvdW50X3Vuc2F0aXNpZmllZF90ZXN0IENB
+MB4XDTE5MDQwNjE1MTgyMVoXDTE5MDcwNjE1MTgyMVowEjEQMA4GA1UEAxMHem1h
+cC5pbzBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQC88j4SbaOIQWD+S8jmT/7LWJKT
+Qmb/0EjrD9C6CYuLupGi4L00u9qNcxXay8AlyFOZac8oJTc/m5XKpOdPlJVFAgMB
+AAGjUzBRMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYB
+BQUHAwIwDAYDVR0TAQH/BAIwADASBgNVHREECzAJggd6bWFwLmlvMA0GCSqGSIb3
+DQEBCwUAA0EAZigFW1vu+nlDgircbhvoJuUPAsUukvoSE2MXuzGuuTxyv4BKmr4s
+NAX9nJWO6oFKylpewY0Df/75MLaok8vb8Q==
+-----END CERTIFICATE-----