6 Commits

Author SHA1 Message Date
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00
shankar0123 b0fc067317 security: close CodeQL #17 (log injection) + #23 (SSRF false-positive reopen)
Two CodeQL alerts in one sweep — both medium-impact follow-ups
on already-merged guards.

Alert #17 — go/log-injection (CWE-117) at
internal/api/middleware/middleware.go:58:

  log.Printf("[%s] %s %s %d %v", requestID, r.Method, r.URL.Path, ...)

  r.Method and r.URL.Path are attacker-controllable (Go's net/http
  percent-decodes path segments before they reach handlers, so
  r.URL.Path can contain CR/LF in the decoded form even though raw
  HTTP request lines cannot). An attacker who controls a URL can
  forge new log entries by embedding %0A%0Afake-log-line.

  Fix: introduce scrubLogValue helper that replaces CR/LF/NUL with
  spaces. Apply to both r.Method and r.URL.Path. Replacement is
  structural (collapse to space) not destructive (drop) so an
  operator scanning the log still sees the field was present, just
  neutralized. Cheap fast path when the value contains no control
  chars (the common case).

  The deprecation comment on this function recommends NewLogging
  (slog with structured fields) where the logger escapes per-field
  natively. The Logging function is preserved for back-compat
  callers; the scrubber is the load-bearing CWE-117 defense for the
  legacy path.

Alert #23 — go/request-forgery (CWE-918) at scep_probe.go:271:

  CodeQL reopened the alert after commit e6919cd. The commit's
  in-function validator dispatch went through a function-pointer
  override hook:

    validateURL := s.scepValidateURL  // could be anything
    if validateURL == nil {
        validateURL = validation.ValidateSafeURL
    }
    if err := validateURL(rawURL); err != nil { ... }

  CodeQL's taint tracker doesn't trust the if-nil branch — the
  override field could be set to a permissive validator, and the
  analyzer can't prove the production validator runs.

  Fix: invert the dispatch. Always call validation.ValidateSafeURL
  literally first; only consult the test-override hook to grant an
  EXEMPTION when the production validator rejects:

    if err := validation.ValidateSafeURL(rawURL); err != nil {
        if s.scepValidateURL == nil || s.scepValidateURL(rawURL) != nil {
            return ... validate url error
        }
    }

  Same applies to ProbeSCEP's entry-point validator. Both call sites
  now have the literal validation.ValidateSafeURL call in-scope of
  the sink (client.Do), which CodeQL recognizes as a sanitizer.

  Production behavior is unchanged: scepValidateURL is nil in
  production, so the production validator's rejection is the only
  gate.

  Test ergonomics are preserved: scepValidateURL still grants the
  test-only exemption for httptest loopback URLs (only difference:
  the override now grants exemption from production validator's
  rejection rather than replacing the validator entirely; identical
  net effect).

Verified locally:
  gofmt: clean (strings is already imported in middleware.go).
  go vet ./internal/api/middleware/... + ./internal/service/...:
    exit 0.
  go test -short ./internal/api/middleware/...: ok 0.244s.
  go test -short ./internal/service/...: ok 4.965s
    (every existing scep_probe test still green — production +
    httptest paths both work).

References:
  https://github.com/certctl-io/certctl/security/code-scanning/17
  https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL #17. Re-closes CodeQL #23 with a fix CodeQL's taint
tracker can verify.
2026-05-04 05:29:35 +00:00
shankar0123 e6919cdaba security(scep_probe): re-validate URL inside scepHTTPGet to close CodeQL #23 (CWE-918)
CodeQL alert #23 (go/request-forgery, CWE-918 SSRF) flagged the
client.Do(req) sink at internal/service/scep_probe.go:232 because
the URL parameter to scepHTTPGet is taint-traced from the user-
supplied input to ProbeSCEP without the analyzer recognizing the
upstream sanitizer.

The defense-in-depth was already in place:
  1. validation.ValidateSafeURL at ProbeSCEP entry (line 75) —
     rejects obvious SSRF targets (loopback / link-local / cloud
     metadata literals) before any network call.
  2. validation.SafeHTTPDialContext on the http.Transport —
     re-resolves the host at dial time and rejects connections to
     reserved IP ranges. This is the authoritative SSRF + DNS-
     rebinding guard. Even if step 1 was bypassed, the dial would
     still fail.

But CodeQL's taint tracker doesn't follow the validator across
function boundaries, so the alert stays open even though the code
is safe. This commit re-runs validation.ValidateSafeURL inside
scepHTTPGet immediately before http.NewRequestWithContext —
sanitizer in the same function as the sink, which CodeQL
recognizes as a guard.

Bonus defense-in-depth: any future call site that wires a URL
into scepHTTPGet without going through ProbeSCEP (e.g. a new code
path that directly probes a discovered URL) inherits the same
SSRF guard automatically. Fail-closed by default.

The validator dispatch matches ProbeSCEP's pattern — tests
override via s.scepValidateURL to hit httptest loopback servers;
production callers use validation.ValidateSafeURL. The probe's
existing httptest-based tests continue to work unchanged.

Verified locally:
  gofmt: clean.
  go vet ./...: exit 0.
  go test -short ./internal/service/...: ok 4.029s
    (every existing scep_probe test still green — the new
    revalidation is a no-op for tests that go through ProbeSCEP
    because the same validator already passed once at entry).

Reference: https://github.com/certctl-io/certctl/security/code-scanning/23
Closes CodeQL alert #23 (go/request-forgery).
2026-05-04 04:58:51 +00:00
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00
shankar0123 84fac19f98 fix(scep-probe): satisfy staticcheck QF1008 in describeCertAlgorithm
CI flagged QF1008 on the chained selector pub.Curve.Params() — the
linter wants the promoted-method form pub.Params() (Curve is embedded
in ecdsa.PublicKey, so Params is reachable via promotion). Restructure
the nil check so the embedded interface still gets validated before the
promoted call, then invoke pub.Params() once and reuse the result.

Verification:
  * gofmt clean
  * staticcheck on internal/service/...: clean
  * 6/6 TestProbeSCEP_* tests still pass
2026-04-29 19:00:05 +00:00
shankar0123 506cff137d 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
2026-04-29 18:51:57 +00:00