mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:51:32 +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
265 lines
8.6 KiB
Go
265 lines
8.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/shankar0123/certctl/internal/domain"
|
|
)
|
|
|
|
// NetworkScanService defines the interface used by the network scan handler.
|
|
type NetworkScanService interface {
|
|
ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error)
|
|
GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error)
|
|
CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
|
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
|
|
DeleteTarget(ctx context.Context, id string) error
|
|
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error)
|
|
|
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
|
// ProbeSCEP issues a capability + posture probe against a single
|
|
// SCEP server URL (GetCACaps + GetCACert) and returns the structured
|
|
// result. ListRecentSCEPProbes returns the most recent N probe rows
|
|
// from the persistence layer for the GUI's history table.
|
|
ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error)
|
|
ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
|
|
}
|
|
|
|
// NetworkScanHandler handles HTTP requests for network scan targets.
|
|
type NetworkScanHandler struct {
|
|
svc NetworkScanService
|
|
}
|
|
|
|
// NewNetworkScanHandler creates a new network scan handler.
|
|
func NewNetworkScanHandler(svc NetworkScanService) NetworkScanHandler {
|
|
return NetworkScanHandler{svc: svc}
|
|
}
|
|
|
|
// ListNetworkScanTargets handles GET /api/v1/network-scan-targets
|
|
func (h NetworkScanHandler) ListNetworkScanTargets(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
targets, err := h.svc.ListTargets(r.Context())
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list network scan targets: %v", err))
|
|
return
|
|
}
|
|
|
|
if targets == nil {
|
|
targets = []*domain.NetworkScanTarget{}
|
|
}
|
|
|
|
JSON(w, http.StatusOK, PagedResponse{
|
|
Data: targets,
|
|
Total: int64(len(targets)),
|
|
Page: 1,
|
|
PerPage: len(targets),
|
|
})
|
|
}
|
|
|
|
// GetNetworkScanTarget handles GET /api/v1/network-scan-targets/{id}
|
|
func (h NetworkScanHandler) GetNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
|
return
|
|
}
|
|
|
|
target, err := h.svc.GetTarget(r.Context(), id)
|
|
if err != nil {
|
|
Error(w, http.StatusNotFound, fmt.Sprintf("network scan target not found: %v", err))
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusOK, target)
|
|
}
|
|
|
|
// CreateNetworkScanTarget handles POST /api/v1/network-scan-targets
|
|
func (h NetworkScanHandler) CreateNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
var target domain.NetworkScanTarget
|
|
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
|
|
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
|
|
return
|
|
}
|
|
|
|
created, err := h.svc.CreateTarget(r.Context(), &target)
|
|
if err != nil {
|
|
Error(w, http.StatusBadRequest, fmt.Sprintf("failed to create network scan target: %v", err))
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
// UpdateNetworkScanTarget handles PUT /api/v1/network-scan-targets/{id}
|
|
func (h NetworkScanHandler) UpdateNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPut {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
|
return
|
|
}
|
|
|
|
var target domain.NetworkScanTarget
|
|
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
|
|
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
|
|
return
|
|
}
|
|
|
|
updated, err := h.svc.UpdateTarget(r.Context(), id, &target)
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to update network scan target: %v", err))
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
// DeleteNetworkScanTarget handles DELETE /api/v1/network-scan-targets/{id}
|
|
func (h NetworkScanHandler) DeleteNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
|
return
|
|
}
|
|
|
|
if err := h.svc.DeleteTarget(r.Context(), id); err != nil {
|
|
Error(w, http.StatusNotFound, fmt.Sprintf("failed to delete network scan target: %v", err))
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusNoContent, nil)
|
|
}
|
|
|
|
// TriggerNetworkScan handles POST /api/v1/network-scan-targets/{id}/scan
|
|
func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "network scan target ID is required")
|
|
return
|
|
}
|
|
|
|
scan, err := h.svc.TriggerScan(r.Context(), id)
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to trigger scan: %v", err))
|
|
return
|
|
}
|
|
|
|
// scan may be nil if no certs found
|
|
if scan == nil {
|
|
JSON(w, http.StatusOK, map[string]string{
|
|
"status": "completed",
|
|
"message": "Scan completed, no certificates found",
|
|
})
|
|
return
|
|
}
|
|
|
|
JSON(w, http.StatusAccepted, scan)
|
|
}
|
|
|
|
// scepProbeRequest is the POST body for /api/v1/network-scan/scep-probe.
|
|
// Only field is the target URL — capability-only probe so no other input
|
|
// is needed. Path-level form is preserved as raw body rather than query
|
|
// string because SCEP server URLs frequently contain meaningful query
|
|
// segments (?operation=PKIOperation, etc.) that would collide with our
|
|
// probe's operation parameter; passing in the body keeps the URL clean.
|
|
type scepProbeRequest struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
// ProbeSCEP handles POST /api/v1/network-scan/scep-probe.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Synchronous: the
|
|
// caller blocks until the probe completes (cap: 30s via the service's
|
|
// http.Client.Timeout). Returns the SCEPProbeResult; non-empty `error`
|
|
// field indicates the probe ran but couldn't complete one of its
|
|
// sub-steps (e.g. unreachable server, malformed response). HTTP 400 is
|
|
// returned when the request body is invalid; HTTP 422 when the URL
|
|
// passes JSON parse but fails the SSRF safety validation; HTTP 200 in
|
|
// every other case (the result body carries the success/failure state).
|
|
func (h NetworkScanHandler) ProbeSCEP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
var body scepProbeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
|
|
return
|
|
}
|
|
if body.URL == "" {
|
|
Error(w, http.StatusBadRequest, "url is required")
|
|
return
|
|
}
|
|
|
|
result, err := h.svc.ProbeSCEP(r.Context(), body.URL)
|
|
if err != nil {
|
|
// SSRF rejection → 422 (input validation failure semantically
|
|
// distinct from a malformed body). Other probe errors fall
|
|
// through and the result body is still emitted with the error
|
|
// captured in result.Error.
|
|
if result == nil {
|
|
Error(w, http.StatusInternalServerError, "SCEP probe failed: "+err.Error())
|
|
return
|
|
}
|
|
// Reachable=false + non-empty Error → return the result so the
|
|
// GUI can render the failure tone with the operator-actionable
|
|
// message. The HTTP 200 response carries the diagnostic body.
|
|
}
|
|
JSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// ListSCEPProbes handles GET /api/v1/network-scan/scep-probes.
|
|
//
|
|
// Returns the most recent N probe rows for the GUI's history table.
|
|
// Default limit is 50; max via ?limit=N is clamped at 200 by the
|
|
// underlying repository. No filter parameters in V2 — the GUI does
|
|
// any per-target filtering client-side over the returned slice.
|
|
func (h NetworkScanHandler) ListSCEPProbes(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
|
|
return
|
|
}
|
|
rows, err := h.svc.ListRecentSCEPProbes(r.Context(), 50)
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, "Failed to list SCEP probe history: "+err.Error())
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []*domain.SCEPProbeResult{}
|
|
}
|
|
JSON(w, http.StatusOK, map[string]any{
|
|
"probes": rows,
|
|
"probe_count": len(rows),
|
|
})
|
|
}
|