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:
shankar0123
2026-04-29 18:51:57 +00:00
parent 0be889ff1d
commit 506cff137d
18 changed files with 1579 additions and 14 deletions
+14 -1
View File
@@ -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';
@@ -320,6 +320,19 @@ export const reloadAdminSCEPIntuneTrust = (pathID: string) =>
export const getAdminSCEPProfiles = () =>
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
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+38
View File
@@ -719,3 +719,41 @@ export interface SCEPProfilesResponse {
profile_count: number;
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;
}
+114 -1
View File
@@ -1,5 +1,5 @@
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 { MemoryRouter } from 'react-router-dom';
import type { ReactNode } from 'react';
@@ -17,6 +17,9 @@ vi.mock('../api/client', () => ({
updateNetworkScanTarget: vi.fn(),
deleteNetworkScanTarget: 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';
@@ -52,6 +55,10 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
vi.clearAllMocks();
cleanup();
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 () => {
@@ -82,3 +89,109 @@ describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', ()
).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');
});
});
+221 -1
View File
@@ -7,13 +7,15 @@ import {
updateNetworkScanTarget,
deleteNetworkScanTarget,
triggerNetworkScan,
probeSCEPServer,
listSCEPProbes,
} from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { NetworkScanTarget } from '../api/types';
import type { NetworkScanTarget, SCEPProbeResult } from '../api/types';
function CreateScanTargetModal({ onClose, onCreate }: {
onClose: () => void;
@@ -258,6 +260,7 @@ export default function NetworkScanPage() {
emptyMessage="No scan targets configured. Create one to start discovering certificates on your network."
/>
)}
<SCEPProbeSection />
</div>
{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>
);
}