mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:11:31 +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
313 lines
11 KiB
Go
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())
|
|
}
|
|
}
|