diff --git a/issuance/crl.go b/issuance/crl.go index 9e2de44a6..f33af1883 100644 --- a/issuance/crl.go +++ b/issuance/crl.go @@ -17,6 +17,13 @@ import ( type CRLProfileConfig struct { ValidityInterval config.Duration MaxBackdate config.Duration + + // LintConfig is a path to a zlint config file, which can be used to control + // the behavior of zlint's "customizable lints". + LintConfig string + // IgnoredLints is a list of lint names that we know will fail for this + // profile, and which we know it is safe to ignore. + IgnoredLints []string } type CRLProfile struct { @@ -38,10 +45,17 @@ func NewCRLProfile(config CRLProfileConfig) (*CRLProfile, error) { return nil, fmt.Errorf("crl max backdate must be non-negative, got %q", config.MaxBackdate) } - reg, err := linter.NewRegistry(nil) + reg, err := linter.NewRegistry(config.IgnoredLints) if err != nil { return nil, fmt.Errorf("creating lint registry: %w", err) } + if config.LintConfig != "" { + lintconfig, err := lint.NewConfigFromFile(config.LintConfig) + if err != nil { + return nil, fmt.Errorf("loading zlint config file: %w", err) + } + reg.SetConfiguration(lintconfig) + } return &CRLProfile{ validityInterval: config.ValidityInterval.Duration, diff --git a/linter/lints/rfc/lint_cert_via_pkimetal.go b/linter/lints/rfc/lint_cert_via_pkimetal.go index e210361c2..fdcd2d6a8 100644 --- a/linter/lints/rfc/lint_cert_via_pkimetal.go +++ b/linter/lints/rfc/lint_cert_via_pkimetal.go @@ -17,13 +17,105 @@ import ( "github.com/zmap/zlint/v3/util" ) -type certViaPKIMetal struct { +// PKIMetalConfig and its execute method provide a shared basis for linting +// both certs and CRLs using PKIMetal. +type PKIMetalConfig struct { Addr string `toml:"addr" comment:"The address where a pkilint REST API can be reached."` Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."` Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."` IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` } +func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResult, error) { + timeout := pkim.Timeout + if timeout == 0 { + timeout = 100 * time.Millisecond + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + apiURL, err := url.JoinPath(pkim.Addr, endpoint) + if err != nil { + return nil, fmt.Errorf("constructing pkimetal url: %w", err) + } + + // reqForm matches PKIMetal's documented form-urlencoded request format. It + // does not include the "profile" field, as its default value ("autodetect") + // is good for our purposes. + // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194 + reqForm := url.Values{} + reqForm.Set("b64input", base64.StdEncoding.EncodeToString(der)) + reqForm.Set("severity", pkim.Severity) + reqForm.Set("format", "json") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(reqForm.Encode())) + if err != nil { + return nil, fmt.Errorf("creating pkimetal request: %w", err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status) + } + + resJSON, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response from pkimetal API: %s", err) + } + + // finding matches the repeated portion of PKIMetal's documented JSON response. + // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221 + type finding struct { + Linter string `json:"linter"` + Finding string `json:"finding"` + Severity string `json:"severity"` + Code string `json:"code"` + Field string `json:"field"` + } + + var res []finding + err = json.Unmarshal(resJSON, &res) + if err != nil { + return nil, fmt.Errorf("parsing response from pkimetal API: %s", err) + } + + var findings []string + for _, finding := range res { + id := fmt.Sprintf("%s:%s", finding.Linter, finding.Code) + if slices.Contains(pkim.IgnoreLints, id) { + continue + } + desc := fmt.Sprintf("%s from %s at %s", finding.Severity, id, finding.Field) + if finding.Finding != "" { + desc = fmt.Sprintf("%s: %s", desc, finding.Finding) + } + findings = append(findings, desc) + } + + if len(findings) != 0 { + // Group the findings by severity, for human readers. + slices.Sort(findings) + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")), + }, nil + } + + return &lint.LintResult{Status: lint.Pass}, nil +} + +type certViaPKIMetal struct { + PKIMetalConfig +} + func init() { lint.RegisterCertificateLint(&lint.CertificateLint{ LintMetadata: lint.LintMetadata{ @@ -51,98 +143,14 @@ func (l *certViaPKIMetal) CheckApplies(c *x509.Certificate) bool { return l.Addr != "" } -// finding matches the repeated portion of PKIMetal's documented JSON response. -// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221 -type finding struct { - Linter string `json:"linter"` - Finding string `json:"finding"` - Severity string `json:"severity"` - Code string `json:"code"` - Field string `json:"field"` -} - func (l *certViaPKIMetal) Execute(c *x509.Certificate) *lint.LintResult { - timeout := l.Timeout - if timeout == 0 { - timeout = 100 * time.Millisecond - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // reqForm matches PKIMetal's documented form-urlencoded request format. It - // does not include the "format" or "profile" fields, as their default values - // ("json" and "autodetect", respectively) are good for our purposes. - // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194 - reqForm := url.Values{} - reqForm.Set("b64input", base64.StdEncoding.EncodeToString(c.Raw)) - reqForm.Set("severity", l.Severity) - - url := fmt.Sprintf("%s/lintcert", l.Addr) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(reqForm.Encode())) + res, err := l.execute("lintcert", c.Raw) if err != nil { return &lint.LintResult{ Status: lint.Error, - Details: fmt.Sprintf("creating pkimetal request: %s", err), - } - } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Accept", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return &lint.LintResult{ - Status: lint.Error, - Details: fmt.Sprintf("making POST request to pkimetal API: %s (timeout %s)", err, timeout), - } - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return &lint.LintResult{ - Status: lint.Error, - Details: fmt.Sprintf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status), + Details: err.Error(), } } - resJSON, err := io.ReadAll(resp.Body) - if err != nil { - return &lint.LintResult{ - Status: lint.Error, - Details: fmt.Sprintf("reading response from pkimetal API: %s", err), - } - } - - var res []finding - err = json.Unmarshal(resJSON, &res) - if err != nil { - return &lint.LintResult{ - Status: lint.Error, - Details: fmt.Sprintf("parsing response from pkimetal API: %s", err), - } - } - - var findings []string - for _, finding := range res { - id := fmt.Sprintf("%s:%s", finding.Linter, finding.Code) - if slices.Contains(l.IgnoreLints, id) { - continue - } - desc := fmt.Sprintf("%s from %s at %s", finding.Severity, id, finding.Field) - if finding.Finding != "" { - desc = fmt.Sprintf("%s: %s", desc, finding.Finding) - } - findings = append(findings, desc) - } - - if len(findings) != 0 { - // Group the findings by severity, for human readers. - slices.Sort(findings) - return &lint.LintResult{ - Status: lint.Error, - Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")), - } - } - - return &lint.LintResult{Status: lint.Pass} + return res } diff --git a/linter/lints/rfc/lint_crl_via_pkimetal.go b/linter/lints/rfc/lint_crl_via_pkimetal.go new file mode 100644 index 000000000..c927eebe5 --- /dev/null +++ b/linter/lints/rfc/lint_crl_via_pkimetal.go @@ -0,0 +1,50 @@ +package rfc + +import ( + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" +) + +type crlViaPKIMetal struct { + PKIMetalConfig +} + +func init() { + lint.RegisterRevocationListLint(&lint.RevocationListLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_pkimetal_lint_cabf_serverauth_crl", + Description: "Runs pkimetal's suite of cabf serverauth CRL lints", + Citation: "https://github.com/pkimetal/pkimetal", + Source: lint.Community, + EffectiveDate: util.CABEffectiveDate, + }, + Lint: NewCrlViaPKIMetal, + }) +} + +func NewCrlViaPKIMetal() lint.RevocationListLintInterface { + return &crlViaPKIMetal{} +} + +func (l *crlViaPKIMetal) Configure() any { + return l +} + +func (l *crlViaPKIMetal) CheckApplies(c *x509.RevocationList) bool { + // This lint applies to all CRLs issued by Boulder, as long as it has + // been configured with an address to reach out to. If not, skip it. + return l.Addr != "" +} + +func (l *crlViaPKIMetal) Execute(c *x509.RevocationList) *lint.LintResult { + res, err := l.execute("lintcrl", c.Raw) + if err != nil { + return &lint.LintResult{ + Status: lint.Error, + Details: err.Error(), + } + } + + return res +} diff --git a/test/config-next/ca.json b/test/config-next/ca.json index b3b254220..6692c568f 100644 --- a/test/config-next/ca.json +++ b/test/config-next/ca.json @@ -118,7 +118,8 @@ }, "crlProfile": { "validityInterval": "216h", - "maxBackdate": "1h5m" + "maxBackdate": "1h5m", + "lintConfig": "test/config-next/zlint.toml" }, "issuers": [ { diff --git a/test/config-next/zlint.toml b/test/config-next/zlint.toml index e9a504785..26bf997e6 100644 --- a/test/config-next/zlint.toml +++ b/test/config-next/zlint.toml @@ -16,3 +16,9 @@ ignore_lints = [ # and "shortlived" profiles. "pkilint:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present", ] + +[e_pkimetal_lint_cabf_serverauth_crl] +addr = "http://10.77.77.9:8080" +severity = "notice" +timeout = 2000000000 # 2 seconds +ignore_lints = [] diff --git a/test/config/ca.json b/test/config/ca.json index 675304d97..a64ec7ac2 100644 --- a/test/config/ca.json +++ b/test/config/ca.json @@ -95,7 +95,8 @@ }, "crlProfile": { "validityInterval": "216h", - "maxBackdate": "1h5m" + "maxBackdate": "1h5m", + "lintConfig": "test/config/zlint.toml" }, "issuers": [ { diff --git a/test/config/zlint.toml b/test/config/zlint.toml index e9a504785..26bf997e6 100644 --- a/test/config/zlint.toml +++ b/test/config/zlint.toml @@ -16,3 +16,9 @@ ignore_lints = [ # and "shortlived" profiles. "pkilint:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present", ] + +[e_pkimetal_lint_cabf_serverauth_crl] +addr = "http://10.77.77.9:8080" +severity = "notice" +timeout = 2000000000 # 2 seconds +ignore_lints = []