mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +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:
@@ -554,6 +554,22 @@ type NetworkScanRepository interface {
|
||||
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.
|
||||
type OwnerRepository interface {
|
||||
// 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)
|
||||
Reference in New Issue
Block a user