github.com/letsencrypt/boulder@v0.20251208.0/linter/lints/rfc/lint_cert_via_pkimetal.go (about)

     1  package rfc
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"slices"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/zmap/zcrypto/x509"
    16  	"github.com/zmap/zlint/v3/lint"
    17  	"github.com/zmap/zlint/v3/util"
    18  )
    19  
    20  // PKIMetalConfig and its execute method provide a shared basis for linting
    21  // both certs and CRLs using PKIMetal.
    22  type PKIMetalConfig struct {
    23  	Addr        string        `toml:"addr" comment:"The address where a pkilint REST API can be reached."`
    24  	Severity    string        `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."`
    25  	Timeout     time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."`
    26  	IgnoreLints []string      `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."`
    27  }
    28  
    29  func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResult, error) {
    30  	timeout := pkim.Timeout
    31  	if timeout == 0 {
    32  		timeout = 100 * time.Millisecond
    33  	}
    34  
    35  	ctx, cancel := context.WithTimeout(context.Background(), timeout)
    36  	defer cancel()
    37  
    38  	apiURL, err := url.JoinPath(pkim.Addr, endpoint)
    39  	if err != nil {
    40  		return nil, fmt.Errorf("constructing pkimetal url: %w", err)
    41  	}
    42  
    43  	// reqForm matches PKIMetal's documented form-urlencoded request format. It
    44  	// does not include the "profile" field, as its default value ("autodetect")
    45  	// is good for our purposes.
    46  	// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194
    47  	reqForm := url.Values{}
    48  	reqForm.Set("b64input", base64.StdEncoding.EncodeToString(der))
    49  	reqForm.Set("severity", pkim.Severity)
    50  	reqForm.Set("format", "json")
    51  
    52  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(reqForm.Encode()))
    53  	if err != nil {
    54  		return nil, fmt.Errorf("creating pkimetal request: %w", err)
    55  	}
    56  	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
    57  	req.Header.Add("Accept", "application/json")
    58  
    59  	resp, err := http.DefaultClient.Do(req)
    60  	if err != nil {
    61  		return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout)
    62  	}
    63  	defer resp.Body.Close()
    64  
    65  	if resp.StatusCode != http.StatusOK {
    66  		return nil, fmt.Errorf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status)
    67  	}
    68  
    69  	resJSON, err := io.ReadAll(resp.Body)
    70  	if err != nil {
    71  		return nil, fmt.Errorf("reading response from pkimetal API: %s", err)
    72  	}
    73  
    74  	// finding matches the repeated portion of PKIMetal's documented JSON response.
    75  	// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221
    76  	type finding struct {
    77  		Linter   string `json:"linter"`
    78  		Finding  string `json:"finding"`
    79  		Severity string `json:"severity"`
    80  		Code     string `json:"code"`
    81  		Field    string `json:"field"`
    82  	}
    83  
    84  	var res []finding
    85  	err = json.Unmarshal(resJSON, &res)
    86  	if err != nil {
    87  		return nil, fmt.Errorf("parsing response from pkimetal API: %s", err)
    88  	}
    89  
    90  	var findings []string
    91  	for _, finding := range res {
    92  		var id string
    93  		if finding.Code != "" {
    94  			id = fmt.Sprintf("%s:%s", finding.Linter, finding.Code)
    95  		} else {
    96  			id = fmt.Sprintf("%s:%s", finding.Linter, strings.ReplaceAll(strings.ToLower(finding.Finding), " ", "_"))
    97  		}
    98  		if slices.Contains(pkim.IgnoreLints, id) {
    99  			continue
   100  		}
   101  		desc := fmt.Sprintf("%s from %s: %s", finding.Severity, id, finding.Finding)
   102  		findings = append(findings, desc)
   103  	}
   104  
   105  	if len(findings) != 0 {
   106  		// Group the findings by severity, for human readers.
   107  		slices.Sort(findings)
   108  		return &lint.LintResult{
   109  			Status:  lint.Error,
   110  			Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")),
   111  		}, nil
   112  	}
   113  
   114  	return &lint.LintResult{Status: lint.Pass}, nil
   115  }
   116  
   117  type certViaPKIMetal struct {
   118  	PKIMetalConfig
   119  }
   120  
   121  func init() {
   122  	lint.RegisterCertificateLint(&lint.CertificateLint{
   123  		LintMetadata: lint.LintMetadata{
   124  			Name:          "e_pkimetal_lint_cabf_serverauth_cert",
   125  			Description:   "Runs pkimetal's suite of cabf serverauth certificate lints",
   126  			Citation:      "https://github.com/pkimetal/pkimetal",
   127  			Source:        lint.Community,
   128  			EffectiveDate: util.CABEffectiveDate,
   129  		},
   130  		Lint: NewCertViaPKIMetal,
   131  	})
   132  }
   133  
   134  func NewCertViaPKIMetal() lint.CertificateLintInterface {
   135  	return &certViaPKIMetal{}
   136  }
   137  
   138  func (l *certViaPKIMetal) Configure() any {
   139  	return l
   140  }
   141  
   142  func (l *certViaPKIMetal) CheckApplies(c *x509.Certificate) bool {
   143  	// This lint applies to all certificates issued by Boulder, as long as it has
   144  	// been configured with an address to reach out to. If not, skip it.
   145  	return l.Addr != ""
   146  }
   147  
   148  func (l *certViaPKIMetal) Execute(c *x509.Certificate) *lint.LintResult {
   149  	res, err := l.execute("lintcert", c.Raw)
   150  	if err != nil {
   151  		return &lint.LintResult{
   152  			Status:  lint.Error,
   153  			Details: err.Error(),
   154  		}
   155  	}
   156  
   157  	return res
   158  }