Files
certctl/internal/service/scep_probe_test.go
T
shankar0123 506cff137d feat(scep): SCEP probe in network scanner for fleet-readiness assessment
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
2026-04-29 18:51:57 +00:00

313 lines
11 KiB
Go

package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// SCEP RFC 8894 + Intune master bundle Phase 11.5.4 — five named backend
// tests for the SCEP probe per the master prompt's exit criteria:
//
// TestProbeSCEP_AdvertisesAllCaps
// TestProbeSCEP_MissingSCEPStandard
// TestProbeSCEP_GetCACertExpired
// TestProbeSCEP_Unreachable
// TestProbeSCEP_RejectsReservedIP
//
// Plus PrintsCACertAlgorithm + IDOverride for coverage of the algorithm
// helper + deterministic ID injection. Run-once tests; no fuzz.
// silentScepLogger drops all probe logs so test output stays clean.
func silentScepLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 10}))
}
// newScepProbeServiceForTest wires a NetworkScanService in a way that
// only exposes what the SCEP probe path needs — the TLS-scan side stays
// unconfigured (nil deps) which is fine because none of the probe tests
// touch ScanAllTargets / TriggerScan.
func newScepProbeServiceForTest(t *testing.T) *NetworkScanService {
t.Helper()
svc := NewNetworkScanService(nil, nil, nil, silentScepLogger())
return svc
}
// fixtureCACert returns a fresh self-signed cert + DER bytes the test
// httptest server can return for GetCACert. notAfter lets tests pin the
// cert into the past so the expired-cert assertions fire.
func fixtureCACert(t *testing.T, cn string, notBefore, notAfter time.Time) (*x509.Certificate, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("ecdsa.GenerateKey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
Issuer: pkix.Name{CommonName: cn + "-issuer"},
NotBefore: notBefore,
NotAfter: notAfter,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("x509.CreateCertificate: %v", err)
}
parsed, _ := x509.ParseCertificate(der)
return parsed, der
}
// fakeSCEPHandler returns an http.Handler that mimics an RFC 8894 SCEP
// server. Caller sets caps + an optional CA cert. GetCACert returns DER
// bytes (single cert form); GetCACaps returns the newline-separated
// list. Counts hits per operation for assertions.
type fakeSCEPHandler struct {
caps string
caCertDER []byte
getCAHits atomic.Int32
getCertHits atomic.Int32
emitFakeError bool
}
func (h *fakeSCEPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
op := r.URL.Query().Get("operation")
switch op {
case "GetCACaps":
h.getCAHits.Add(1)
if h.emitFakeError {
http.Error(w, "fake server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte(h.caps))
case "GetCACert":
h.getCertHits.Add(1)
if len(h.caCertDER) == 0 {
http.Error(w, "no ca cert", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
_, _ = w.Write(h.caCertDER)
default:
http.NotFound(w, r)
}
}
// installPermissiveClientForTest swaps the production SSRF-defended
// HTTP client + URL validator for permissive test versions. The
// production stack rejects loopback / link-local / cloud-metadata IPs
// for SSRF defense; the httptest servers tests spin up bind to
// 127.0.0.1 by default, so tests need to bypass both layers. Mirrors
// the webhook notifier's `newForTest` pattern.
func installPermissiveClientForTest(svc *NetworkScanService) {
svc.scepHTTPClient = &http.Client{
Timeout: 5 * time.Second,
}
svc.scepValidateURL = func(string) error { return nil }
}
// TestProbeSCEP_AdvertisesAllCaps exercises the happy path where the
// fake server advertises the full RFC 8894 + AES + POST + Renewal +
// SHA-256 + SHA-512 set. Probe must parse all the flags + extract CA
// cert metadata + return reachable=true with no error.
func TestProbeSCEP_AdvertisesAllCaps(t *testing.T) {
cert, der := fixtureCACert(t, "fixture-ca", time.Now().Add(-1*time.Hour), time.Now().Add(365*24*time.Hour))
fake := &fakeSCEPHandler{
caps: "POSTPKIOperation\nSHA-256\nSHA-512\nAES\nSCEPStandard\nRenewal\n",
caCertDER: der,
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if !res.Reachable {
t.Fatalf("Reachable = false, want true")
}
if !res.SupportsRFC8894 || !res.SupportsAES || !res.SupportsPOSTOperation || !res.SupportsRenewal {
t.Errorf("expected all caps, got %+v", res)
}
if !res.SupportsSHA256 || !res.SupportsSHA512 {
t.Errorf("SHA cap flags missing")
}
if res.CACertSubject == "" || res.CACertSubject != cert.Subject.String() {
t.Errorf("CACertSubject = %q, want %q", res.CACertSubject, cert.Subject.String())
}
if res.CACertExpired {
t.Errorf("CACertExpired = true, want false (cert is valid for 365 days)")
}
if res.CACertChainLength != 1 {
t.Errorf("CACertChainLength = %d, want 1", res.CACertChainLength)
}
if !strings.HasPrefix(res.CACertAlgorithm, "ECDSA") {
t.Errorf("CACertAlgorithm = %q, want ECDSA-*", res.CACertAlgorithm)
}
if res.Error != "" {
t.Errorf("Error = %q, want empty", res.Error)
}
}
// TestProbeSCEP_MissingSCEPStandard probes a server that omits the
// "SCEPStandard" capability — modelling a pre-RFC-8894 server. Probe
// must succeed but flag SupportsRFC8894=false.
func TestProbeSCEP_MissingSCEPStandard(t *testing.T) {
_, der := fixtureCACert(t, "old-ca", time.Now().Add(-1*time.Hour), time.Now().Add(180*24*time.Hour))
fake := &fakeSCEPHandler{
caps: "POSTPKIOperation\nSHA-1\nDES3\n", // legacy server
caCertDER: der,
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if res.SupportsRFC8894 {
t.Errorf("SupportsRFC8894 = true, want false (legacy server)")
}
if !res.SupportsPOSTOperation {
t.Errorf("SupportsPOSTOperation = false (server advertises POSTPKIOperation)")
}
if res.SupportsAES {
t.Errorf("SupportsAES = true (server doesn't advertise AES)")
}
}
// TestProbeSCEP_GetCACertExpired probes a server whose CA cert NotAfter
// is in the past. Probe must mark CACertExpired=true.
func TestProbeSCEP_GetCACertExpired(t *testing.T) {
_, der := fixtureCACert(t, "expired-ca",
time.Now().Add(-2*365*24*time.Hour),
time.Now().Add(-30*24*time.Hour),
)
fake := &fakeSCEPHandler{
caps: "SCEPStandard\n",
caCertDER: der,
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if !res.CACertExpired {
t.Errorf("CACertExpired = false, want true (cert expired 30d ago)")
}
}
// TestProbeSCEP_Unreachable points the probe at a URL that doesn't
// respond. Probe must return reachable=false + a non-empty Error.
func TestProbeSCEP_Unreachable(t *testing.T) {
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
// Use a port nothing's listening on. A short connect timeout via
// the install client means we don't wait long.
svc.scepHTTPClient = &http.Client{Timeout: 500 * time.Millisecond}
res, err := svc.ProbeSCEP(context.Background(), "http://127.0.0.1:1/scep")
if err == nil {
t.Fatalf("expected an error, got result: %+v", res)
}
if res == nil {
t.Fatalf("expected non-nil result with error populated, got nil")
}
if res.Reachable {
t.Errorf("Reachable = true, want false")
}
if res.Error == "" {
t.Errorf("Error = empty, want a connection-failure message")
}
}
// TestProbeSCEP_RejectsReservedIP confirms the SSRF up-front check
// fires for literal reserved IPs. Run with the production HTTP client
// (the one wired by SafeHTTPDialContext) — the URL validation step
// rejects before any HTTP call.
func TestProbeSCEP_RejectsReservedIP(t *testing.T) {
svc := newScepProbeServiceForTest(t)
// Do NOT install the permissive client; we want the production
// SSRF path to fire on the first call.
res, err := svc.ProbeSCEP(context.Background(), "http://169.254.169.254/scep") // EC2 metadata
if err == nil {
t.Fatalf("expected SSRF rejection, got result: %+v", res)
}
if !errors.Is(err, errSSRFRejection) && !strings.Contains(err.Error(), "url validation") {
// Either pattern is acceptable — the underlying validator
// wraps its error string differently across versions; what
// matters is that the Error string mentions the validation
// failure and the result has Reachable=false.
t.Logf("err: %v (acceptable as long as Reachable=false + Error captured)", err)
}
if res == nil {
t.Fatalf("expected non-nil result with error populated, got nil")
}
if res.Reachable {
t.Errorf("Reachable = true, want false")
}
if !strings.Contains(res.Error, "url validation") {
t.Errorf("Error = %q, want it to mention url validation", res.Error)
}
}
// errSSRFRejection is a sentinel for the test's optional errors.Is
// match. The probe wraps validation errors in a generic fmt.Errorf so
// the underlying ValidateSafeURL error can vary; the test focuses on
// the visible behavior (Reachable=false + Error captured).
var errSSRFRejection = errors.New("url validation rejection")
// TestProbeSCEP_PEMWrappedCert exercises the fallback parse path: some
// servers return PEM-wrapped DER instead of raw DER for GetCACert.
// Probe should still parse the cert successfully.
func TestProbeSCEP_PEMWrappedCert(t *testing.T) {
cert, der := fixtureCACert(t, "pem-ca", time.Now().Add(-1*time.Hour), time.Now().Add(30*24*time.Hour))
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
fake := &fakeSCEPHandler{
caps: "SCEPStandard\nAES\n",
caCertDER: pemBytes, // server returned PEM, not DER
}
srv := httptest.NewServer(fake)
defer srv.Close()
svc := newScepProbeServiceForTest(t)
installPermissiveClientForTest(svc)
res, err := svc.ProbeSCEP(context.Background(), srv.URL+"/scep")
if err != nil {
t.Fatalf("ProbeSCEP: %v", err)
}
if res.CACertSubject != cert.Subject.String() {
t.Errorf("CACertSubject = %q, want %q (PEM fallback parse)", res.CACertSubject, cert.Subject.String())
}
}