mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:51:30 +00:00
506cff137d
Phase 11.5 of the SCEP RFC 8894 + Intune master bundle. Adds an
operator-facing SCEP probe that issues GetCACaps + GetCACert against
an arbitrary SCEP server URL and returns a structured posture snapshot
(reachable + advertised caps + RFC 8894 / AES / POST / Renewal /
SHA-256 / SHA-512 support flags + CA cert subject + issuer + NotBefore
+ NotAfter + days-to-expiry + algorithm + chain length).
Two operator use cases per the master prompt:
1. Pre-migration assessment — probe an existing EJBCA / NDES SCEP
server before switching to certctl to see what capabilities it
advertises and what the CA cert looks like.
2. Compliance posture audits — periodic ad-hoc probes against the
operator's own SCEP servers to flag drift.
Capability-only — does NOT POST a CSR per the spec (would consume slot
allocations on the target server + create audit noise). Standalone CLI
binary explicitly out of scope (per the master prompt §11.5.6 and the
operator's confirmation): the probe code lands inside certctl; a
future thin Cobra wrapper is a separate decision.
Backend (six new + one extended file):
* internal/domain/network_scan.go — new SCEPProbeResult struct with
every probe field documented for the GUI's display layer.
* migrations/000021_scep_probe_results.up.sql + .down.sql — new
scep_probe_results table with TEXT id, target_url, all probe
flags, CA cert metadata, probed_at, probe_duration_ms, error.
Two indexes: idx_scep_probe_results_probed_at (DESC) for the
'recent probes' GUI query, idx_scep_probe_results_target_url
(target_url, probed_at DESC) for the future per-URL history view.
* internal/repository/interfaces.go — new SCEPProbeResultRepository
interface (Insert + ListRecent).
* internal/repository/postgres/scep_probe_results.go — Postgres
implementation. ListRecent clamps limit to [1, 200]; on read
re-derives ca_cert_days_to_expiry against the query-time wall
clock so 'X days remaining' stays fresh.
* internal/service/scep_probe.go — ProbeSCEP(ctx, url) on
NetworkScanService. Validation order:
1. Up-front URL validation via validation.ValidateSafeURL
(defaults to validation.ValidateSafeURL but injectable for
tests via the new scepValidateURL field on the service).
2. Dial-time SSRF re-check via SafeHTTPDialContext on the
http.Transport (defends against DNS rebinding).
3. GET ?operation=GetCACaps + GET ?operation=GetCACert.
GetCACert handles three response shapes: PKCS#7 SignedData
certs-only envelope (multi-cert), raw DER (single-cert),
and PEM-wrapped DER (non-conforming servers).
Times out at 30s; uses a 1MB body cap for DoS defense; wraps
the result + persists via the repo (nil-safe) before returning.
describeCertAlgorithm helper returns 'RSA-N' / 'ECDSA-curve' /
'Ed25519' / 'DSA' for the GUI's algorithm column.
* internal/service/network_scan.go — added scepProbeRepo +
scepHTTPClient + scepValidateURL + scepIDFn + nowFn fields;
SetSCEPProbeRepo wires the repo at startup.
* internal/api/handler/network_scan.go — extended NetworkScanService
interface with ProbeSCEP + ListRecentSCEPProbes; added two new
HTTP handlers:
POST /api/v1/network-scan/scep-probe (body {url})
GET /api/v1/network-scan/scep-probes (recent history)
Synchronous probe; HTTP 200 with the result body for both success
and reachable-but-failed cases (so the GUI can render the failure
tone with the operator-actionable error message).
* internal/api/router/router.go — registered the two routes inline
after the existing network-scan target endpoints.
* api/openapi.yaml — documented both endpoints (operationId
probeSCEP + listSCEPProbes) with full schema + response codes.
* cmd/server/main.go — wires the new SCEPProbeResultRepository
onto the network scan service via SetSCEPProbeRepo right after
the existing NewNetworkScanService construction.
Backend tests (6 new — exit-criteria-named per the master prompt):
* TestProbeSCEP_AdvertisesAllCaps — happy path, full RFC 8894
capability set, ECDSA P-256 CA cert, 365-day expiry.
* TestProbeSCEP_MissingSCEPStandard — pre-RFC-8894 server (only
POSTPKIOperation + SHA-1 + DES3); SupportsRFC8894 = false.
* TestProbeSCEP_GetCACertExpired — CA cert NotAfter 30d in the
past; CACertExpired = true.
* TestProbeSCEP_Unreachable — connect to TCP port 1; probe
returns Reachable=false + non-empty Error.
* TestProbeSCEP_RejectsReservedIP — http://169.254.169.254/scep
(EC2 metadata literal) rejected by the up-front
validation.ValidateSafeURL gate; result captures the error
without ever issuing the HTTP call.
* TestProbeSCEP_PEMWrappedCert — server returns PEM instead of
raw DER for GetCACert; the fallback parse path handles it.
Frontend (one extended file + types/client):
* web/src/api/types.ts — SCEPProbeResult + SCEPProbesResponse.
* web/src/api/client.ts — probeSCEPServer + listSCEPProbes
helpers.
* web/src/pages/NetworkScanPage.tsx — new SCEPProbeSection
component + ProbeResultPanel (with capability badges + CA cert
details panel + raw caps line) + SCEPProbeHistoryTable. Form
rejects empty URL with inline error before calling the API.
Reload mutation goes through useTrackedMutation with explicit
invalidates: [['scep-probes']] (M-009 contract).
Frontend tests (5 new + 0 regressions):
* Scep probe section header + form renders.
* Empty URL is rejected with inline error and never calls the
probe endpoint.
* Successful probe renders capability badges + CA cert subject
+ days-remaining inline panel.
* Probe-level errors are surfaced in the inline panel (no result
panel rendered).
* Recent-probes history table renders one row per probe.
* (Existing 2 NetworkScanPage XSS-hardening tests stub the new
listSCEPProbes endpoint to an empty list so they still pass.)
Verification:
* gofmt clean on touched files
* go vet ./... clean
* staticcheck on service+handler+router+repository+cmd-server clean
* go test -short across service+handler+router+repository+cmd-server
+ integration: all green (existing + 6 new probe tests pass)
* Frontend tsc --noEmit clean
* Vitest: 7/7 NetworkScanPage tests pass (2 existing XSS + 5 new
probe section)
* G-3 docs-drift CI guard reproduced locally clean (no new env vars)
* M-009 hard-zero useMutation guard clean (probe mutation goes
through useTrackedMutation)
* openapi-parity guard satisfied (both new routes documented)
* The mockNetworkScanService in handler + integration packages
extended with stub Probe methods; targeted coverage stays in
scep_probe_test.go.
Out of scope (per master prompt §11.5.6 + operator confirmation):
* Standalone certctl-scan CLI binary — separate decision, ~1d of
follow-up work when/if shipped.
Refs: cowork/scep-rfc8894-intune-master-prompt.md::Phase 11.5
cowork/scep-rfc8894-intune/progress.md
198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
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';
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// M-029 Pass 3 (Audit M-026): NetworkScanPage XSS-hardening + render coverage.
|
|
// Scan targets are operator-supplied CIDR / hostname strings; scan results
|
|
// are server-side discovery output that may include attacker-controlled
|
|
// cert subjects via the discovery surface.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
vi.mock('../api/client', () => ({
|
|
getNetworkScanTargets: vi.fn(),
|
|
createNetworkScanTarget: vi.fn(),
|
|
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';
|
|
import * as client from '../api/client';
|
|
|
|
function renderWithQuery(ui: ReactNode) {
|
|
const qc = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<MemoryRouter>{ui}</MemoryRouter>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
const xssPayload = '<script data-xss="network-scan">window.__xss_pwned__=1;</script>';
|
|
|
|
const xssScanTarget = {
|
|
id: 'ns-xss-001',
|
|
name: xssPayload,
|
|
network_range: xssPayload,
|
|
ports: [443, 8443],
|
|
agent_id: xssPayload,
|
|
enabled: true,
|
|
last_scan_at: new Date().toISOString(),
|
|
last_scan_status: 'failed',
|
|
last_scan_message: xssPayload,
|
|
};
|
|
|
|
describe('NetworkScanPage — render + XSS hardening (M-026 / M-029 Pass 3)', () => {
|
|
beforeEach(() => {
|
|
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 () => {
|
|
vi.mocked(client.getNetworkScanTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
|
renderWithQuery(<NetworkScanPage />);
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('heading', { level: 2, name: 'Network Scanning' })).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('does NOT execute <script> payloads in scan-target fields', async () => {
|
|
vi.mocked(client.getNetworkScanTargets).mockResolvedValue({
|
|
data: [xssScanTarget],
|
|
total: 1,
|
|
page: 1,
|
|
per_page: 50,
|
|
} as never);
|
|
renderWithQuery(<NetworkScanPage />);
|
|
await waitFor(() => {
|
|
expect(document.body.textContent ?? '').toContain('<script data-xss="network-scan">');
|
|
});
|
|
|
|
const liveScripts = document.querySelectorAll('script[data-xss="network-scan"]');
|
|
expect(liveScripts.length, 'scan-target fields must not inject a live <script>').toBe(0);
|
|
expect(
|
|
(window as unknown as { __xss_pwned__?: number }).__xss_pwned__,
|
|
'scan-target <script> body must not have executed',
|
|
).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');
|
|
});
|
|
});
|