mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 12:28:55 +00:00
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
This commit is contained in:
@@ -732,6 +732,109 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/network-scan/scep-probe:
|
||||||
|
post:
|
||||||
|
tags: [SCEP]
|
||||||
|
summary: Probe an SCEP server for capability + posture
|
||||||
|
description: |
|
||||||
|
Synchronous probe against an SCEP server URL. Issues
|
||||||
|
`GET ?operation=GetCACaps` and `GET ?operation=GetCACert`
|
||||||
|
and returns the structured `SCEPProbeResult` (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).
|
||||||
|
|
||||||
|
Capability-only — does NOT POST a CSR (would consume slot
|
||||||
|
allocations on the target server + create audit noise). Used
|
||||||
|
for pre-migration assessment + compliance posture audits.
|
||||||
|
|
||||||
|
SSRF-defended: the URL is validated up-front (reserved IPs
|
||||||
|
rejected) AND the underlying HTTP client uses the
|
||||||
|
SafeHTTPDialContext that re-resolves the host at dial time
|
||||||
|
(defends against DNS rebinding).
|
||||||
|
|
||||||
|
Result is persisted to the `scep_probe_results` table via
|
||||||
|
migration 000021 so the GUI can show recent probe history.
|
||||||
|
SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
operationId: probeSCEP
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
format: uri
|
||||||
|
description: Base SCEP server URL (no `?operation=...` suffix needed; the probe appends its own operations).
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Probe completed (the result body's `error` field carries any sub-step failure)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
target_url:
|
||||||
|
type: string
|
||||||
|
reachable:
|
||||||
|
type: boolean
|
||||||
|
advertised_caps:
|
||||||
|
type: array
|
||||||
|
items: { type: string }
|
||||||
|
supports_rfc8894: { type: boolean }
|
||||||
|
supports_aes: { type: boolean }
|
||||||
|
supports_post_operation: { type: boolean }
|
||||||
|
supports_renewal: { type: boolean }
|
||||||
|
supports_sha256: { type: boolean }
|
||||||
|
supports_sha512: { type: boolean }
|
||||||
|
ca_cert_subject: { type: string }
|
||||||
|
ca_cert_issuer: { type: string }
|
||||||
|
ca_cert_not_before: { type: string, format: date-time }
|
||||||
|
ca_cert_not_after: { type: string, format: date-time }
|
||||||
|
ca_cert_expired: { type: boolean }
|
||||||
|
ca_cert_days_to_expiry: { type: integer }
|
||||||
|
ca_cert_algorithm: { type: string }
|
||||||
|
ca_cert_chain_length: { type: integer }
|
||||||
|
probed_at: { type: string, format: date-time }
|
||||||
|
probe_duration_ms: { type: integer }
|
||||||
|
error: { type: string }
|
||||||
|
"400":
|
||||||
|
description: Missing or malformed `url` field
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
|
/api/v1/network-scan/scep-probes:
|
||||||
|
get:
|
||||||
|
tags: [SCEP]
|
||||||
|
summary: List recent SCEP probe results
|
||||||
|
description: |
|
||||||
|
Returns the most recent 50 SCEP probe results across any
|
||||||
|
target URL, ordered by `probed_at` descending. Backs the
|
||||||
|
GUI's "Recent SCEP probes" history table on the Network
|
||||||
|
Scan page. SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
operationId: listSCEPProbes
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Recent probe results
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
probes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
probe_count:
|
||||||
|
type: integer
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/InternalError"
|
||||||
|
|
||||||
/api/v1/admin/scep/profiles:
|
/api/v1/admin/scep/profiles:
|
||||||
get:
|
get:
|
||||||
tags: [SCEP]
|
tags: [SCEP]
|
||||||
|
|||||||
@@ -356,6 +356,12 @@ func main() {
|
|||||||
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
|
discoveryService := service.NewDiscoveryService(discoveryRepo, certificateRepo, auditService)
|
||||||
networkScanRepo := postgres.NewNetworkScanRepository(db)
|
networkScanRepo := postgres.NewNetworkScanRepository(db)
|
||||||
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
|
networkScanService := service.NewNetworkScanService(networkScanRepo, discoveryService, auditService, logger)
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — wire the SCEP
|
||||||
|
// probe persistence repo onto the network scan service so the new
|
||||||
|
// /api/v1/network-scan/scep-probe endpoint can persist results to
|
||||||
|
// scep_probe_results (migration 000021).
|
||||||
|
scepProbeRepo := postgres.NewSCEPProbeResultRepository(db)
|
||||||
|
networkScanService.SetSCEPProbeRepo(scepProbeRepo)
|
||||||
logger.Info("initialized network scan service")
|
logger.Info("initialized network scan service")
|
||||||
|
|
||||||
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
|
// Ensure the sentinel "server-scanner" agent exists for network discovery dedup.
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ type NetworkScanService interface {
|
|||||||
UpdateTarget(ctx context.Context, id string, 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
|
DeleteTarget(ctx context.Context, id string) error
|
||||||
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, 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.
|
// NetworkScanHandler handles HTTP requests for network scan targets.
|
||||||
@@ -177,3 +185,80 @@ func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Re
|
|||||||
|
|
||||||
JSON(w, http.StatusAccepted, scan)
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
|||||||
return nil, fmt.Errorf("not found: %w", ErrMockNotFound)
|
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) {
|
func TestListNetworkScanTargets(t *testing.T) {
|
||||||
svc := &mockNetworkScanService{
|
svc := &mockNetworkScanService{
|
||||||
targets: []*domain.NetworkScanTarget{
|
targets: []*domain.NetworkScanTarget{
|
||||||
|
|||||||
@@ -349,6 +349,12 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
|
r.Register("PUT /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.UpdateNetworkScanTarget))
|
||||||
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
|
r.Register("DELETE /api/v1/network-scan-targets/{id}", http.HandlerFunc(reg.NetworkScan.DeleteNetworkScanTarget))
|
||||||
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
|
r.Register("POST /api/v1/network-scan-targets/{id}/scan", http.HandlerFunc(reg.NetworkScan.TriggerNetworkScan))
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
|
// Bearer-auth gated by the standard middleware chain; not admin-
|
||||||
|
// only because the probe is read-only against operator-supplied
|
||||||
|
// URLs and reuses the existing SafeHTTPDialContext SSRF defense.
|
||||||
|
r.Register("POST /api/v1/network-scan/scep-probe", http.HandlerFunc(reg.NetworkScan.ProbeSCEP))
|
||||||
|
r.Register("GET /api/v1/network-scan/scep-probes", http.HandlerFunc(reg.NetworkScan.ListSCEPProbes))
|
||||||
|
|
||||||
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
|
// Verification routes: /api/v1/jobs/{id}/verify and /api/v1/jobs/{id}/verification
|
||||||
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
|
r.Register("POST /api/v1/jobs/{id}/verify", http.HandlerFunc(reg.Verification.VerifyDeployment))
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import "time"
|
|||||||
|
|
||||||
// NetworkScanTarget defines a network range to scan for TLS certificates.
|
// NetworkScanTarget defines a network range to scan for TLS certificates.
|
||||||
type NetworkScanTarget struct {
|
type NetworkScanTarget struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
CIDRs []string `json:"cidrs"`
|
CIDRs []string `json:"cidrs"`
|
||||||
Ports []int64 `json:"ports"`
|
Ports []int64 `json:"ports"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ScanIntervalHours int `json:"scan_interval_hours"`
|
ScanIntervalHours int `json:"scan_interval_hours"`
|
||||||
TimeoutMs int `json:"timeout_ms"`
|
TimeoutMs int `json:"timeout_ms"`
|
||||||
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
LastScanAt *time.Time `json:"last_scan_at,omitempty"`
|
||||||
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
LastScanDurationMs *int `json:"last_scan_duration_ms,omitempty"`
|
||||||
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
LastScanCertsFound *int `json:"last_scan_certs_found,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkScanResult holds the outcome of scanning a single endpoint.
|
// NetworkScanResult holds the outcome of scanning a single endpoint.
|
||||||
@@ -25,3 +25,43 @@ type NetworkScanResult struct {
|
|||||||
Error string
|
Error string
|
||||||
LatencyMs int
|
LatencyMs int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEPProbeResult is the per-target output of an SCEP probe — a
|
||||||
|
// capability/posture snapshot of an SCEP server (RFC 8894 §3.5.1
|
||||||
|
// GetCACaps + §3.5.1 GetCACert). Used for pre-migration assessment
|
||||||
|
// (operators about to switch from EJBCA / NDES to certctl run the
|
||||||
|
// scanner against their existing SCEP server first) and compliance
|
||||||
|
// posture audits.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
//
|
||||||
|
// The probe deliberately does NOT POST a CSR — that would consume slot
|
||||||
|
// allocations on the target server and create audit noise. Reachability
|
||||||
|
// + capability + CA-cert metadata is the value this returns.
|
||||||
|
//
|
||||||
|
// Persistence: instances are stored in scep_probe_results (migration
|
||||||
|
// 000021) so the operator's GUI can show recent probe history.
|
||||||
|
type SCEPProbeResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TargetURL string `json:"target_url"`
|
||||||
|
Reachable bool `json:"reachable"`
|
||||||
|
AdvertisedCaps []string `json:"advertised_caps"` // GetCACaps response, parsed
|
||||||
|
SupportsRFC8894 bool `json:"supports_rfc8894"` // GetCACaps contains "SCEPStandard"
|
||||||
|
SupportsAES bool `json:"supports_aes"` // contains "AES"
|
||||||
|
SupportsPOSTOperation bool `json:"supports_post_operation"` // contains "POSTPKIOperation"
|
||||||
|
SupportsRenewal bool `json:"supports_renewal"` // contains "Renewal"
|
||||||
|
SupportsSHA256 bool `json:"supports_sha256"` // contains "SHA-256"
|
||||||
|
SupportsSHA512 bool `json:"supports_sha512"` // contains "SHA-512"
|
||||||
|
CACertSubject string `json:"ca_cert_subject,omitempty"` // GetCACert leaf cert subject DN
|
||||||
|
CACertIssuer string `json:"ca_cert_issuer,omitempty"` // leaf cert issuer DN
|
||||||
|
CACertNotBefore time.Time `json:"ca_cert_not_before,omitempty"`
|
||||||
|
CACertNotAfter time.Time `json:"ca_cert_not_after,omitempty"`
|
||||||
|
CACertExpired bool `json:"ca_cert_expired"`
|
||||||
|
CACertDaysToExpiry int `json:"ca_cert_days_to_expiry"`
|
||||||
|
CACertAlgorithm string `json:"ca_cert_algorithm,omitempty"` // "RSA-2048", "ECDSA-P256", etc.
|
||||||
|
CACertChainLength int `json:"ca_cert_chain_length"` // 1 = single cert, >1 = full chain returned
|
||||||
|
ProbedAt time.Time `json:"probed_at"`
|
||||||
|
ProbeDurationMs int64 `json:"probe_duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1516,6 +1516,18 @@ func (m *mockNetworkScanService) TriggerScan(ctx context.Context, targetID strin
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — interface
|
||||||
|
// satisfaction stubs. The lifecycle integration tests don't exercise
|
||||||
|
// the SCEP probe path; targeted coverage lives in
|
||||||
|
// internal/service/scep_probe_test.go.
|
||||||
|
func (m *mockNetworkScanService) ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNetworkScanService) ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// mockVerificationService implements handler.VerificationService for integration tests.
|
// mockVerificationService implements handler.VerificationService for integration tests.
|
||||||
type mockVerificationService struct{}
|
type mockVerificationService struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -554,6 +554,22 @@ type NetworkScanRepository interface {
|
|||||||
UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error
|
UpdateScanResults(ctx context.Context, id string, scanAt time.Time, durationMs int, certsFound int) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEPProbeResultRepository persists per-run SCEP probe snapshots.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5. The probe is a
|
||||||
|
// pre-migration / compliance-posture tool — operators run it ad-hoc
|
||||||
|
// against arbitrary SCEP server URLs and the GUI shows recent history.
|
||||||
|
// No FK to network_scan_targets — probe targets are URLs, not necessarily
|
||||||
|
// network-scan-target rows.
|
||||||
|
type SCEPProbeResultRepository interface {
|
||||||
|
// Insert persists a single probe outcome.
|
||||||
|
Insert(ctx context.Context, result *domain.SCEPProbeResult) error
|
||||||
|
// ListRecent returns the most recent N probe results across any URL,
|
||||||
|
// ordered by probed_at descending. Used by the GUI's "recent probes"
|
||||||
|
// table on the Network Scan page.
|
||||||
|
ListRecent(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
// OwnerRepository defines operations for managing certificate owners.
|
// OwnerRepository defines operations for managing certificate owners.
|
||||||
type OwnerRepository interface {
|
type OwnerRepository interface {
|
||||||
// List returns all owners.
|
// List returns all owners.
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"github.com/shankar0123/certctl/internal/domain"
|
||||||
|
"github.com/shankar0123/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SCEPProbeResultRepository is the PostgreSQL-backed implementation of
|
||||||
|
// repository.SCEPProbeResultRepository.
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Each row is one
|
||||||
|
// completed probe run; the table accumulates history (no in-place
|
||||||
|
// updates) so the GUI can show "recent probes" without losing the prior
|
||||||
|
// snapshot's CA cert metadata.
|
||||||
|
type SCEPProbeResultRepository struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSCEPProbeResultRepository creates a new Postgres-backed repo.
|
||||||
|
func NewSCEPProbeResultRepository(db *sql.DB) *SCEPProbeResultRepository {
|
||||||
|
return &SCEPProbeResultRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert persists a single probe result.
|
||||||
|
func (r *SCEPProbeResultRepository) Insert(ctx context.Context, result *domain.SCEPProbeResult) error {
|
||||||
|
if result == nil {
|
||||||
|
return fmt.Errorf("scep probe result: nil")
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO scep_probe_results (
|
||||||
|
id, target_url, reachable,
|
||||||
|
advertised_caps, supports_rfc8894, supports_aes,
|
||||||
|
supports_post_operation, supports_renewal,
|
||||||
|
supports_sha256, supports_sha512,
|
||||||
|
ca_cert_subject, ca_cert_issuer,
|
||||||
|
ca_cert_not_before, ca_cert_not_after, ca_cert_expired,
|
||||||
|
ca_cert_algorithm, ca_cert_chain_length,
|
||||||
|
probed_at, probe_duration_ms, error
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $5, $6,
|
||||||
|
$7, $8,
|
||||||
|
$9, $10,
|
||||||
|
$11, $12,
|
||||||
|
$13, $14, $15,
|
||||||
|
$16, $17,
|
||||||
|
$18, $19, $20
|
||||||
|
)`,
|
||||||
|
result.ID, result.TargetURL, result.Reachable,
|
||||||
|
pq.Array(result.AdvertisedCaps), result.SupportsRFC8894, result.SupportsAES,
|
||||||
|
result.SupportsPOSTOperation, result.SupportsRenewal,
|
||||||
|
result.SupportsSHA256, result.SupportsSHA512,
|
||||||
|
nullString(result.CACertSubject), nullString(result.CACertIssuer),
|
||||||
|
nullTime(result.CACertNotBefore), nullTime(result.CACertNotAfter), result.CACertExpired,
|
||||||
|
nullString(result.CACertAlgorithm), result.CACertChainLength,
|
||||||
|
result.ProbedAt, result.ProbeDurationMs, nullString(result.Error),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert scep probe result: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecent returns the most recent N probe results across any URL,
|
||||||
|
// ordered by probed_at descending. limit is clamped to [1, 200] to bound
|
||||||
|
// the response size — the GUI defaults to 50.
|
||||||
|
func (r *SCEPProbeResultRepository) ListRecent(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT id, target_url, reachable,
|
||||||
|
advertised_caps, supports_rfc8894, supports_aes,
|
||||||
|
supports_post_operation, supports_renewal,
|
||||||
|
supports_sha256, supports_sha512,
|
||||||
|
ca_cert_subject, ca_cert_issuer,
|
||||||
|
ca_cert_not_before, ca_cert_not_after, ca_cert_expired,
|
||||||
|
ca_cert_algorithm, ca_cert_chain_length,
|
||||||
|
probed_at, probe_duration_ms, error,
|
||||||
|
created_at
|
||||||
|
FROM scep_probe_results
|
||||||
|
ORDER BY probed_at DESC
|
||||||
|
LIMIT $1`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list recent scep probe results: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []*domain.SCEPProbeResult
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
row domain.SCEPProbeResult
|
||||||
|
subject sql.NullString
|
||||||
|
issuer sql.NullString
|
||||||
|
notBefore sql.NullTime
|
||||||
|
notAfter sql.NullTime
|
||||||
|
algorithm sql.NullString
|
||||||
|
errString sql.NullString
|
||||||
|
)
|
||||||
|
err := rows.Scan(
|
||||||
|
&row.ID, &row.TargetURL, &row.Reachable,
|
||||||
|
pq.Array(&row.AdvertisedCaps), &row.SupportsRFC8894, &row.SupportsAES,
|
||||||
|
&row.SupportsPOSTOperation, &row.SupportsRenewal,
|
||||||
|
&row.SupportsSHA256, &row.SupportsSHA512,
|
||||||
|
&subject, &issuer,
|
||||||
|
¬Before, ¬After, &row.CACertExpired,
|
||||||
|
&algorithm, &row.CACertChainLength,
|
||||||
|
&row.ProbedAt, &row.ProbeDurationMs, &errString,
|
||||||
|
&row.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan scep probe result row: %w", err)
|
||||||
|
}
|
||||||
|
if subject.Valid {
|
||||||
|
row.CACertSubject = subject.String
|
||||||
|
}
|
||||||
|
if issuer.Valid {
|
||||||
|
row.CACertIssuer = issuer.String
|
||||||
|
}
|
||||||
|
if notBefore.Valid {
|
||||||
|
row.CACertNotBefore = notBefore.Time
|
||||||
|
}
|
||||||
|
if notAfter.Valid {
|
||||||
|
row.CACertNotAfter = notAfter.Time
|
||||||
|
if !row.CACertExpired {
|
||||||
|
// Re-derive days_to_expiry on read so it reflects the
|
||||||
|
// query-time wall clock rather than the persisted
|
||||||
|
// snapshot's wall clock — operators care about how
|
||||||
|
// fresh "30d remaining" is.
|
||||||
|
hours := time.Until(notAfter.Time).Hours()
|
||||||
|
row.CACertDaysToExpiry = int(hours / 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if algorithm.Valid {
|
||||||
|
row.CACertAlgorithm = algorithm.String
|
||||||
|
}
|
||||||
|
if errString.Valid {
|
||||||
|
row.Error = errString.String
|
||||||
|
}
|
||||||
|
out = append(out, &row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate scep probe results: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullString returns sql.NullString — empty becomes NULL.
|
||||||
|
func nullString(s string) sql.NullString {
|
||||||
|
if s == "" {
|
||||||
|
return sql.NullString{}
|
||||||
|
}
|
||||||
|
return sql.NullString{String: s, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullTime returns sql.NullTime — zero time becomes NULL.
|
||||||
|
func nullTime(t time.Time) sql.NullTime {
|
||||||
|
if t.IsZero() {
|
||||||
|
return sql.NullTime{}
|
||||||
|
}
|
||||||
|
return sql.NullTime{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface check.
|
||||||
|
var _ repository.SCEPProbeResultRepository = (*SCEPProbeResultRepository)(nil)
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,6 +30,15 @@ type NetworkScanService struct {
|
|||||||
auditService *AuditService
|
auditService *AuditService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
concurrency int
|
concurrency int
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe
|
||||||
|
// state. Optional: nil-safe so deploys that don't enable the probe
|
||||||
|
// surface (no scep_probe_results table populated) still work.
|
||||||
|
scepProbeRepo repository.SCEPProbeResultRepository
|
||||||
|
scepHTTPClient *http.Client // built from SafeHTTPDialContext for SSRF defense
|
||||||
|
scepValidateURL func(string) error // defaults to validation.ValidateSafeURL; tests inject permissive
|
||||||
|
scepIDFn func() string
|
||||||
|
nowFn func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNetworkScanService creates a new network scan service.
|
// NewNetworkScanService creates a new network scan service.
|
||||||
@@ -44,9 +54,20 @@ func NewNetworkScanService(
|
|||||||
auditService: auditService,
|
auditService: auditService,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
concurrency: 50,
|
concurrency: 50,
|
||||||
|
nowFn: time.Now,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSCEPProbeRepo wires the SCEP probe persistence repository onto the
|
||||||
|
// service. Called from cmd/server/main.go at startup. Nil-safe — calling
|
||||||
|
// ProbeSCEP without a repo just skips the persist step (the probe still
|
||||||
|
// runs and returns its result synchronously).
|
||||||
|
//
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5.
|
||||||
|
func (s *NetworkScanService) SetSCEPProbeRepo(repo repository.SCEPProbeResultRepository) {
|
||||||
|
s.scepProbeRepo = repo
|
||||||
|
}
|
||||||
|
|
||||||
// ListTargets returns all network scan targets.
|
// ListTargets returns all network scan targets.
|
||||||
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
func (s *NetworkScanService) ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error) {
|
||||||
return s.networkScanRepo.List(ctx)
|
return s.networkScanRepo.List(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Down migration for 000021_scep_probe_results.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_scep_probe_results_target_url;
|
||||||
|
DROP INDEX IF EXISTS idx_scep_probe_results_probed_at;
|
||||||
|
DROP TABLE IF EXISTS scep_probe_results;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Migration 000021: SCEP probe results (Phase 11.5 of the SCEP RFC 8894
|
||||||
|
-- + Intune master bundle).
|
||||||
|
--
|
||||||
|
-- The control plane's network scanner can probe an SCEP server URL
|
||||||
|
-- (RFC 8894 §3.5.1 GetCACaps + GetCACert) and persist a structured
|
||||||
|
-- posture snapshot per run. Operators use this for:
|
||||||
|
-- 1. Pre-migration assessment — point the probe at an existing
|
||||||
|
-- EJBCA / NDES SCEP server to see what capabilities it advertises
|
||||||
|
-- (RFC 8894 / AES / POST / Renewal / SHA-256 / SHA-512) and what
|
||||||
|
-- the CA cert looks like (subject, issuer, expiry, algorithm).
|
||||||
|
-- 2. Compliance posture audits — periodic probes against the
|
||||||
|
-- operator's own SCEP servers to flag drift.
|
||||||
|
--
|
||||||
|
-- The probe deliberately does NOT POST a CSR — capability-only.
|
||||||
|
-- Standalone CLI for this same probe is explicitly out of scope for
|
||||||
|
-- this bundle; the GUI surface inside certctl is the only consumer
|
||||||
|
-- of this table at this time.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scep_probe_results (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_url TEXT NOT NULL,
|
||||||
|
reachable BOOLEAN NOT NULL,
|
||||||
|
advertised_caps TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
supports_rfc8894 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_aes BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_post_operation BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_renewal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_sha256 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
supports_sha512 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ca_cert_subject TEXT,
|
||||||
|
ca_cert_issuer TEXT,
|
||||||
|
ca_cert_not_before TIMESTAMPTZ,
|
||||||
|
ca_cert_not_after TIMESTAMPTZ,
|
||||||
|
ca_cert_expired BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ca_cert_algorithm TEXT,
|
||||||
|
ca_cert_chain_length INTEGER NOT NULL DEFAULT 0,
|
||||||
|
probed_at TIMESTAMPTZ NOT NULL,
|
||||||
|
probe_duration_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
error TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The two query patterns the GUI uses:
|
||||||
|
-- - "show me the most recent N probes across any URL" → probed_at DESC
|
||||||
|
-- - "show me the probe history for this URL" → target_url + probed_at DESC
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scep_probe_results_probed_at
|
||||||
|
ON scep_probe_results(probed_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scep_probe_results_target_url
|
||||||
|
ON scep_probe_results(target_url, probed_at DESC);
|
||||||
+14
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse } from './types';
|
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse, IntuneStatsResponse, IntuneReloadTrustResponse, SCEPProfilesResponse, SCEPProbeResult, SCEPProbesResponse } from './types';
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -320,6 +320,19 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
|
|||||||
export const getAdminSCEPProfiles = () =>
|
export const getAdminSCEPProfiles = () =>
|
||||||
fetchJSON<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
|
fetchJSON<SCEPProfilesResponse>(`${BASE}/admin/scep/profiles`);
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe
|
||||||
|
// (capability + posture). Synchronous — the caller blocks until the
|
||||||
|
// probe completes (cap: 30s server-side). Persists to the history
|
||||||
|
// table that listSCEPProbes reads from.
|
||||||
|
export const probeSCEPServer = (url: string) =>
|
||||||
|
fetchJSON<SCEPProbeResult>(`${BASE}/network-scan/scep-probe`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listSCEPProbes = () =>
|
||||||
|
fetchJSON<SCEPProbesResponse>(`${BASE}/network-scan/scep-probes`);
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
export const getAgents = (params: Record<string, string> = {}) => {
|
export const getAgents = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
@@ -719,3 +719,41 @@ export interface SCEPProfilesResponse {
|
|||||||
profile_count: number;
|
profile_count: number;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
|
||||||
|
//
|
||||||
|
// Backs the SCEP Probe section on the Network Scan page. The probe
|
||||||
|
// issues GetCACaps + GetCACert against an operator-supplied SCEP
|
||||||
|
// server URL and returns capability + posture metadata. Used for
|
||||||
|
// pre-migration assessment + compliance posture audits. Persisted
|
||||||
|
// to scep_probe_results (migration 000021) so the GUI can render
|
||||||
|
// recent probe history.
|
||||||
|
export interface SCEPProbeResult {
|
||||||
|
id: string;
|
||||||
|
target_url: string;
|
||||||
|
reachable: boolean;
|
||||||
|
advertised_caps: string[];
|
||||||
|
supports_rfc8894: boolean;
|
||||||
|
supports_aes: boolean;
|
||||||
|
supports_post_operation: boolean;
|
||||||
|
supports_renewal: boolean;
|
||||||
|
supports_sha256: boolean;
|
||||||
|
supports_sha512: boolean;
|
||||||
|
ca_cert_subject?: string;
|
||||||
|
ca_cert_issuer?: string;
|
||||||
|
ca_cert_not_before?: string;
|
||||||
|
ca_cert_not_after?: string;
|
||||||
|
ca_cert_expired: boolean;
|
||||||
|
ca_cert_days_to_expiry: number;
|
||||||
|
ca_cert_algorithm?: string;
|
||||||
|
ca_cert_chain_length: number;
|
||||||
|
probed_at: string;
|
||||||
|
probe_duration_ms: number;
|
||||||
|
error?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SCEPProbesResponse {
|
||||||
|
probes: SCEPProbeResult[];
|
||||||
|
probe_count: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -17,6 +17,9 @@ vi.mock('../api/client', () => ({
|
|||||||
updateNetworkScanTarget: vi.fn(),
|
updateNetworkScanTarget: vi.fn(),
|
||||||
deleteNetworkScanTarget: vi.fn(),
|
deleteNetworkScanTarget: vi.fn(),
|
||||||
triggerNetworkScan: vi.fn(),
|
triggerNetworkScan: vi.fn(),
|
||||||
|
// SCEP RFC 8894 + Intune master bundle Phase 11.5: SCEP probe.
|
||||||
|
probeSCEPServer: vi.fn(),
|
||||||
|
listSCEPProbes: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import NetworkScanPage from './NetworkScanPage';
|
import NetworkScanPage from './NetworkScanPage';
|
||||||
@@ -52,6 +55,10 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
cleanup();
|
cleanup();
|
||||||
delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__;
|
delete (window as unknown as { __xss_pwned__?: number }).__xss_pwned__;
|
||||||
|
// SCEP probe section runs in parallel with the scan-targets table;
|
||||||
|
// stub its history endpoint to an empty list so the existing tests
|
||||||
|
// don't accidentally exercise the probe path.
|
||||||
|
vi.mocked(client.listSCEPProbes).mockResolvedValue({ probes: [], probe_count: 0 } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the page header when getNetworkScanTargets resolves', async () => {
|
it('renders the page header when getNetworkScanTargets resolves', async () => {
|
||||||
@@ -82,3 +89,109 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SCEP Probe section — Phase 11.5 of the master bundle.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const happyProbeResult = {
|
||||||
|
id: 'spr-test-1',
|
||||||
|
target_url: 'https://scep.example.com/scep',
|
||||||
|
reachable: true,
|
||||||
|
advertised_caps: ['POSTPKIOperation', 'SHA-256', 'SHA-512', 'AES', 'SCEPStandard', 'Renewal'],
|
||||||
|
supports_rfc8894: true,
|
||||||
|
supports_aes: true,
|
||||||
|
supports_post_operation: true,
|
||||||
|
supports_renewal: true,
|
||||||
|
supports_sha256: true,
|
||||||
|
supports_sha512: true,
|
||||||
|
ca_cert_subject: 'CN=test-ca',
|
||||||
|
ca_cert_issuer: 'CN=test-ca',
|
||||||
|
ca_cert_not_before: '2026-01-01T00:00:00Z',
|
||||||
|
ca_cert_not_after: '2027-01-01T00:00:00Z',
|
||||||
|
ca_cert_expired: false,
|
||||||
|
ca_cert_days_to_expiry: 250,
|
||||||
|
ca_cert_algorithm: 'ECDSA-P-256',
|
||||||
|
ca_cert_chain_length: 1,
|
||||||
|
probed_at: '2026-04-29T16:00:00Z',
|
||||||
|
probe_duration_ms: 245,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('NetworkScanPage — SCEP probe section (Phase 11.5)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
vi.mocked(client.getNetworkScanTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
|
vi.mocked(client.listSCEPProbes).mockResolvedValue({ probes: [], probe_count: 0 } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the SCEP probe section header + form', async () => {
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
expect(await screen.findByTestId('scep-probe-section')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-url-input')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-submit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty URL with an inline error and never calls the probe endpoint', async () => {
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
fireEvent.click(await screen.findByTestId('scep-probe-submit'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('scep-probe-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(client.probeSCEPServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs a probe and renders capability badges + CA cert details on success', async () => {
|
||||||
|
vi.mocked(client.probeSCEPServer).mockResolvedValue(happyProbeResult as never);
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
|
||||||
|
const input = await screen.findByTestId('scep-probe-url-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'https://scep.example.com/scep' } });
|
||||||
|
fireEvent.click(screen.getByTestId('scep-probe-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(client.probeSCEPServer).toHaveBeenCalledWith('https://scep.example.com/scep');
|
||||||
|
});
|
||||||
|
const panel = await screen.findByTestId('scep-probe-result-panel');
|
||||||
|
expect(panel).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-cap-badges')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('scep-probe-cap-rfc-8894').textContent).toContain('✓');
|
||||||
|
expect(screen.getByTestId('scep-probe-cap-aes').textContent).toContain('✓');
|
||||||
|
// Subject + days-remaining are rendered inside the panel; assert
|
||||||
|
// their substrings rather than using getByText (which matches a
|
||||||
|
// single text node and can miss content split across nested
|
||||||
|
// elements like dt/dd pairs).
|
||||||
|
expect(panel.textContent ?? '').toContain('CN=test-ca');
|
||||||
|
expect(panel.textContent ?? '').toContain('250d remaining');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces probe-level errors in the inline panel', async () => {
|
||||||
|
vi.mocked(client.probeSCEPServer).mockRejectedValue(new Error('network unreachable'));
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByTestId('scep-probe-url-input'), { target: { value: 'https://broken.example.com/scep' } });
|
||||||
|
fireEvent.click(screen.getByTestId('scep-probe-submit'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('scep-probe-error')).toHaveTextContent(/network unreachable/);
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId('scep-probe-result-panel')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the recent-probes history table with a row per probe', async () => {
|
||||||
|
vi.mocked(client.listSCEPProbes).mockResolvedValue({
|
||||||
|
probes: [
|
||||||
|
happyProbeResult,
|
||||||
|
{ ...happyProbeResult, id: 'spr-test-2', target_url: 'https://other.example.com/scep', supports_rfc8894: false },
|
||||||
|
],
|
||||||
|
probe_count: 2,
|
||||||
|
} as never);
|
||||||
|
renderWithQuery(<NetworkScanPage />);
|
||||||
|
|
||||||
|
const table = await screen.findByTestId('scep-probe-history-table');
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
expect(rows[0].textContent).toContain('scep.example.com');
|
||||||
|
expect(rows[1].textContent).toContain('other.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import {
|
|||||||
updateNetworkScanTarget,
|
updateNetworkScanTarget,
|
||||||
deleteNetworkScanTarget,
|
deleteNetworkScanTarget,
|
||||||
triggerNetworkScan,
|
triggerNetworkScan,
|
||||||
|
probeSCEPServer,
|
||||||
|
listSCEPProbes,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { NetworkScanTarget } from '../api/types';
|
import type { NetworkScanTarget, SCEPProbeResult } from '../api/types';
|
||||||
|
|
||||||
function CreateScanTargetModal({ onClose, onCreate }: {
|
function CreateScanTargetModal({ onClose, onCreate }: {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -258,6 +260,7 @@ export default function NetworkScanPage() {
|
|||||||
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
|
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<SCEPProbeSection />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
@@ -269,3 +272,220 @@ export default function NetworkScanPage() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SCEP Probe section — Phase 11.5 of the master bundle.
|
||||||
|
// =============================================================================
|
||||||
|
//
|
||||||
|
// Operator-facing panel that runs an ad-hoc SCEP probe against a single
|
||||||
|
// URL. Used for pre-migration assessment (probe an existing EJBCA / NDES
|
||||||
|
// SCEP server before switching to certctl) and compliance posture audits
|
||||||
|
// (probe your own SCEP server periodically). Capability-only — does NOT
|
||||||
|
// POST a CSR. SSRF-defended at the backend via SafeHTTPDialContext.
|
||||||
|
//
|
||||||
|
// History table polls every 60s via TanStack Query.
|
||||||
|
|
||||||
|
function SCEPProbeSection() {
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [latestResult, setLatestResult] = useState<SCEPProbeResult | null>(null);
|
||||||
|
const [probeError, setProbeError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: ['scep-probes'],
|
||||||
|
queryFn: listSCEPProbes,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const probeMutation = useTrackedMutation<SCEPProbeResult, Error, string>({
|
||||||
|
mutationFn: (target: string) => probeSCEPServer(target),
|
||||||
|
invalidates: [['scep-probes']],
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setLatestResult(result);
|
||||||
|
setProbeError(undefined);
|
||||||
|
},
|
||||||
|
onError: (err: Error) => {
|
||||||
|
setLatestResult(null);
|
||||||
|
setProbeError(err.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleProbe = () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setProbeError('Enter a SCEP server URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProbeError(undefined);
|
||||||
|
probeMutation.mutate(url.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="px-6 py-4 mt-2 border-t border-surface-border" data-testid="scep-probe-section">
|
||||||
|
<header className="mb-3">
|
||||||
|
<h2 className="text-base font-semibold text-ink">SCEP server probe</h2>
|
||||||
|
<p className="text-xs text-ink-muted">
|
||||||
|
Probe a SCEP server URL for capability + posture (RFC 8894 GetCACaps + GetCACert).
|
||||||
|
Use before migrating from EJBCA / NDES to verify what the existing server advertises.
|
||||||
|
Capability-only: does NOT POST a CSR. Reserved IP ranges are rejected.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bg-surface border border-surface-border rounded-lg p-4 mb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://scep.example.com/scep"
|
||||||
|
className="flex-1 border border-surface-border rounded px-3 py-2 text-sm font-mono"
|
||||||
|
data-testid="scep-probe-url-input"
|
||||||
|
disabled={probeMutation.isPending}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleProbe();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleProbe}
|
||||||
|
disabled={probeMutation.isPending}
|
||||||
|
className="px-4 py-2 text-sm text-white bg-brand-600 hover:bg-brand-700 rounded disabled:opacity-50"
|
||||||
|
data-testid="scep-probe-submit"
|
||||||
|
>
|
||||||
|
{probeMutation.isPending ? 'Probing…' : 'Probe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{probeError && (
|
||||||
|
<div className="mt-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="scep-probe-error">
|
||||||
|
{probeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{latestResult && <SCEPProbeResultPanel result={latestResult} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SCEPProbeHistoryTable
|
||||||
|
probes={historyQuery.data?.probes ?? []}
|
||||||
|
isLoading={historyQuery.isLoading}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
|
||||||
|
const tone = result.error
|
||||||
|
? 'bg-red-50 border-red-300 text-red-800'
|
||||||
|
: result.reachable
|
||||||
|
? 'bg-emerald-50 border-emerald-300 text-emerald-900'
|
||||||
|
: 'bg-amber-50 border-amber-300 text-amber-900';
|
||||||
|
return (
|
||||||
|
<div className={`mt-3 rounded border p-3 text-xs ${tone}`} data-testid="scep-probe-result-panel">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<strong className="text-sm">{result.target_url}</strong>
|
||||||
|
<span>{formatDateTime(result.probed_at)} · {result.probe_duration_ms}ms</span>
|
||||||
|
</div>
|
||||||
|
{result.error && (
|
||||||
|
<p className="font-mono text-[11px] mb-2">Error: {result.error}</p>
|
||||||
|
)}
|
||||||
|
{result.reachable && (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2" data-testid="scep-probe-cap-badges">
|
||||||
|
<CapBadge label="RFC 8894" supported={result.supports_rfc8894} />
|
||||||
|
<CapBadge label="AES" supported={result.supports_aes} />
|
||||||
|
<CapBadge label="POST" supported={result.supports_post_operation} />
|
||||||
|
<CapBadge label="Renewal" supported={result.supports_renewal} />
|
||||||
|
<CapBadge label="SHA-256" supported={result.supports_sha256} />
|
||||||
|
<CapBadge label="SHA-512" supported={result.supports_sha512} />
|
||||||
|
</div>
|
||||||
|
{result.ca_cert_subject && (
|
||||||
|
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
||||||
|
<dt className="font-semibold">CA cert subject:</dt>
|
||||||
|
<dd className="font-mono text-[11px]">{result.ca_cert_subject}</dd>
|
||||||
|
<dt className="font-semibold">Issuer:</dt>
|
||||||
|
<dd className="font-mono text-[11px]">{result.ca_cert_issuer}</dd>
|
||||||
|
<dt className="font-semibold">Algorithm:</dt>
|
||||||
|
<dd>{result.ca_cert_algorithm || '(unknown)'}</dd>
|
||||||
|
<dt className="font-semibold">Chain length:</dt>
|
||||||
|
<dd>{result.ca_cert_chain_length}</dd>
|
||||||
|
<dt className="font-semibold">Expires:</dt>
|
||||||
|
<dd>
|
||||||
|
{result.ca_cert_not_after ? formatDateTime(result.ca_cert_not_after) : '(unknown)'}
|
||||||
|
{' '}
|
||||||
|
{result.ca_cert_expired ? (
|
||||||
|
<span className="text-red-600 font-semibold">(EXPIRED)</span>
|
||||||
|
) : (
|
||||||
|
<span>({result.ca_cert_days_to_expiry}d remaining)</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
{result.advertised_caps && result.advertised_caps.length > 0 && (
|
||||||
|
<p className="mt-2 text-[11px]">
|
||||||
|
Raw caps: <code>{result.advertised_caps.join(', ')}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapBadge({ label, supported }: { label: string; supported: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[11px] uppercase px-2 py-0.5 rounded border ${
|
||||||
|
supported ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-gray-100 text-gray-600 border-gray-300'
|
||||||
|
}`}
|
||||||
|
data-testid={`scep-probe-cap-${label.toLowerCase().replace(/\W/g, '-')}`}
|
||||||
|
>
|
||||||
|
{label} {supported ? '✓' : '✗'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SCEPProbeHistoryTable({ probes, isLoading }: { probes: SCEPProbeResult[]; isLoading: boolean }) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-xs text-ink-muted">Loading probe history…</p>;
|
||||||
|
}
|
||||||
|
if (probes.length === 0) {
|
||||||
|
return <p className="text-xs text-ink-muted">No SCEP probes yet — probe a URL above to start.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mt-3" data-testid="scep-probe-history-table">
|
||||||
|
<h3 className="text-xs font-semibold text-ink uppercase tracking-wide mb-2">Recent SCEP probes</h3>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="text-ink-muted uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-1 pr-2">When</th>
|
||||||
|
<th className="text-left py-1 pr-2">Target</th>
|
||||||
|
<th className="text-left py-1 pr-2">Reachable</th>
|
||||||
|
<th className="text-left py-1 pr-2">RFC 8894</th>
|
||||||
|
<th className="text-left py-1 pr-2">CA expiry</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{probes.map((p) => (
|
||||||
|
<tr key={p.id} className="border-t border-surface-border">
|
||||||
|
<td className="py-1 pr-2 font-mono">{formatDateTime(p.probed_at)}</td>
|
||||||
|
<td className="py-1 pr-2 font-mono break-all">{p.target_url}</td>
|
||||||
|
<td className="py-1 pr-2">
|
||||||
|
{p.reachable ? (
|
||||||
|
<span className="text-emerald-700">Yes</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-700">No</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-1 pr-2">{p.supports_rfc8894 ? '✓' : '✗'}</td>
|
||||||
|
<td className="py-1 pr-2">
|
||||||
|
{p.ca_cert_expired ? (
|
||||||
|
<span className="text-red-700 font-semibold">EXPIRED</span>
|
||||||
|
) : p.ca_cert_subject ? (
|
||||||
|
`${p.ca_cert_days_to_expiry}d`
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user