mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:51: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
234 lines
6.6 KiB
Go
234 lines
6.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// mockNetworkScanService implements NetworkScanService for testing.
|
|
type mockNetworkScanService struct {
|
|
targets []*domain.NetworkScanTarget
|
|
}
|
|
|
|
func (m *mockNetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
|
return m.targets, nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error) {
|
|
for _, t := range m.targets {
|
|
if t.ID == id {
|
|
return t, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
|
}
|
|
|
|
func (m *mockNetworkScanService) CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
|
if target.Name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
target.ID = "nst-test-123"
|
|
m.targets = append(m.targets, target)
|
|
return target, nil
|
|
}
|
|
|
|
func (m *mockNetworkScanService) UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error) {
|
|
for _, t := range m.targets {
|
|
if t.ID == id {
|
|
if target.Name != "" {
|
|
t.Name = target.Name
|
|
}
|
|
return t, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
|
}
|
|
|
|
func (m *mockNetworkScanService) DeleteTarget(ctx context.Context, id string) error {
|
|
for i, t := range m.targets {
|
|
if t.ID == id {
|
|
m.targets = append(m.targets[:i], m.targets[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("not found: %w", ErrMockNotFound)
|
|
}
|
|
|
|
func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error) {
|
|
for _, t := range m.targets {
|
|
if t.ID == targetID {
|
|
return &domain.DiscoveryScan{
|
|
ID: "dscan-test",
|
|
AgentID: "server-scanner",
|
|
CertificatesFound: 3,
|
|
}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
|
}
|
|
|
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
|
|
// satisfaction stubs for the SCEP probe methods. The existing mock
|
|
// doesn't exercise the probe path; dedicated tests in
|
|
// scep_probe_handler_test.go (Phase 11.5.F) cover that surface with
|
|
// their own targeted mock.
|
|
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
|
|
return nil, fmt.Errorf("ProbeSCEP not implemented in mockNetworkScanService — use scepProbeMockService")
|
|
}
|
|
|
|
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
|
return []*domain.SCEPProbeResult{}, nil
|
|
}
|
|
|
|
func TestListNetworkScanTargets(t *testing.T) {
|
|
svc := &mockNetworkScanService{
|
|
targets: []*domain.NetworkScanTarget{
|
|
{ID: "nst-1", Name: "target1", CIDRs: []string{"10.0.0.0/24"}, Ports: []int64{443}},
|
|
{ID: "nst-2", Name: "target2", CIDRs: []string{"192.168.0.0/16"}, Ports: []int64{443, 8443}},
|
|
},
|
|
}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/network-scan-targets", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListNetworkScanTargets(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
|
|
var resp PagedResponse
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp.Total != 2 {
|
|
t.Errorf("expected total 2, got %d", resp.Total)
|
|
}
|
|
}
|
|
|
|
func TestListNetworkScanTargets_Empty(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/network-scan-targets", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListNetworkScanTargets(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreateNetworkScanTarget(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"name": "Production",
|
|
"cidrs": []string{"10.0.0.0/24"},
|
|
"ports": []int64{443},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
h.CreateNetworkScanTarget(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateNetworkScanTarget_InvalidJSON(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader([]byte("not json")))
|
|
w := httptest.NewRecorder()
|
|
h.CreateNetworkScanTarget(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCreateNetworkScanTarget_MissingName(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
body, _ := json.Marshal(map[string]interface{}{
|
|
"cidrs": []string{"10.0.0.0/24"},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", bytes.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
h.CreateNetworkScanTarget(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestDeleteNetworkScanTarget_NotFound(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/network-scan-targets/nst-nonexistent", nil)
|
|
req.SetPathValue("id", "nst-nonexistent")
|
|
w := httptest.NewRecorder()
|
|
h.DeleteNetworkScanTarget(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestTriggerNetworkScan(t *testing.T) {
|
|
svc := &mockNetworkScanService{
|
|
targets: []*domain.NetworkScanTarget{
|
|
{ID: "nst-1", Name: "target1"},
|
|
},
|
|
}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets/nst-1/scan", nil)
|
|
req.SetPathValue("id", "nst-1")
|
|
w := httptest.NewRecorder()
|
|
h.TriggerNetworkScan(w, req)
|
|
|
|
if w.Code != http.StatusAccepted {
|
|
t.Errorf("expected 202, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestTriggerNetworkScan_NotFound(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets/nst-nonexistent/scan", nil)
|
|
req.SetPathValue("id", "nst-nonexistent")
|
|
w := httptest.NewRecorder()
|
|
h.TriggerNetworkScan(w, req)
|
|
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Errorf("expected 500, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestListNetworkScanTargets_MethodNotAllowed(t *testing.T) {
|
|
svc := &mockNetworkScanService{}
|
|
h := NewNetworkScanHandler(svc)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/network-scan-targets", nil)
|
|
w := httptest.NewRecorder()
|
|
h.ListNetworkScanTargets(w, req)
|
|
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("expected 405, got %d", w.Code)
|
|
}
|
|
}
|