mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:41:30 +00:00
506cff137d
Phase 11.5 of the SCEP RFC 8894 + Intune master bundle. Adds an
operator-facing SCEP probe that issues GetCACaps + GetCACert against
an arbitrary SCEP server URL and returns a structured posture snapshot
(reachable + advertised caps + RFC 8894 / AES / POST / Renewal /
SHA-256 / SHA-512 support flags + CA cert subject + issuer + NotBefore
+ NotAfter + days-to-expiry + algorithm + chain length).
Two operator use cases per the master prompt:
1. Pre-migration assessment — probe an existing EJBCA / NDES SCEP
server before switching to certctl to see what capabilities it
advertises and what the CA cert looks like.
2. Compliance posture audits — periodic ad-hoc probes against the
operator's own SCEP servers to flag drift.
Capability-only — does NOT POST a CSR per the spec (would consume slot
allocations on the target server + create audit noise). Standalone CLI
binary explicitly out of scope (per the master prompt §11.5.6 and the
operator's confirmation): the probe code lands inside certctl; a
future thin Cobra wrapper is a separate decision.
Backend (six new + one extended file):
* internal/domain/network_scan.go — new SCEPProbeResult struct with
every probe field documented for the GUI's display layer.
* migrations/000021_scep_probe_results.up.sql + .down.sql — new
scep_probe_results table with TEXT id, target_url, all probe
flags, CA cert metadata, probed_at, probe_duration_ms, error.
Two indexes: idx_scep_probe_results_probed_at (DESC) for the
'recent probes' GUI query, idx_scep_probe_results_target_url
(target_url, probed_at DESC) for the future per-URL history view.
* internal/repository/interfaces.go — new SCEPProbeResultRepository
interface (Insert + ListRecent).
* internal/repository/postgres/scep_probe_results.go — Postgres
implementation. ListRecent clamps limit to [1, 200]; on read
re-derives ca_cert_days_to_expiry against the query-time wall
clock so 'X days remaining' stays fresh.
* internal/service/scep_probe.go — ProbeSCEP(ctx, url) on
NetworkScanService. Validation order:
1. Up-front URL validation via validation.ValidateSafeURL
(defaults to validation.ValidateSafeURL but injectable for
tests via the new scepValidateURL field on the service).
2. Dial-time SSRF re-check via SafeHTTPDialContext on the
http.Transport (defends against DNS rebinding).
3. GET ?operation=GetCACaps + GET ?operation=GetCACert.
GetCACert handles three response shapes: PKCS#7 SignedData
certs-only envelope (multi-cert), raw DER (single-cert),
and PEM-wrapped DER (non-conforming servers).
Times out at 30s; uses a 1MB body cap for DoS defense; wraps
the result + persists via the repo (nil-safe) before returning.
describeCertAlgorithm helper returns 'RSA-N' / 'ECDSA-curve' /
'Ed25519' / 'DSA' for the GUI's algorithm column.
* internal/service/network_scan.go — added scepProbeRepo +
scepHTTPClient + scepValidateURL + scepIDFn + nowFn fields;
SetSCEPProbeRepo wires the repo at startup.
* internal/api/handler/network_scan.go — extended NetworkScanService
interface with ProbeSCEP + ListRecentSCEPProbes; added two new
HTTP handlers:
POST /api/v1/network-scan/scep-probe (body {url})
GET /api/v1/network-scan/scep-probes (recent history)
Synchronous probe; HTTP 200 with the result body for both success
and reachable-but-failed cases (so the GUI can render the failure
tone with the operator-actionable error message).
* internal/api/router/router.go — registered the two routes inline
after the existing network-scan target endpoints.
* api/openapi.yaml — documented both endpoints (operationId
probeSCEP + listSCEPProbes) with full schema + response codes.
* cmd/server/main.go — wires the new SCEPProbeResultRepository
onto the network scan service via SetSCEPProbeRepo right after
the existing NewNetworkScanService construction.
Backend tests (6 new — exit-criteria-named per the master prompt):
* TestProbeSCEP_AdvertisesAllCaps — happy path, full RFC 8894
capability set, ECDSA P-256 CA cert, 365-day expiry.
* TestProbeSCEP_MissingSCEPStandard — pre-RFC-8894 server (only
POSTPKIOperation + SHA-1 + DES3); SupportsRFC8894 = false.
* TestProbeSCEP_GetCACertExpired — CA cert NotAfter 30d in the
past; CACertExpired = true.
* TestProbeSCEP_Unreachable — connect to TCP port 1; probe
returns Reachable=false + non-empty Error.
* TestProbeSCEP_RejectsReservedIP — http://169.254.169.254/scep
(EC2 metadata literal) rejected by the up-front
validation.ValidateSafeURL gate; result captures the error
without ever issuing the HTTP call.
* TestProbeSCEP_PEMWrappedCert — server returns PEM instead of
raw DER for GetCACert; the fallback parse path handles it.
Frontend (one extended file + types/client):
* web/src/api/types.ts — SCEPProbeResult + SCEPProbesResponse.
* web/src/api/client.ts — probeSCEPServer + listSCEPProbes
helpers.
* web/src/pages/NetworkScanPage.tsx — new SCEPProbeSection
component + ProbeResultPanel (with capability badges + CA cert
details panel + raw caps line) + SCEPProbeHistoryTable. Form
rejects empty URL with inline error before calling the API.
Reload mutation goes through useTrackedMutation with explicit
invalidates: [['scep-probes']] (M-009 contract).
Frontend tests (5 new + 0 regressions):
* Scep probe section header + form renders.
* Empty URL is rejected with inline error and never calls the
probe endpoint.
* Successful probe renders capability badges + CA cert subject
+ days-remaining inline panel.
* Probe-level errors are surfaced in the inline panel (no result
panel rendered).
* Recent-probes history table renders one row per probe.
* (Existing 2 NetworkScanPage XSS-hardening tests stub the new
listSCEPProbes endpoint to an empty list so they still pass.)
Verification:
* gofmt clean on touched files
* go vet ./... clean
* staticcheck on service+handler+router+repository+cmd-server clean
* go test -short across service+handler+router+repository+cmd-server
+ integration: all green (existing + 6 new probe tests pass)
* Frontend tsc --noEmit clean
* Vitest: 7/7 NetworkScanPage tests pass (2 existing XSS + 5 new
probe section)
* G-3 docs-drift CI guard reproduced locally clean (no new env vars)
* M-009 hard-zero useMutation guard clean (probe mutation goes
through useTrackedMutation)
* openapi-parity guard satisfied (both new routes documented)
* The mockNetworkScanService in handler + integration packages
extended with stub Probe methods; targeted coverage stays in
scep_probe_test.go.
Out of scope (per master prompt §11.5.6 + operator confirmation):
* Standalone certctl-scan CLI binary — separate decision, ~1d of
follow-up work when/if shipped.
Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 11.5
cowork/scep-rfc8894-intune/progress.md
338 lines
12 KiB
Go
338 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
"github.com/shankar0123/certctl/internal/pkcs7"
|
|
"github.com/shankar0123/certctl/internal/validation"
|
|
)
|
|
|
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
|
//
|
|
// Probes an SCEP server URL for capability + posture metadata
|
|
// (RFC 8894 §3.5.1 GetCACaps + GetCACert). Used for pre-migration
|
|
// assessment + compliance posture audits. Deliberately does NOT POST a
|
|
// CSR — capability-only.
|
|
//
|
|
// SSRF defense: the HTTP client uses validation.SafeHTTPDialContext so
|
|
// dial-time DNS resolution is checked against the reserved-IP filter
|
|
// (defends against DNS rebinding); the URL is also validated up-front
|
|
// via validation.ValidateSafeURL for an early diagnostic.
|
|
//
|
|
// The probe accumulates persistent history in scep_probe_results
|
|
// (migration 000021) when SetSCEPProbeRepo wired a repo at startup;
|
|
// otherwise the probe runs and returns its result without persisting.
|
|
|
|
// scepProbeTimeout caps a single probe at 30s. The probe issues at
|
|
// most 2-3 GETs against the target, each with default Go HTTP-client
|
|
// behavior (single connection, no retries) — 30s is generous for
|
|
// reachable servers and bounds the wait for unreachable / hung ones.
|
|
const scepProbeTimeout = 30 * time.Second
|
|
|
|
// scepProbeUserAgent identifies certctl in the target server's logs so
|
|
// operators running the probe see a clear source attribution.
|
|
const scepProbeUserAgent = "certctl-network-scan/scep-probe"
|
|
|
|
// ProbeSCEP probes the given URL as an SCEP server and returns a
|
|
// structured posture snapshot. The result is also persisted via
|
|
// SetSCEPProbeRepo (when configured) so the GUI can render recent
|
|
// probe history.
|
|
//
|
|
// Validation order:
|
|
//
|
|
// 1. validation.ValidateSafeURL — catches obvious SSRF targets
|
|
// (loopback / link-local / cloud-metadata literals) before any
|
|
// network call. Cheap early diagnostic.
|
|
// 2. The HTTP transport's DialContext (SafeHTTPDialContext) re-
|
|
// resolves the target host at dial time and re-checks reserved
|
|
// IPs. Defends against DNS-rebinding (the URL passes step 1 but
|
|
// resolves to a reserved IP at dial time).
|
|
// 3. The probe issues GET ?operation=GetCACaps and GET ?operation=GetCACert.
|
|
// GetCACert can return either a single DER cert OR a PKCS#7
|
|
// SignedData certs-only envelope (RFC 8894 §3.5.1). The probe
|
|
// handles both.
|
|
func (s *NetworkScanService) ProbeSCEP(ctx context.Context, rawURL string) (*domain.SCEPProbeResult, error) {
|
|
id := s.scepProbeID()
|
|
now := s.nowFnOrDefault()
|
|
started := now()
|
|
result := &domain.SCEPProbeResult{
|
|
ID: id,
|
|
TargetURL: rawURL,
|
|
ProbedAt: started,
|
|
}
|
|
|
|
// Step 1: cheap up-front URL validation (SSRF early diagnostic).
|
|
// Defaults to validation.ValidateSafeURL; tests inject a permissive
|
|
// validator via service-level field so they can hit httptest
|
|
// loopback servers (which the production validator correctly
|
|
// rejects). Mirrors the webhook notifier's `newForTest` pattern.
|
|
validateURL := s.scepValidateURL
|
|
if validateURL == nil {
|
|
validateURL = validation.ValidateSafeURL
|
|
}
|
|
if err := validateURL(rawURL); err != nil {
|
|
result.Reachable = false
|
|
result.Error = "url validation: " + err.Error()
|
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
|
s.persistProbeResult(ctx, result)
|
|
return result, fmt.Errorf("scep probe: validate url: %w", err)
|
|
}
|
|
|
|
// Normalize the base URL — strip any trailing query string so we
|
|
// can append ?operation=... unambiguously.
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
result.Reachable = false
|
|
result.Error = "url parse: " + err.Error()
|
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
|
s.persistProbeResult(ctx, result)
|
|
return result, fmt.Errorf("scep probe: parse url: %w", err)
|
|
}
|
|
parsed.RawQuery = ""
|
|
baseURL := parsed.String()
|
|
|
|
client := s.scepProbeClient()
|
|
|
|
// Step 2: GetCACaps — newline-separated capability list.
|
|
caps, capsErr := s.scepGetCACaps(ctx, client, baseURL)
|
|
if capsErr != nil {
|
|
result.Reachable = false
|
|
result.Error = "GetCACaps: " + capsErr.Error()
|
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
|
s.persistProbeResult(ctx, result)
|
|
return result, capsErr
|
|
}
|
|
result.Reachable = true
|
|
result.AdvertisedCaps = caps
|
|
for _, c := range caps {
|
|
switch strings.TrimSpace(c) {
|
|
case "SCEPStandard":
|
|
result.SupportsRFC8894 = true
|
|
case "AES":
|
|
result.SupportsAES = true
|
|
case "POSTPKIOperation":
|
|
result.SupportsPOSTOperation = true
|
|
case "Renewal":
|
|
result.SupportsRenewal = true
|
|
case "SHA-256":
|
|
result.SupportsSHA256 = true
|
|
case "SHA-512":
|
|
result.SupportsSHA512 = true
|
|
}
|
|
}
|
|
|
|
// Step 3: GetCACert — DER cert OR PKCS#7 SignedData certs-only envelope.
|
|
certs, certErr := s.scepGetCACert(ctx, client, baseURL)
|
|
if certErr != nil {
|
|
// Non-fatal: server reached + caps parsed, but CA cert fetch
|
|
// failed. Operator gets caps + the error explaining the CA
|
|
// cert state.
|
|
result.Error = "GetCACert: " + certErr.Error()
|
|
} else if len(certs) > 0 {
|
|
result.CACertChainLength = len(certs)
|
|
leaf := certs[0]
|
|
result.CACertSubject = leaf.Subject.String()
|
|
result.CACertIssuer = leaf.Issuer.String()
|
|
result.CACertNotBefore = leaf.NotBefore
|
|
result.CACertNotAfter = leaf.NotAfter
|
|
nowVal := now()
|
|
result.CACertExpired = nowVal.After(leaf.NotAfter)
|
|
if !result.CACertExpired {
|
|
result.CACertDaysToExpiry = int(leaf.NotAfter.Sub(nowVal).Hours() / 24)
|
|
}
|
|
result.CACertAlgorithm = describeCertAlgorithm(leaf)
|
|
}
|
|
|
|
result.ProbeDurationMs = time.Since(started).Milliseconds()
|
|
s.persistProbeResult(ctx, result)
|
|
return result, nil
|
|
}
|
|
|
|
// scepGetCACaps fetches GET ?operation=GetCACaps and parses the
|
|
// newline-separated capability list. Lines are trimmed of CRLF; empty
|
|
// lines are skipped. Per RFC 8894 §3.5.2 the response Content-Type is
|
|
// text/plain with one capability per line.
|
|
func (s *NetworkScanService) scepGetCACaps(ctx context.Context, client *http.Client, baseURL string) ([]string, error) {
|
|
url := baseURL + "?operation=GetCACaps"
|
|
body, err := s.scepHTTPGet(ctx, client, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []string
|
|
for _, line := range strings.Split(string(body), "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if t == "" {
|
|
continue
|
|
}
|
|
out = append(out, t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// scepGetCACert fetches GET ?operation=GetCACert and parses the
|
|
// returned cert(s). RFC 8894 §3.5.1: the response is either:
|
|
//
|
|
// - A single DER-encoded X.509 cert (Content-Type
|
|
// application/x-x509-ca-cert) when the CA has a single cert.
|
|
// - A PKCS#7 SignedData certs-only envelope (Content-Type
|
|
// application/x-x509-ca-ra-cert) when the CA returns multiple
|
|
// certs (CA + RA, or CA chain).
|
|
//
|
|
// We attempt the PKCS#7 parse first, fall back to single-cert DER
|
|
// parse if that fails. Returns the cert chain in order (CA leaf first).
|
|
func (s *NetworkScanService) scepGetCACert(ctx context.Context, client *http.Client, baseURL string) ([]*x509.Certificate, error) {
|
|
url := baseURL + "?operation=GetCACert"
|
|
body, err := s.scepHTTPGet(ctx, client, url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Try PKCS#7 SignedData first — the multi-cert form. ParseSignedData
|
|
// already decodes each embedded cert into *x509.Certificate, so we
|
|
// just take the slice as-is.
|
|
if signed, p7Err := pkcs7.ParseSignedData(body); p7Err == nil && len(signed.Certificates) > 0 {
|
|
return signed.Certificates, nil
|
|
}
|
|
|
|
// Fall back to single DER cert (or a PEM-wrapped cert from a
|
|
// non-conforming server — try both).
|
|
if c, err := x509.ParseCertificate(body); err == nil {
|
|
return []*x509.Certificate{c}, nil
|
|
}
|
|
if block, _ := pem.Decode(body); block != nil {
|
|
if c, err := x509.ParseCertificate(block.Bytes); err == nil {
|
|
return []*x509.Certificate{c}, nil
|
|
}
|
|
}
|
|
return nil, errors.New("could not parse GetCACert response as DER, PEM, or PKCS#7 SignedData")
|
|
}
|
|
|
|
// scepHTTPGet issues a single GET with the probe's user agent + the
|
|
// SSRF-defended HTTP client. Reads the body up to 1MB to defend against
|
|
// a huge-response DoS from a misbehaving target.
|
|
func (s *NetworkScanService) scepHTTPGet(ctx context.Context, client *http.Client, url string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", scepProbeUserAgent)
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("http get: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("http status %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB cap
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// scepProbeClient returns the lazily-built SSRF-defended HTTP client.
|
|
// Built once per service lifetime; the transport reuses connections.
|
|
func (s *NetworkScanService) scepProbeClient() *http.Client {
|
|
if s.scepHTTPClient != nil {
|
|
return s.scepHTTPClient
|
|
}
|
|
transport := &http.Transport{
|
|
DialContext: validation.SafeHTTPDialContext(scepProbeTimeout),
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ResponseHeaderTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
ForceAttemptHTTP2: true,
|
|
}
|
|
s.scepHTTPClient = &http.Client{
|
|
Timeout: scepProbeTimeout,
|
|
Transport: transport,
|
|
}
|
|
return s.scepHTTPClient
|
|
}
|
|
|
|
// scepProbeID returns a fresh ID for a probe row. Defaults to
|
|
// "spr-<uuid>"; tests can inject a deterministic generator via
|
|
// (NetworkScanService).scepIDFn.
|
|
func (s *NetworkScanService) scepProbeID() string {
|
|
if s.scepIDFn != nil {
|
|
return s.scepIDFn()
|
|
}
|
|
return "spr-" + uuid.New().String()
|
|
}
|
|
|
|
// nowFnOrDefault returns the configured clock (for test injection) or
|
|
// time.Now if unset. Used so the probe's two NotAfter comparisons
|
|
// (CACertExpired + ProbedAt) share a single observation point.
|
|
func (s *NetworkScanService) nowFnOrDefault() func() time.Time {
|
|
if s.nowFn != nil {
|
|
return s.nowFn
|
|
}
|
|
return time.Now
|
|
}
|
|
|
|
// persistProbeResult writes the probe outcome to scep_probe_results
|
|
// when a repo was wired. Failure to persist is logged but doesn't
|
|
// fail the caller — the probe's primary contract is "run + return"
|
|
// not "run + persist". Operators get the result regardless.
|
|
func (s *NetworkScanService) persistProbeResult(ctx context.Context, result *domain.SCEPProbeResult) {
|
|
if s.scepProbeRepo == nil {
|
|
return
|
|
}
|
|
if err := s.scepProbeRepo.Insert(ctx, result); err != nil && s.logger != nil {
|
|
s.logger.Warn("scep probe result persist failed (probe still returned to caller)",
|
|
"target_url", result.TargetURL,
|
|
"id", result.ID,
|
|
"error", err)
|
|
}
|
|
}
|
|
|
|
// ListRecentSCEPProbes returns the most recent N probe rows. Thin
|
|
// wrapper around the repository so the handler depends on the service
|
|
// surface, not the repo directly. Returns empty slice (not nil) when
|
|
// no repo is wired so JSON marshaling stays clean.
|
|
func (s *NetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
|
if s.scepProbeRepo == nil {
|
|
return []*domain.SCEPProbeResult{}, nil
|
|
}
|
|
return s.scepProbeRepo.ListRecent(ctx, limit)
|
|
}
|
|
|
|
// describeCertAlgorithm returns a short, operator-friendly description
|
|
// of the cert's public key algorithm + size. Examples:
|
|
// - "RSA-2048" / "RSA-3072" / "RSA-4096"
|
|
// - "ECDSA-P256" / "ECDSA-P384" / "ECDSA-P521"
|
|
// - "Ed25519"
|
|
// - "" for unrecognized algorithms.
|
|
func describeCertAlgorithm(c *x509.Certificate) string {
|
|
switch pub := c.PublicKey.(type) {
|
|
case *rsa.PublicKey:
|
|
return fmt.Sprintf("RSA-%d", pub.N.BitLen())
|
|
case *ecdsa.PublicKey:
|
|
if pub.Curve != nil && pub.Curve.Params() != nil {
|
|
return "ECDSA-" + pub.Curve.Params().Name
|
|
}
|
|
return "ECDSA"
|
|
}
|
|
switch c.PublicKeyAlgorithm {
|
|
case x509.Ed25519:
|
|
return "Ed25519"
|
|
case x509.DSA:
|
|
return "DSA"
|
|
}
|
|
return ""
|
|
}
|