commit 7348faf6c0fba52e3a8e4bc0502c5ce18096f0a7 Author: Public Source Publisher Date: Thu Oct 1 01:30:09 2026 +0200 Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..195a341 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +.git +.gitignore +.gitea +.github +.next +node_modules +.env +.env.* +docs +e2e +scripts +**/*.test.ts +vitest.config.mjs +playwright.config.ts +test-results +playwright-report +coverage +*.tsbuildinfo +certscope-sbom.cdx.json +*.log +*.md +Dockerfile +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..315f971 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +.next/ +out/ +coverage/ +test-results/ +.env +.env.* +!.env.example +*.log +*.tsbuildinfo +certscope-sbom.cdx.json +artifacts/ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..91ad71c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +ARG NODE_BASE_IMAGE=node:22.23.2-alpine3.24@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 + +FROM ${NODE_BASE_IMAGE} AS deps +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +FROM ${NODE_BASE_IMAGE} AS build +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY --from=deps /app/node_modules ./node_modules +COPY package.json package-lock.json next.config.mjs tsconfig.json next-env.d.ts ./ +COPY app ./app +COPY lib ./lib +COPY public ./public + +RUN npm run build + +FROM ${NODE_BASE_IMAGE} AS runner +WORKDIR /app + +ARG BUILD_VERSION=1.0.4 +ARG BUILD_REVISION=local + +ENV NODE_ENV=production +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV CERTSCOPE_BUILD_REVISION=$BUILD_REVISION + +LABEL org.opencontainers.image.revision=$BUILD_REVISION \ + org.opencontainers.image.version=$BUILD_VERSION \ + org.opencontainers.image.title="CertScope" \ + org.opencontainers.image.description="Explainable DNS, TLS and HTTP posture inspection with hardened SSRF boundaries." + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# The runtime does not need package managers. Upgrade the pinned Alpine layer +# to the current security revision, then remove npm/npx/corepack from the +# attack surface scanned and shipped with production. +RUN apk upgrade --no-cache \ + && rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \ + && rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack + +COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=build --chown=nextjs:nodejs /app/public ./public + +USER nextjs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -q -O /dev/null --timeout=4 http://127.0.0.1:3000/api/health || exit 1 + +CMD ["node", "server.js"] diff --git a/PUBLIC_SOURCE_EXPORT.md b/PUBLIC_SOURCE_EXPORT.md new file mode 100644 index 0000000..8441f94 --- /dev/null +++ b/PUBLIC_SOURCE_EXPORT.md @@ -0,0 +1,3 @@ +# Public source export + +This source snapshot was generated from the private canonical repository at revision `fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e`. It contains no private Git history or operational evidence. Changes are published from the canonical repository. diff --git a/PUBLIC_SOURCE_MANIFEST.json b/PUBLIC_SOURCE_MANIFEST.json new file mode 100644 index 0000000..8244023 --- /dev/null +++ b/PUBLIC_SOURCE_MANIFEST.json @@ -0,0 +1,321 @@ +{ + "schemaVersion": 1, + "sourceRevision": "fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e", + "files": [ + { + "path": ".dockerignore", + "sha256": "d4b38ea32c25c79f0348e9873f8ff9d6b1bbe9fac6f392149a397552092fb1ac", + "bytes": 273 + }, + { + "path": ".gitignore", + "sha256": "72c057804545ca54c34f731ef891b01e38082609918e981f82a98b17ca244b73", + "bytes": 154 + }, + { + "path": "Dockerfile", + "sha256": "5982e3118cb5e28d83d9807d82fe681ac626f5023f1ed6ec63804408c3fe7249", + "bytes": 1993 + }, + { + "path": "README.md", + "sha256": "a8b5176abcf5ddc844768c2116807a86d540ebaeab928f37ab059d7ed2630fcc", + "bytes": 6764 + }, + { + "path": "SECURITY.md", + "sha256": "39891b42e166d5e5dd29a6246c270d281305d36a54612477535419f677d5eb45", + "bytes": 5470 + }, + { + "path": "app/api/health/route.test.ts", + "sha256": "e519a0a0d5b946c7b21e6a3e8d9ba4d3ff141edda3eec89e770c889deff9bb28", + "bytes": 489 + }, + { + "path": "app/api/health/route.ts", + "sha256": "b49292d7c03cf8594b9ec8e7c3305e359da03238b15ee5ad48c98e20d0dc0d16", + "bytes": 391 + }, + { + "path": "app/api/inspect/route.test.ts", + "sha256": "de7144c431b0cf510f89cc14255b7a0d0dc38708c21e534faeb395e53e81c56f", + "bytes": 1714 + }, + { + "path": "app/api/inspect/route.ts", + "sha256": "e34c86231aff7f53561a8546713ff24e330b909b013d63ef6704a4fecf3fc57c", + "bytes": 2109 + }, + { + "path": "app/components/report/evidence-views.tsx", + "sha256": "57a6fc6448dbad2346a64adad5b9d74a44458ce29f4e31dffd74259311e50bd0", + "bytes": 9425 + }, + { + "path": "app/components/report/report-actions.tsx", + "sha256": "1b21b4a6f9a97ed590fb139e08b6ead9a1d93957c5ff66386bc1fe383eb1534f", + "bytes": 3816 + }, + { + "path": "app/components/report/report-utils.tsx", + "sha256": "2ab23b8951bc99c434f5bb5ba9f05237196f0624c58cbc08700222f683db4258", + "bytes": 4713 + }, + { + "path": "app/favicon.ico", + "sha256": "9ca7a93d73feb69793cfd7fd5e36ff3c817f363b97ec6fd281bddeb8a8872ab0", + "bytes": 4286 + }, + { + "path": "app/globals.css", + "sha256": "e48ea439984ddc3907f31a0b0183526818c670ff8f173759e5c2545c151bcde3", + "bytes": 45325 + }, + { + "path": "app/icon.svg", + "sha256": "d6e914eed7c3cdbe945073c6827b662b6acee5146657e6139b315fca2befd065", + "bytes": 350 + }, + { + "path": "app/layout.tsx", + "sha256": "f1c70724fa6472f1166b059220dd5a9b0ae8c16575b87ba0e2b29cc965240660", + "bytes": 691 + }, + { + "path": "app/page.tsx", + "sha256": "5fb824a874995521cb9f583923a85b1802fc29b3f059e17038cfdae1d8caeffb", + "bytes": 9944 + }, + { + "path": "app/report-view.tsx", + "sha256": "ef152f5df02c77d6067eab301ac3bf2b10e63eb103e3a7efd9f59b6203d337df", + "bytes": 25234 + }, + { + "path": "docker-compose.yml", + "sha256": "7e99f37c2a8fd04c67aa48ee6ed1b191d6058cd8e517391312f038b13c308570", + "bytes": 1080 + }, + { + "path": "docs/ARCHITECTURE.md", + "sha256": "bcb0b179ea3bb95d80e86beeabc7ce2394874d24369bbee919c1fb5841c5c7b8", + "bytes": 4686 + }, + { + "path": "docs/THREAT_MODEL.md", + "sha256": "54ef9c9d78f4c68628385b17a3d849b308499ae8ac592d2dbf37fa397213e7b4", + "bytes": 3799 + }, + { + "path": "docs/assets/certscope-http-evidence.png", + "sha256": "a587dd0c17177baa83d1f72c030642777eef587946c540a50076dff18bbeb2ef", + "bytes": 295467 + }, + { + "path": "docs/assets/certscope-overview.png", + "sha256": "43fdda7f707617dbe70bdf1244882dbb2308448ac507d22d05ed453d21d9a17c", + "bytes": 401387 + }, + { + "path": "e2e/smoke.spec.ts", + "sha256": "10e44639c6f507b250c50ef8a22c5ca1d078643e65796904906b244c8a3f34f9", + "bytes": 14782 + }, + { + "path": "eslint.config.mjs", + "sha256": "dcb0749e57ff9a80c6114638f3a010dc2bb30e8cf546da8a65e05621fae20d3f", + "bytes": 509 + }, + { + "path": "lib/inspect/admission.test.ts", + "sha256": "f8b9f6f1eac16fc0aee0626bd00b8648f425dd5143c95420d0514ec3a27f9579", + "bytes": 2195 + }, + { + "path": "lib/inspect/admission.ts", + "sha256": "a46e1aef9acb85aa93fe471e0002012ba3b2abed09001492aac80e6754dbc5bd", + "bytes": 2019 + }, + { + "path": "lib/inspect/deadline.ts", + "sha256": "76df833f5ad10363d061284e9bed1f06a290e7c72c7ca232e417ddf42901d65f", + "bytes": 1076 + }, + { + "path": "lib/inspect/demo.ts", + "sha256": "607788f2bc122cf81b29ef080fad6191b1e30d9ac750ea76fa210537edb85dab", + "bytes": 3697 + }, + { + "path": "lib/inspect/diagnostics.test.ts", + "sha256": "8530c28c90e09ae7a9723e51bb22cb55d4a43a97e946d96d16a7cf2e199224de", + "bytes": 8647 + }, + { + "path": "lib/inspect/diagnostics.ts", + "sha256": "b64afd62e5e97c9acab0cf4acc726b4e235abdd4b3c390a989886d961ddd8d98", + "bytes": 27087 + }, + { + "path": "lib/inspect/dns.test.ts", + "sha256": "41bfad04d413406937b28294807350b18b52f1e56630ddde492fd85db88f6a7b", + "bytes": 4204 + }, + { + "path": "lib/inspect/dns.ts", + "sha256": "62174f54923e92203bd4ec87a5274aa40543eb6728d49d3e091bad52d3c2f91b", + "bytes": 9671 + }, + { + "path": "lib/inspect/headers.test.ts", + "sha256": "9de489c7eb2aeacb55a505ef0cb795f327579d3ebfc06e68bd85d8209ac7784f", + "bytes": 5137 + }, + { + "path": "lib/inspect/headers.ts", + "sha256": "abc6f120bd9439ee72b074bd4f5d2401f09ce082f2a8b409175bf83ed8c6fce0", + "bytes": 14253 + }, + { + "path": "lib/inspect/http-status.test.ts", + "sha256": "a1b95fd36f30a533f8518bb67a1ec6a844b1f0895feb44da46413b136c125757", + "bytes": 740 + }, + { + "path": "lib/inspect/http-status.ts", + "sha256": "b16e7177ca4a39d74be8b8b7bc66d286deb3c9b1be718bf87e8b03de6a2c5a02", + "bytes": 843 + }, + { + "path": "lib/inspect/http.test.ts", + "sha256": "1f57311dc6e21c7aa568fefc61d48f0bc8ba86c463faa8827145095a5a9712b2", + "bytes": 5785 + }, + { + "path": "lib/inspect/http.ts", + "sha256": "83a6793782310399d3947b447f1fe68ddcb0120bbdf3854c1f9621a4c13be30d", + "bytes": 12246 + }, + { + "path": "lib/inspect/inspect.test.ts", + "sha256": "75cbdf06dfb3b3adcb521bb75352fbb419902953fdd464f92d626653116cc110", + "bytes": 890 + }, + { + "path": "lib/inspect/inspect.ts", + "sha256": "392cecf0f292df776e71425968ff7f4a5383e16b78cf02429e261f8c8832737a", + "bytes": 2556 + }, + { + "path": "lib/inspect/policy.test.ts", + "sha256": "3c221dcf8676487bb52902dca6a03f28a06314e6de23fd1320e7c056e3e1c99c", + "bytes": 1388 + }, + { + "path": "lib/inspect/policy.ts", + "sha256": "94be8cdb69ecd30bff0abd3841feab1de636c844e0821fb509b499892eec8842", + "bytes": 2991 + }, + { + "path": "lib/inspect/report.test.ts", + "sha256": "4164a76a6837524ff9538a91427b4bf15231c73ef912abe06b9c6f674311d919", + "bytes": 1517 + }, + { + "path": "lib/inspect/report.ts", + "sha256": "5aa54c914059b62e938159e15aecc2fe463aca048d04945e56c55c91d07c8502", + "bytes": 2553 + }, + { + "path": "lib/inspect/score.test.ts", + "sha256": "a8aa4180bccbfe74ec784bd804a7cbcecdf96feb0ed81203e73925bd3fa5b570", + "bytes": 1356 + }, + { + "path": "lib/inspect/score.ts", + "sha256": "f040ab703ea3958aedd0a37efbcb5578d23f9286eb92a04f50aa31c174c38a02", + "bytes": 953 + }, + { + "path": "lib/inspect/tls.test.ts", + "sha256": "bb08b00f4f405047e9677e10eb766d7ab63e4edb6797a0b0cb88fb97d727118c", + "bytes": 5419 + }, + { + "path": "lib/inspect/tls.ts", + "sha256": "a47e2f499cb6b803367ebc9d6be1db4e9e29d61987a935346014120173f10200", + "bytes": 5690 + }, + { + "path": "lib/inspect/types.ts", + "sha256": "1305126a7904ccf691605ba8c05ba4a42d34e2a6ebcfe307f7f179e3967b6400", + "bytes": 1203 + }, + { + "path": "lib/inspect/validation.test.ts", + "sha256": "4faf3451a40e3439d0994d3942297154fe08e9f7ce191ba4fdb2d4fd687d5682", + "bytes": 1248 + }, + { + "path": "lib/inspect/validation.ts", + "sha256": "64e15f3273fb978bb54741698567f27da4e06b4faf749034d67f4acd6fd31b1e", + "bytes": 2992 + }, + { + "path": "lib/version.ts", + "sha256": "2dcc64d9106e5f65f3b9570f96d4aba2622619bad0f7b45ab54f935f67fe3dd5", + "bytes": 187 + }, + { + "path": "next-env.d.ts", + "sha256": "1b59d4c6b83807db275d43f3cf2cc8e9323f465fab764eea091cc5becd5bad37", + "bytes": 295 + }, + { + "path": "next.config.mjs", + "sha256": "b99f21f877f27b595db21b2a0e7f1e99378a87eb21dbcff5febe534f69b2a982", + "bytes": 911 + }, + { + "path": "package-lock.json", + "sha256": "1c9913bac05f70f09f3a512e3edbe6d5031d97d1654c299edee0f8c65d7ce25d", + "bytes": 265224 + }, + { + "path": "package.json", + "sha256": "64a36582dfb66a12c2abf71855c4f4b870ab2b6c674e6336f689ec1f344155a0", + "bytes": 881 + }, + { + "path": "playwright.config.ts", + "sha256": "cf98c061832394f990cf66268b86f1a3414b37221052056216d22f8c30eadb6f", + "bytes": 827 + }, + { + "path": "public/dockerman-icon.png", + "sha256": "350325ea7c6ddf4e38f7eb7f49fcff5e8f8ce172d831f4fbbd50e19f3493ae88", + "bytes": 6114 + }, + { + "path": "scripts/generate-sbom.mjs", + "sha256": "e91c6ef1c3cda5f3121807ef70a5ed690eab2954cd2f98ca9276937819ec7cb1", + "bytes": 666 + }, + { + "path": "scripts/start-standalone.mjs", + "sha256": "878e31a70268cf794464b26382e3a738cf81a1acf4b59035f730f976e6046eec", + "bytes": 885 + }, + { + "path": "tsconfig.json", + "sha256": "9a9faaa9620f2b919755c0696f6790d8b5aee81a0fd8d6eec9803d3161c5dd0c", + "bytes": 740 + }, + { + "path": "vitest.config.mjs", + "sha256": "50683bb0c5edceac875b21d63e621e1acfff59b1889432f784e37880df1f45d4", + "bytes": 296 + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..17f5b4b --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# CertScope-Public + +CertScope is a compact, self-hosted domain posture inspector. Enter one public hostname and get an explainable report across DNS, TLS and HTTP(S), with deterministic findings, evidence pointers and portable exports. + +## The problem it solves + +Configuration risk is spread across DNS records, certificates and HTTP response policy. CertScope puts those signals in one small report and explains each score deduction with the observed evidence and a practical remediation. + +![CertScope overview](docs/assets/certscope-overview.png) + +## What it checks + +- DNS A, AAAA, MX, NS, TXT/SPF and DMARC records, including partial family/query states. +- TLS protocol, endpoint address, certificate identity, SAN/hostname coverage, trust state, validity dates and fingerprint. +- HTTP-to-HTTPS behavior, validated redirect chains, terminal status semantics and security headers. +- Evidence-backed edge-case checks for malformed certificate windows, MX/SPF/DMARC posture and partial record-family failures. +- HSTS value quality, directive-aware CSP quality, `nosniff`, browser-style Referrer-Policy fallback evaluation and clickjacking protection. +- Stable diagnostics, evidence pointers, remediation and JSON/Markdown export. + +The built-in `portfolio-demo.invalid` fixture is deterministic and does not use the network, so the product can be reviewed in under 30 seconds. Before a scan, the landing explains the product boundary. After a scan, it becomes an inspection workspace: provenance, completeness, timestamp, actions and evidence are kept together without fictional dashboard features. If a re-inspection fails, the previous validated report stays visible with its original demo/live label and an explicit error banner. + +## Scoring + +Each category starts at 100 and applies only the negative impacts of findings in that category, clamped to 0–100. The overall score is the rounded equal-weighted average: + +```text +overall = round((DNS + TLS + HTTP) / 3) +``` + +`deductions` is `100 - overall`; it is not the sum of category losses. Missing evidence caused by a partial collector is not silently converted into a finding or score penalty. The report shows completeness separately. + +![HTTP evidence](docs/assets/certscope-http-evidence.png) + +## Security boundary + +CertScope accepts one normalized DNS hostname, not a URL or IP literal. It resolves A and AAAA answers before connecting and rejects every non-public-unicast address, including private, loopback, link-local, multicast, CGNAT and ULA space. HTTP/TLS connect directly to a validated address while retaining the normalized hostname for Host/SNI. Every redirect is checked again, limited to three hops, restricted to HTTP(S) and default ports; blocked redirect targets and HTTPS-to-HTTP downgrades are retained as findings. DNS, TLS and HTTP have hard aggregate deadlines; candidate address attempts are bounded. + +The unauthenticated API has a process-local four-slot concurrency guard and a 30-request/60-second fair-use window. Forwarded client headers are only used when `CERTSCOPE_TRUST_PROXY=true`; otherwise requests share a conservative direct-client bucket. Authentication, accounts, persistence, queues, workers, AI and multi-host scanning are deliberately out of scope. See [SECURITY.md](SECURITY.md) and [the threat model](docs/THREAT_MODEL.md). + +## Run locally + +```bash +npm ci +npm run dev +``` + +Open `http://localhost:3000`, inspect a public hostname, or choose **Load demo** for the offline fixture. + +The release checks are: + +```bash +npm ci +npm audit --omit=dev --audit-level=high +npm audit --audit-level=moderate +npm run lint +npm run typecheck +npm test +npm run build +npx playwright install chromium +npm run test:e2e +npm run sbom +``` + +`npm run sbom` writes an ignored production CycloneDX file named `certscope-sbom.cdx.json`. + +## Docker deployment + +```bash +docker compose up -d --build +``` + +Open `http://localhost:1251`. The image uses Next.js standalone output, a pinned Node Alpine base and a non-root runtime. Compose drops capabilities, enables no-new-privileges, uses a read-only filesystem and bounds temporary storage and resources. `/api/health` reports the application version and build revision. + +Compose publishes port 1251 on all host interfaces. Bind the mapping to loopback (`127.0.0.1:1251:3000`) or place the service behind an authenticated HTTPS reverse proxy before exposing it beyond a trusted network. Set `CERTSCOPE_TRUST_PROXY=true` only when trusted proxy headers cannot be supplied directly by clients. + +## Portfolio walkthrough + +1. Start on the landing to see the exact DNS, TLS and HTTP scope and public-target safeguards. +2. Click **Load demo** to open the offline, deterministic fixture. +3. Compare the dominant overall score with the DNS, TLS and HTTP category cards and report context. +4. Read the completeness indicator and severity-first findings list; open a finding for remediation, then use **View evidence** to jump to its structured DNS, TLS, HTTP or Raw evidence. Close the drawer with Escape. +5. Use the structured DNS, TLS and HTTP tabs; evidence is explicitly `Yes`, `No` or `Unavailable` when a collector is partial. Raw remains the complete normalized JSON with copy, download and line-wrap controls. +6. Re-inspect the current host or export JSON/Markdown. On mobile, these actions live behind **Actions**. + +## Project shape + +`app/` contains the Next.js presentation, health route and inspect API. The report workspace is composed from focused report action, utility and DNS/TLS/HTTP evidence components; `lib/inspect/` contains normalization/policy, deadlines, collectors, header posture parsing, diagnostics, scoring, demo data and exports. Collectors return evidence plus `_meta` status even when a probe fails; presentation code does not perform network probes. + +## Demo screenshots + +The images above show the bundled `portfolio-demo.invalid` fixture. They do not contain production targets or confidential inspection results; the captured UI version may precede the current package version. + +## License + +No project-wide license file is included in this repository. Publishing the source does not grant an open-source license. Normal copyright restrictions and the applicable terms for individual third-party components remain in effect. + +## Public source distribution + +The private canonical repository remains the development source. Reviewed snapshots are synchronized automatically to `CertScope-Public` after changes reach its default branch. The public export contains application source, dependency locks, generic deployment examples and selected demo visuals; private Git history, infrastructure workflows and operational records are excluded. `PUBLIC_SOURCE_MANIFEST.json` identifies the exact source revision and file hashes. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4155abd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,30 @@ +# Security policy + +CertScope is intentionally a bounded single-host inspector, not a network scanner or a general-purpose fetch proxy. + +## Security boundaries + +- Input is one normalized DNS hostname. URLs, credentials, IP literals and malformed hostnames are rejected; Unicode names are converted to their ASCII IDN form before policy checks. +- A and AAAA resolution is performed before connection. Every answer is validated as public unicast before any candidate is attempted. If one family fails, the other safe family can continue as partial evidence; if no safe address exists, inspection stops. +- HTTP and HTTPS use only ports 80 and 443. Redirects are restricted to HTTP(S), revalidated and re-resolved for every hop, limited to three hops, and policy-rejected targets are surfaced as evidence without a request. HTTPS-to-HTTP downgrade is explicitly blocked. +- Redirect evidence redacts URL userinfo before it is returned or exported; the raw redirect is only used transiently to enforce the credential policy. +- HTTP and TLS connect directly to the validated IP address while preserving the normalized hostname for the Host header and TLS SNI. This avoids a later unchecked hostname resolution in the connect path and keeps DNS-rebinding/TOCTOU exposure bounded to the individual validated attempt. +- DNS, TLS and HTTP use aggregate deadlines, not only per-hop timers. Address fallback is bounded and records failed candidates without unbounded retries. HTTP response bodies are discarded after headers are captured. +- The unauthenticated inspect endpoint has four in-process inspection slots and a 30-request/60-second process-local fair-use window. Forwarded identity is only honored with `CERTSCOPE_TRUST_PROXY=true`; otherwise the conservative direct-client bucket is used. This is not a replacement for access control at a public edge. +- Partial collector failures are typed evidence and return a report where possible; they are not fabricated missing findings and do not receive a score deduction for unavailable evidence. +- API input/policy failures are `400`, no-resolution failures are `422`, capacity/fair-use failures are `429`, and unexpected application failures are generic `500` responses. Unexpected logs contain only an error class and optional code, never the hostname, response body or stack trace. +- The app sets baseline browser security headers: CSP, frame denial, `nosniff`, strict referrer policy, permissions policy and CORP. COOP is deliberately not emitted: this documented HTTP-only self-hosted endpoint cannot enforce it and Chromium records it as an ignored-header console error. The CSP retains `unsafe-inline` for Next.js static/RSC bootstrap compatibility. Next.js documents nonce-based CSP as a dynamic-rendering/middleware integration; this compact static/standalone app does not add that architectural cost without a tested end-to-end nonce pipeline. HSTS is not forced on the app because the documented self-hosted endpoint may be served over plain HTTP behind an external TLS terminator. +- The production container uses a pinned Node Alpine base, Next standalone runtime artifacts, a non-root user, no-new-privileges, dropped capabilities, a read-only filesystem, bounded `/tmp` and resource limits. +- The application has no accounts, database, background workers, shell execution, arbitrary fetch endpoint or AI dependency. + +## Header semantics + +The HTTP diagnostics evaluate values, not just presence. HSTS requires one valid non-negative `max-age`; zero is disabled, under one day is weak, and less than one year is short-lived. CSP follows first-directive-wins semantics for duplicates and evaluates broad sources by directive: a script `data:` source is materially different from an image `data:` source. The parser intentionally reports only reliable posture observations; it is not a full CSP validator. `X-Content-Type-Options` must equal `nosniff` case-insensitively. Referrer-Policy comma fallback lists use the last browser-recognized policy; unknown fallback tokens do not invalidate an otherwise supported policy. A restrictive CSP `frame-ancestors` policy is accepted as clickjacking protection equivalent to `X-Frame-Options`, while scheme-wide and wildcard sources are weak. The typed aggregate reports healthy controls, attention required or incomplete evidence without turning a partial collector into a missing control. SPF analysis only evaluates the visible terminal `all` qualifier and never follows `include` chains. Malformed certificate validity windows and malformed MX answers are surfaced as evidence-backed warnings; they are not silently treated as healthy. + +## Dependencies and release hygiene + +`package-lock.json` is authoritative. CI and Docker use `npm ci`, CI runs production-only and full npm audits, the production image is built and scanned for high/critical OS and library vulnerabilities, and a production CycloneDX SBOM is uploaded as a build artifact. Workflow actions are pinned to immutable commit SHAs and the Docker base is pinned to a registry manifest digest. Dependency and base-image updates are deliberate changes followed by the full validation suite. + +## Reporting issues + +Do not include live credentials, private keys or confidential internal hostnames in a report. Provide a minimal reproduction using public or synthetic targets. Security fixes must preserve the public-address and redirect policy and include an offline regression test. diff --git a/app/api/health/route.test.ts b/app/api/health/route.test.ts new file mode 100644 index 0000000..233a7f8 --- /dev/null +++ b/app/api/health/route.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { GET } from "./route"; +import { CERTSCOPE_VERSION } from "@/lib/version"; + +describe("GET /api/health", () => { + it("returns a cache-free versioned liveness response", async () => { + const response = GET(); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toMatchObject({ status: "ok", version: CERTSCOPE_VERSION }); + }); +}); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..c65b911 --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { CERTSCOPE_REVISION, CERTSCOPE_VERSION } from "@/lib/version"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export function GET() { + return NextResponse.json( + { status: "ok", version: CERTSCOPE_VERSION, revision: CERTSCOPE_REVISION }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/app/api/inspect/route.test.ts b/app/api/inspect/route.test.ts new file mode 100644 index 0000000..e5cfeaf --- /dev/null +++ b/app/api/inspect/route.test.ts @@ -0,0 +1,39 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { InspectionInputError, InspectionResolutionError } from "@/lib/inspect/policy"; + +const { inspectMock } = vi.hoisted(() => ({ inspectMock: vi.fn() })); +vi.mock("@/lib/inspect/inspect", () => ({ inspectHost: inspectMock })); + +import { classifyInspectionError, GET } from "./route"; + +function request(host = "example.com") { + return new NextRequest("http://localhost/api/inspect?host=" + encodeURIComponent(host)); +} + +describe("GET /api/inspect error classification", () => { + beforeEach(() => inspectMock.mockReset()); + + it("returns a report with no-store caching on success", async () => { + inspectMock.mockResolvedValue({ host: "example.com" }); + const response = await GET(request()); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(await response.json()).toEqual({ host: "example.com" }); + }); + + it("maps user input and resolution policy errors to client errors", async () => { + inspectMock.mockRejectedValueOnce(new InspectionInputError("invalid host")); + expect((await GET(request("bad input"))).status).toBe(400); + inspectMock.mockRejectedValueOnce(new InspectionResolutionError("not found")); + expect((await GET(request("missing.example"))).status).toBe(422); + }); + + it("classifies unexpected failures as a generic 500 without exposing details", () => { + expect(classifyInspectionError(new Error("private upstream detail"))).toEqual({ + status: 500, + message: "Inspection service is temporarily unavailable. Try again shortly.", + }); + }); + +}); diff --git a/app/api/inspect/route.ts b/app/api/inspect/route.ts new file mode 100644 index 0000000..54d83ea --- /dev/null +++ b/app/api/inspect/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { checkRateLimit, clientKeyFromHeaders, tryAcquireInspection } from "@/lib/inspect/admission"; +import { inspectHost } from "@/lib/inspect/inspect"; +import { InspectionInputError, InspectionPolicyError, InspectionResolutionError } from "@/lib/inspect/policy"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function jsonError(error: string, status: number, headers: Record = {}) { + return NextResponse.json({ error }, { status, headers: { "Cache-Control": "no-store", ...headers } }); +} + +export function classifyInspectionError(error: unknown) { + if (error instanceof InspectionInputError || error instanceof InspectionPolicyError) return { status: 400, message: error.message }; + if (error instanceof InspectionResolutionError) return { status: 422, message: error.message }; + return { status: 500, message: "Inspection service is temporarily unavailable. Try again shortly." }; +} + +export async function GET(request: NextRequest) { + const host = request.nextUrl.searchParams.get("host") ?? ""; + const rate = checkRateLimit(clientKeyFromHeaders(request.headers)); + if (!rate.allowed) return jsonError("Request rate limit reached. Try again shortly.", 429, { "Retry-After": String(rate.retryAfterSeconds) }); + const release = tryAcquireInspection(); + if (!release) { + return jsonError("Inspection capacity is temporarily full. Try again shortly.", 429, { "Retry-After": "1" }); + } + + try { + return NextResponse.json(await inspectHost(host), { headers: { "Cache-Control": "no-store" } }); + } catch (error) { + const classified = classifyInspectionError(error); + if (classified.status === 500) console.error("inspection_unexpected_error", { + name: error instanceof Error ? error.name : "UnknownError", + code: typeof error === "object" && error !== null && "code" in error ? String((error as { code?: unknown }).code ?? "") : "", + }); + return jsonError(classified.message, classified.status); + } finally { + release(); + } +} diff --git a/app/components/report/evidence-views.tsx b/app/components/report/evidence-views.tsx new file mode 100644 index 0000000..145290c --- /dev/null +++ b/app/components/report/evidence-views.tsx @@ -0,0 +1,59 @@ +import { analyzeHeaders, unavailableHeaderPosture, type HeaderControlStatus, type HeaderMap } from "@/lib/inspect/headers"; +import { statusForHttp } from "@/lib/inspect/http-status"; +import { + CopyIcon, + collectorLabel, + collectorMeta, + compact, + finiteNumber, + formatCertificateName, + formatMxRecords, + hasValue, + isRecord, + terminalHttpsResult, + triState, +} from "./report-utils"; + +type CopyHandler = (label: string, value: string) => void; + +function EvidenceValue({ label, value, onCopy }: { label: string; value: unknown; onCopy: CopyHandler }) { + const text = label === "MX" ? formatMxRecords(value) : compact(value); + return
{text}
; +} + +export function CollectorState({ value, label }: { value: Record; label: string }) { + const meta = collectorMeta(value); + return
{label}{collectorLabel(meta.status)}
{meta.errors.length ?
    {meta.errors.map((error, index) =>
  • {isRecord(error) && hasValue(error.message) ? String(error.message) : String(error)}
  • )}
:

No collector errors recorded.

}
; +} + +export function DnsEvidence({ dns, highlightedEvidence, onCopy }: { dns: Record; highlightedEvidence: string | null; onCopy: CopyHandler }) { + const rows: Array<[string, unknown]> = [["A", dns.a], ["AAAA", dns.aaaa], ["MX", dns.mx], ["NS", dns.ns], ["TXT", dns.txt], ["SPF", dns.spf], ["DMARC", dns.dmarc]]; + const spfPosture = isRecord(dns.spfPosture) ? dns.spfPosture : null; + const dmarcPosture = isRecord(dns.dmarcPosture) ? dns.dmarcPosture : null; + return

DNS records

{rows.map(([label, value]) =>
{label}
)}

Parsed mail posture

SPF
{compact(spfPosture?.status ?? dns.spf)}
SPF terminal all
{compact(spfPosture?.allQualifier ?? "Unavailable")}
DMARC
{compact(dmarcPosture?.status ?? dns.dmarc)}
Policy
{compact(dmarcPosture?.policy ?? "Unavailable")}
Records
{Array.isArray(dns.dmarc) ? dns.dmarc.length : 0} DMARC / {Array.isArray(dns.spf) ? dns.spf.length : 0} SPF
; +} + +export function TlsEvidence({ tls, highlightedEvidence }: { tls: Record; highlightedEvidence: string | null }) { + const attempts = Array.isArray(tls.attempts) ? tls.attempts : []; + const status = collectorMeta(tls).status; + return

Certificate

Protocol
{compact(tls.protocol)}
Endpoint
{compact(tls.address)}
Authorized
{triState(tls.authorized, status !== "failed")}
Hostname match
{triState(tls.hostnameValid, status !== "failed")}
Issuer
{formatCertificateName(tls.issuer)}
Subject
{formatCertificateName(tls.subject)}
SANs
{compact(tls.subjectaltname)}
Valid from
{compact(tls.validFrom)}
Valid to
{compact(tls.validTo)}
Days remaining
{finiteNumber(tls.daysRemaining) ? `${tls.daysRemaining} days` : "Unavailable"}
Fingerprint
{compact(tls.fingerprint256)}

Connection attempts

{attempts.length ?
    {attempts.map((attempt, index) =>
  • {compact(isRecord(attempt) ? attempt.address : null)}{compact(isRecord(attempt) ? attempt.error : attempt)}
  • )}
:

The first validated endpoint completed the TLS handshake.

}
; +} + +function controlTone(status: HeaderControlStatus) { + return status === "strong" || status === "good" ? "good" : status === "unavailable" ? "muted" : "warning"; +} + +export function HttpEvidence({ http, highlightedEvidence }: { http: Record; highlightedEvidence: string | null }) { + const terminal = terminalHttpsResult(http); + const headers = terminal && isRecord(terminal.headers) ? terminal.headers as HeaderMap : null; + const chain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : []; + const plain = isRecord(http.http) ? http.http : null; + const plainStatus = statusForHttp(plain?.status); + const posture = headers ? analyzeHeaders(headers) : unavailableHeaderPosture(); + const redirectHops = [plain, ...chain].filter((hop): hop is Record => hop !== null && hasValue(hop.url)); + const observedPlain = plain !== null && !("error" in plain); + const observedRedirectTarget = isRecord(http.httpRedirectTarget) && "validated" in http.httpRedirectTarget; + const labels: Record = { hsts: "HSTS", csp: "CSP", nosniff: "Nosniff", "referrer-policy": "Referrer-Policy", "frame-protection": "Frame protection" }; + const headerNames = ["strict-transport-security", "content-security-policy", "x-content-type-options", "referrer-policy", "x-frame-options"]; + return

Redirect path

HTTP to HTTPS

{plainStatus.label}
{redirectHops.length ?
    {redirectHops.map((hop, index) =>
  1. {index + 1}
    {String(hop.url).startsWith("https:") ? "HTTPS" : "HTTP"}{compact(hop.url)}{hasValue(hop.address) && Validated endpoint · {String(hop.address)}}
    {"error" in hop ? compact(hop.code ?? "Error") : `HTTP ${compact(hop.status)}`}
    {index < redirectHops.length - 1 && }
  2. )}
:

No HTTP(S) response was recorded.

}
Validated target
{triState(isRecord(http.httpRedirectTarget) ? http.httpRedirectTarget.validated : undefined, observedRedirectTarget)}
HTTPS redirect
{triState(http.httpRedirectsToHttps, observedPlain)}
{http.httpsDowngrade === true &&

HTTPS downgrade blocked before the HTTP target was requested.

}

Final HTTPS response

Security header posture

{posture.aggregate.label}
{posture.controls.map((control) =>
{labels[control.id]}{control.status}{control.reason}
)}
{headers ?
Show normalized header evidence
    {headerNames.map((name) =>
  • {name}{compact(headers[name])}
  • )}
:

No terminal HTTPS response; controls are unavailable rather than missing.

}
; +} diff --git a/app/components/report/report-actions.tsx b/app/components/report/report-actions.tsx new file mode 100644 index 0000000..da1b5d0 --- /dev/null +++ b/app/components/report/report-actions.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +function RefreshIcon() { + return ; +} +function ShareIcon() { + return ; +} + +type ReportActionsProps = { + loading: boolean; + onReinspect: () => void; + onInspectAnother: () => void; + onShare: () => void; + onExportJson: () => void; + onExportMarkdown: () => void; +}; + +export function ReportActions({ loading, onReinspect, onInspectAnother, onShare, onExportJson, onExportMarkdown }: ReportActionsProps) { + const [mobileOpen, setMobileOpen] = useState(false); + const [desktopExportOpen, setDesktopExportOpen] = useState(false); + const [mobileExportOpen, setMobileExportOpen] = useState(false); + const desktopRef = useRef(null); + const mobileRef = useRef(null); + + const close = () => { + setMobileOpen(false); + setDesktopExportOpen(false); + setMobileExportOpen(false); + }; + + useEffect(() => { + if (!mobileOpen && !desktopExportOpen && !mobileExportOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") close(); + }; + const onPointerDown = (event: PointerEvent) => { + if (![desktopRef.current, mobileRef.current].some((element) => element?.contains(event.target as Node))) close(); + }; + document.addEventListener("keydown", onKeyDown); + document.addEventListener("pointerdown", onPointerDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("pointerdown", onPointerDown); + }; + }, [mobileOpen, desktopExportOpen, mobileExportOpen]); + + const controls = (variant: "desktop" | "mobile") => { + const exportOpen = variant === "desktop" ? desktopExportOpen : mobileExportOpen; + const setExportOpen = variant === "desktop" ? setDesktopExportOpen : setMobileExportOpen; + const exportId = variant + "-export-options"; + return <> + + + +
{exportOpen &&
}
+ ; + }; + + return <>
{controls("desktop")}
{mobileOpen &&
{controls("mobile")}
}
; +} diff --git a/app/components/report/report-utils.tsx b/app/components/report/report-utils.tsx new file mode 100644 index 0000000..7a0be09 --- /dev/null +++ b/app/components/report/report-utils.tsx @@ -0,0 +1,110 @@ +import type { CSSProperties } from "react"; +import type { Finding } from "@/lib/inspect/types"; + +export type Tab = "overview" | "dns" | "tls" | "http" | "raw"; +export type FindingFilter = "all" | Finding["severity"]; + +export const tabs: Tab[] = ["overview", "dns", "tls", "http", "raw"]; +export const headerNames = [ + "strict-transport-security", + "content-security-policy", + "x-content-type-options", + "referrer-policy", + "x-frame-options", +]; + +export function downloadText(filename: string, content: string, type: string) { + const blob = new Blob([content], { type }); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(href), 0); +} + +export function CopyIcon() { + return ; +} + +export function CloseIcon() { + return ; +} + +export function Gauge({ value, tone }: { value: number; tone: "green" | "amber" | "red" | "muted" }) { + const degrees = Math.max(0, Math.min(100, value)) * 3.6; + return
; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function finiteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function hasValue(value: unknown) { + return value !== null && value !== undefined && value !== ""; +} + +export function compact(value: unknown) { + if (Array.isArray(value)) return value.length ? value.map((item) => item && typeof item === "object" ? JSON.stringify(item) : String(item)).join("\n") : "—"; + if (value && typeof value === "object") return JSON.stringify(value); + return value == null || value === "" ? "—" : String(value); +} + +export function formatMxRecords(value: unknown) { + if (!Array.isArray(value)) return compact(value); + return value.length ? value.map((record) => { + if (!isRecord(record) || typeof record.exchange !== "string" || !finiteNumber(record.priority)) return compact(record); + return `${record.priority} · ${record.exchange}`; + }).join("\n") : "—"; +} + +export function formatCertificateName(value: unknown) { + if (!isRecord(value)) return compact(value); + const labels: Record = { O: "Organization", CN: "Common name", C: "Country" }; + return Object.entries(value).map(([key, entry]) => `${labels[key] ?? key}: ${compact(entry)}`).join("\n") || "—"; +} + +export function severityLabel(severity: Finding["severity"]) { + return severity === "critical" ? "Critical" : severity === "warning" ? "Warning" : severity === "good" ? "Good" : "Info"; +} + +export function findingIcon(severity: Finding["severity"]) { + return severity === "good" ? "✓" : severity === "info" ? "i" : "!"; +} + +export function toneForScore(value: number) { + return value >= 90 ? "green" as const : value >= 75 ? "amber" as const : "red" as const; +} + +export function terminalHttpsResult(http: Record) { + const chain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : []; + const last = chain.at(-1); + return last && !("error" in last) ? last : null; +} + +export function collectorMeta(value: Record) { + const meta = isRecord(value._meta) ? value._meta : {}; + const status = meta.status === "ok" || meta.status === "partial" || meta.status === "failed" ? meta.status : "failed"; + const errors = Array.isArray(meta.errors) ? meta.errors : []; + return { status, errors }; +} + +export function collectorLabel(status: string) { + return status === "ok" ? "Complete" : status === "partial" ? "Partial" : "Failed"; +} + +export function triState(value: unknown, observed: boolean) { + return value === true ? "Yes" : value === false && observed ? "No" : "Unavailable"; +} + +export function formatInspectedAt(value: string) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return value; + return `${date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "medium" })} local`; +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..f997658 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..462e62d --- /dev/null +++ b/app/globals.css @@ -0,0 +1,491 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --surface-0: #06111f; + --surface-1: #081a2a; + --surface-2: #0b2235; + --surface-3: #0d2a41; + --surface-elevated: #102f48; + --surface-report: rgba(7, 26, 40, .76); + --surface-evidence: rgba(5, 22, 35, .42); + --border-subtle: rgba(120, 179, 218, .16); + --border-strong: rgba(91, 173, 229, .38); + --text: #edf5fb; + --text-soft: #c2d5e4; + --muted: #8ca8bd; + --blue: #4db3ff; + --blue-strong: #218fe5; + --blue-soft: #9dd9ff; + --green: #54e8a1; + --amber: #ffca52; + --red: #ff778b; + --status-complete: var(--green); + --status-partial: var(--amber); + --status-error: var(--red); + --focus-ring: #9edcff; + --radius-sm: 10px; + --radius-md: 16px; + --radius-lg: 24px; + --shadow-1: 0 16px 44px rgba(0, 0, 0, .18); + --shadow-2: 0 22px 64px rgba(0, 0, 0, .35); + --ease: 160ms ease; +} + +* { box-sizing: border-box; } +html, body { min-width: 320px; min-height: 100%; overflow-x: hidden; } +body { + margin: 0; + background: + radial-gradient(circle at 18% -10%, rgba(37, 122, 184, .27), transparent 34rem), + radial-gradient(circle at 88% 12%, rgba(23, 102, 155, .16), transparent 30rem), + var(--surface-0); + color: var(--text); + font-size: 14px; +} +body::before { + position: fixed; + z-index: -1; + inset: 0; + content: ""; + opacity: .42; + pointer-events: none; + background-image: + linear-gradient(rgba(97, 164, 207, .025) 1px, transparent 1px), + linear-gradient(90deg, rgba(97, 164, 207, .025) 1px, transparent 1px); + background-size: 42px 42px; + mask-image: linear-gradient(to bottom, black, transparent 78%); +} +button, input { font: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .58; } +a { color: inherit; } +button, a, input, .finding-card, .capability-card { transition: border-color var(--ease), color var(--ease), background var(--ease), box-shadow var(--ease), transform var(--ease); } + +.cert-app-shell { min-height: 100vh; } +.app-topbar { + position: sticky; + top: 0; + z-index: 10; + min-height: 72px; + display: flex; + align-items: center; + gap: 28px; + padding: 0 max(24px, calc((100vw - 1360px) / 2)); + border-bottom: 1px solid var(--border-subtle); + background: rgba(5, 15, 25, .78); + backdrop-filter: blur(18px); +} +.brand-lockup { display: flex; align-items: center; gap: 11px; min-width: 220px; } +.brand-lockup strong { display: block; letter-spacing: -.035em; font-size: 24px; line-height: 1; } +.brand-lockup small { display: block; margin-top: 4px; color: var(--text-soft); font-size: 8px; font-weight: 800; letter-spacing: .14em; } +.brand-mark { width: 33px; height: 37px; display: grid; place-items: center; color: var(--blue); } +.brand-mark svg { width: 100%; height: 100%; } +.product-context { display: flex; flex-direction: column; gap: 3px; color: var(--text-soft); font-size: 12px; } +.product-context strong { font-size: 12px; letter-spacing: .015em; } +.product-context span { color: var(--muted); font-size: 11px; } +.topbar-right { display: flex; align-items: center; gap: 13px; margin-left: auto; } +.version-chip { padding: 5px 9px; border: 1px solid rgba(87, 181, 239, .4); border-radius: 999px; background: rgba(18, 61, 91, .66); color: var(--blue-soft); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; } +.repo-link { color: var(--text-soft); font-size: 12px; text-decoration: none; } +.repo-link:hover, .repo-link:focus-visible { color: white; text-decoration: underline; } + +.cert-main { width: min(1360px, 100%); margin: 0 auto; padding: 32px 24px 72px; } +.hero-workspace { display: grid; grid-template-columns: minmax(0, 1.06fr) minmax(380px, .94fr); gap: clamp(28px, 5vw, 74px); align-items: center; min-height: 346px; padding: clamp(30px, 5vw, 64px); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: linear-gradient(135deg, rgba(13, 43, 64, .78), rgba(6, 20, 33, .58) 64%, rgba(14, 53, 79, .55)); box-shadow: var(--shadow-1); overflow: hidden; } +.hero-workspace::after { position: absolute; content: ""; width: 420px; height: 420px; margin: -310px 0 0 -220px; border: 1px solid rgba(94, 190, 246, .15); border-radius: 50%; pointer-events: none; } +.hero-workspace { position: relative; } +.hero-copy, .inspect-cluster { position: relative; z-index: 1; } +.tagline { margin: 0 0 26px; color: var(--blue-soft); font-size: 11px; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } +.hero-kicker { display: flex; align-items: center; gap: 8px; margin: 0 0 13px; color: var(--blue-soft); font-size: 12px; font-weight: 700; } +.hero-kicker span { color: var(--blue); font-size: 11px; } +.hero-copy h1 { margin: 0; font-size: clamp(48px, 6vw, 76px); line-height: .92; letter-spacing: -.065em; } +.hero-statement { max-width: 590px; margin: 17px 0 0; color: #d9eafb; font-size: clamp(22px, 2.3vw, 31px); line-height: 1.12; letter-spacing: -.035em; } +.hero-description { max-width: 590px; margin: 18px 0 0; color: var(--text-soft); font-size: 15px; line-height: 1.6; } +.hero-signals { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 24px; } +.hero-signals span { padding: 6px 10px; border: 1px solid rgba(90, 175, 229, .28); border-radius: 999px; background: rgba(12, 52, 78, .46); color: #c8e4f7; font-size: 11px; font-weight: 700; } + +.inspect-cluster { display: grid; gap: 14px; padding: 22px; border: 1px solid rgba(109, 190, 240, .31); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(10, 42, 65, .98), rgba(7, 27, 43, .96)); box-shadow: 0 20px 46px rgba(0, 0, 0, .26), inset 0 1px 0 rgba(255, 255, 255, .04); } +.inspect-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.inspect-card-heading p { margin: 0 0 4px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; } +.inspect-card-heading strong { font-size: 17px; } +.inspect-card-status { padding: 5px 8px; border: 1px solid rgba(87, 213, 153, .35); border-radius: 999px; background: rgba(17, 76, 55, .68); color: var(--green); font-size: 10px; font-weight: 750; white-space: nowrap; } +.host-field-wrap label { display: block; margin-bottom: 7px; color: #d5e6f3; font-size: 12px; font-weight: 750; } +.host-field-wrap input { width: 100%; height: 50px; padding: 0 15px; border: 1px solid rgba(106, 177, 220, .42); border-radius: var(--radius-sm); outline: none; background: rgba(4, 18, 30, .72); color: var(--text); font-size: 16px; } +.host-field-wrap input::placeholder { color: #6f91aa; } +.host-field-wrap input:hover { border-color: rgba(113, 195, 243, .65); } +.host-field-wrap input:focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(76, 178, 255, .16); } +.host-field-wrap small { display: block; margin-top: 7px; color: var(--muted); font-size: 11px; line-height: 1.4; } +.quick-hosts { display: flex; flex-wrap: wrap; gap: 7px; } +.quick-hosts button { padding: 5px 8px; border: 1px solid rgba(112, 177, 218, .25); border-radius: 7px; background: rgba(19, 58, 84, .48); color: #bbd8eb; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; } +.quick-hosts button:hover, .quick-hosts button:focus-visible { border-color: var(--blue); background: rgba(22, 86, 127, .65); color: white; } +.inspect-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; } +.primary-btn, .secondary-btn, .preview-demo-button { min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 10px; font-weight: 780; } +.primary-btn { padding: 0 19px; border: 1px solid #4ab3ff; background: linear-gradient(180deg, #289cf3, #167cc7); box-shadow: 0 9px 25px rgba(23, 137, 223, .28); color: white; } +.primary-btn:hover:not(:disabled) { border-color: #8ed3ff; background: linear-gradient(180deg, #36a7fb, #218bd7); box-shadow: 0 12px 31px rgba(23, 137, 223, .38); transform: translateY(-1px); } +.primary-btn svg { width: 19px; } +.secondary-btn { padding: 0 14px; border: 1px solid rgba(114, 183, 224, .4); background: rgba(10, 45, 68, .72); color: #d6e8f5; } +.secondary-btn:hover:not(:disabled), .preview-demo-button:hover { border-color: var(--blue); background: rgba(22, 84, 124, .82); color: white; } +.secondary-btn svg, .preview-demo-button svg { width: 17px; } +.inspect-card-note { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; } +.error-banner { display: grid; gap: 4px; padding: 12px; border: 1px solid rgba(255, 119, 139, .44); border-radius: var(--radius-sm); background: rgba(91, 27, 40, .48); color: #ffdce2; } +.error-banner strong { color: #ffb9c4; font-size: 12px; } +.error-banner span { font-size: 12px; line-height: 1.45; } +.error-banner small { color: #e7a8b3; font-size: 11px; } + +.inspection-progress { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 17px; align-items: center; margin-top: 22px; padding: 17px 20px; border: 1px solid rgba(78, 176, 246, .32); border-radius: var(--radius-md); background: rgba(9, 37, 56, .8); box-shadow: var(--shadow-1); } +.progress-orb { width: 43px; height: 48px; color: var(--blue); animation: inspection-pulse 1.65s ease-in-out infinite; } +.progress-orb svg { width: 100%; height: 100%; } +.inspection-progress strong { font-size: 14px; } +.inspection-progress p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.inspection-progress code { color: var(--blue-soft); } +.progress-checks { display: flex; gap: 7px; } +.progress-checks span { padding: 5px 8px; border-radius: 999px; background: rgba(27, 79, 111, .76); color: #bbddf3; font-size: 10px; font-weight: 750; } + +.capability-section { margin-top: 32px; } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 0 2px 14px; } +.section-heading p, .demo-preview-copy > p, .security-boundary p { margin: 0 0 7px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; } +.section-heading h2 { max-width: 700px; margin: 0; font-size: clamp(22px, 3vw, 30px); line-height: 1.16; letter-spacing: -.035em; } +.section-heading > span { padding-bottom: 3px; color: var(--muted); font-size: 12px; white-space: nowrap; } +.capability-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 13px; } +.capability-card { position: relative; min-height: 145px; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: linear-gradient(145deg, rgba(13, 39, 59, .84), rgba(7, 24, 38, .8)); overflow: hidden; } +.capability-card::after { position: absolute; inset: auto -25px -50px auto; width: 130px; height: 130px; border: 1px solid rgba(83, 180, 241, .12); border-radius: 50%; content: ""; } +.capability-card:hover { border-color: rgba(96, 186, 241, .44); background: linear-gradient(145deg, rgba(15, 51, 76, .94), rgba(7, 27, 42, .9)); transform: translateY(-2px); } +.capability-index { display: block; color: var(--blue); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; } +.capability-card h3 { margin: 17px 0 7px; font-size: 18px; } +.capability-card p { max-width: 235px; margin: 0; color: var(--text-soft); font-size: 13px; line-height: 1.5; } +.security-boundary { display: grid; grid-template-columns: minmax(200px, .8fr) minmax(0, 2.2fr); gap: 22px; align-items: center; margin-top: 14px; padding: 18px 20px; border-top: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle); } +.security-boundary strong { font-size: 15px; } +.security-boundary ul { display: flex; flex-wrap: wrap; gap: 9px 16px; margin: 0; padding: 0; list-style: none; } +.security-boundary li { display: inline-flex; align-items: center; gap: 6px; color: var(--text-soft); font-size: 12px; } +.security-boundary svg { width: 15px; color: var(--green); } +.demo-preview { display: grid; grid-template-columns: minmax(0, .88fr) minmax(340px, 1.12fr); gap: 28px; align-items: center; margin-top: 34px; padding: clamp(22px, 4vw, 38px); border: 1px solid rgba(75, 159, 214, .29); border-radius: var(--radius-lg); background: radial-gradient(circle at 86% 20%, rgba(37, 125, 182, .2), transparent 23rem), linear-gradient(135deg, rgba(8, 29, 45, .92), rgba(10, 41, 62, .82)); box-shadow: var(--shadow-1); } +.demo-preview-copy h2 { max-width: 450px; margin: 0; font-size: clamp(25px, 3vw, 36px); line-height: 1.08; letter-spacing: -.045em; } +.demo-preview-copy > span { display: block; max-width: 450px; margin-top: 13px; color: var(--text-soft); line-height: 1.6; } +.preview-demo-button { margin-top: 20px; padding: 0 14px; border: 1px solid rgba(101, 192, 246, .44); background: rgba(14, 57, 85, .75); color: #d9ecf9; } +.demo-preview-report { padding: 18px; border: 1px solid rgba(113, 193, 239, .35); border-radius: var(--radius-md); background: rgba(4, 19, 31, .72); box-shadow: 0 14px 30px rgba(0,0,0,.16); } +.demo-preview-report header { display: flex; align-items: center; justify-content: space-between; gap: 14px; } +.demo-preview-report header > div { display: grid; gap: 4px; } +.demo-preview-report header span { color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; } +.demo-preview-report header strong { font: 13px ui-monospace, SFMono-Regular, Consolas, monospace; } +.demo-preview-report header > b { color: var(--green); font-size: 34px; letter-spacing: -.05em; } +.demo-preview-report header > b small { color: var(--muted); font-size: 12px; } +.preview-score-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 17px; } +.preview-score-grid div { padding: 11px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(12, 43, 63, .62); } +.preview-score-grid span { display: block; color: var(--muted); font-size: 10px; font-weight: 750; } +.preview-score-grid strong { display: block; margin-top: 4px; font-size: 22px; font-variant-numeric: tabular-nums; } +.preview-finding { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 10px; margin-top: 12px; padding: 12px; border: 1px solid rgba(255, 202, 82, .28); border-radius: var(--radius-sm); background: rgba(73, 54, 16, .28); } +.preview-finding-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: rgba(190, 139, 24, .42); color: var(--amber); font-weight: 900; } +.preview-finding strong { font-size: 12px; } +.preview-finding p { margin: 4px 0 0; color: var(--text-soft); font-size: 11px; line-height: 1.45; } + +.report-workspace { margin-top: 24px; min-width: 0; } +.report-context-bar { display: grid; grid-template-columns: minmax(180px, .72fr) minmax(0, 2fr); gap: 24px; align-items: center; padding: 18px 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: rgba(7, 27, 42, .72); } +.report-identity p { margin: 0 0 5px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; } +.report-identity h2 { margin: 0; overflow-wrap: anywhere; font: 700 22px/1.12 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: -.035em; } +.report-context-meta { display: grid; grid-template-columns: .9fr 1.5fr 1fr; gap: 16px; margin: 0; } +.report-context-meta div { min-width: 0; } +.report-context-meta dt { margin-bottom: 5px; color: var(--muted); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.report-context-meta dd { margin: 0; color: var(--text-soft); font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; } +.address-list { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px !important; } +.workspace-host-form { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; padding-top: 14px; border-top: 1px solid var(--border-subtle); } +.workspace-host-form label { grid-column: 1 / -1; color: var(--muted); font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.workspace-host-form input { min-width: 0; height: 37px; padding: 0 10px; border: 1px solid rgba(106, 177, 220, .42); border-radius: 8px; outline: none; background: rgba(4, 18, 30, .72); color: var(--text); } +.workspace-host-form button { min-height: 37px; padding: 0 12px; border: 1px solid var(--blue); border-radius: 8px; background: rgba(22, 84, 124, .82); color: white; font-weight: 750; } +.score-row { display: grid; grid-template-columns: minmax(280px, .8fr) minmax(0, 2.2fr); gap: 14px; margin-top: 14px; } +.category-score-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; } +.metric-card { min-width: 0; min-height: 138px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 18px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: linear-gradient(160deg, rgba(12, 40, 60, .9), rgba(6, 23, 37, .92)); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .025); } +.metric-card.overall { min-height: 176px; padding: 23px; border-color: rgba(91, 189, 242, .4); background: radial-gradient(circle at 88% 18%, rgba(47, 158, 221, .17), transparent 11rem), linear-gradient(145deg, rgba(13, 49, 74, .96), rgba(6, 25, 39, .94)); } +.metric-card > div:first-child { display: flex; flex-direction: column; gap: 7px; min-width: 0; } +.metric-card span { color: var(--blue-soft); font-size: 12px; font-weight: 780; } +.metric-card strong { font-size: 31px; line-height: .95; letter-spacing: -.05em; font-variant-numeric: tabular-nums; } +.metric-card.overall strong { font-size: 52px; } +.metric-card strong small { color: var(--muted); font-size: 13px; font-weight: 600; letter-spacing: 0; } +.metric-card em { color: var(--text-soft); font-size: 11px; font-style: normal; line-height: 1.4; } +.metric-card.green em { color: var(--green); } +.metric-card.amber em { color: var(--amber); } +.metric-card.red em { color: var(--red); } +.metric-card.muted em { color: var(--muted); } +.gauge { position: relative; width: 62px; height: 62px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--green) var(--gauge), rgba(72, 116, 140, .38) 0); } +.metric-card.overall .gauge { width: 86px; height: 86px; } +.gauge.amber { background: conic-gradient(var(--amber) var(--gauge), rgba(119, 92, 35, .5) 0); } +.gauge.red { background: conic-gradient(var(--red) var(--gauge), rgba(117, 53, 68, .56) 0); } +.gauge.muted { background: conic-gradient(#8ca8bd var(--gauge), rgba(72, 91, 109, .52) 0); } +.gauge > div { position: absolute; inset: 7px; border-radius: 50%; background: var(--surface-1); } +.report-status-row { min-height: 27px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 9px; color: var(--muted); font-size: 11px; } +.status-badge, .demo-badge { display: inline-flex; align-items: center; padding: 4px 8px; border: 1px solid rgba(119, 163, 191, .35); border-radius: 999px; background: rgba(39, 66, 83, .54); color: var(--text-soft); font-size: 11px; font-weight: 800; white-space: nowrap; } +.status-badge.complete, .status-badge.green { border-color: rgba(84, 232, 161, .35); background: rgba(18, 74, 54, .72); color: var(--green); } +.status-badge.partial, .status-badge.amber { border-color: rgba(255, 202, 82, .36); background: rgba(87, 66, 20, .6); color: var(--amber); } +.status-badge.failed, .status-badge.red { border-color: rgba(255, 119, 139, .38); background: rgba(91, 35, 47, .65); color: #ffbdc8; } +.status-badge.muted { color: var(--muted); } +.demo-badge { border-color: rgba(77, 179, 255, .38); background: rgba(18, 66, 96, .66); color: var(--blue-soft); } +.completeness-note { display: flex; gap: 8px; margin: 9px 0 0; padding: 10px 12px; border: 1px solid rgba(255, 202, 82, .3); border-radius: var(--radius-sm); background: rgba(80, 59, 18, .34); color: #ffe1a0; font-size: 12px; line-height: 1.45; } +.completeness-note.failed { border-color: rgba(255, 119, 139, .35); background: rgba(82, 30, 43, .36); color: #ffd2da; } + +.report-tabs-bar { position: sticky; top: 72px; z-index: 9; min-height: 60px; display: flex; align-items: stretch; gap: 12px; margin-top: 14px; border-bottom: 1px solid var(--border-subtle); min-width: 0; background: rgba(6, 17, 31, .96); backdrop-filter: blur(14px); } +.report-tabs-bar nav { display: flex; flex: 0 1 auto; min-width: 0; overflow-x: auto; } +.report-tabs-bar nav button { min-width: 86px; padding: 0 14px; border: 0; border-bottom: 3px solid transparent; background: transparent; color: var(--muted); font-size: 12px; font-weight: 800; letter-spacing: .02em; } +.report-tabs-bar nav button:hover { color: var(--text); background: rgba(36, 88, 122, .28); } +.report-tabs-bar nav button.active { border-bottom-color: var(--blue); background: linear-gradient(180deg, rgba(31, 105, 151, .4), rgba(12, 40, 61, .5)); color: white; } +.report-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; white-space: nowrap; } +.report-actions button, .mobile-action-menu button { min-height: 35px; border: 1px solid rgba(96, 172, 217, .35); border-radius: 8px; background: rgba(13, 48, 72, .72); color: #d5e7f4; } +.report-actions button:hover, .mobile-action-menu button:hover { border-color: var(--blue); background: rgba(22, 79, 113, .9); } +.report-actions svg, .mobile-action-menu svg { width: 16px; } +.action-reinspect, .share-btn { display: flex; align-items: center; justify-content: center; gap: 7px; padding: 0 10px; } +.export-btn { padding: 0 9px; font-size: 11px; } +.mobile-action-menu { display: none; position: relative; margin-left: auto; } +.actions-toggle { min-width: 82px; padding: 0 11px; white-space: nowrap; } +.actions-popover { position: absolute; right: 0; z-index: 5; display: grid; gap: 6px; min-width: 180px; margin-top: 6px; padding: 8px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-elevated); box-shadow: var(--shadow-2); } +.actions-popover button { justify-content: flex-start; width: 100%; } +.action-status { min-height: 18px; margin: 5px 0 0; color: var(--blue-soft); font-size: 11px; } +.manual-copy-panel { display: grid; gap: 9px; margin-top: 9px; padding: 14px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-elevated); } +.manual-copy-panel > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.manual-copy-panel p { margin: 0; color: var(--text-soft); font-size: 13px; } +.manual-copy-panel button { min-height: 32px; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 7px; background: rgba(13, 48, 72, .72); color: var(--text); } +.manual-copy-panel textarea { width: 100%; min-height: 110px; padding: 10px; border: 1px solid var(--border-strong); border-radius: 7px; background: var(--surface-1); color: var(--text); font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; resize: vertical; } + +.findings-panel { margin-top: 18px; padding: 17px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: rgba(7, 25, 39, .7); } +.findings-panel > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin: 0 2px 12px; } +.findings-panel > header > div { display: flex; align-items: center; gap: 10px; } +.findings-panel h2 { margin: 0; font-size: 19px; letter-spacing: -.02em; } +.findings-panel header span { padding: 3px 9px; border-radius: 999px; background: rgba(35, 83, 112, .66); color: #c1ddeb; font-size: 10px; font-weight: 750; } +.findings-panel header button, .card-link, .show-all-button { border: 0; background: transparent; color: var(--blue-soft); font-size: 11px; font-weight: 700; } +.findings-panel header button:hover, .card-link:hover, .show-all-button:hover { color: white; text-decoration: underline; } +.finding-filters { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 2px 12px; } +.finding-filters button { padding: 5px 10px; border: 1px solid rgba(109, 174, 211, .26); border-radius: 999px; background: rgba(11, 43, 64, .66); color: var(--text-soft); font-size: 12px; } +.finding-filters button.active { border-color: var(--blue); background: rgba(27, 104, 151, .54); color: white; } +.findings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } +.finding-card { width: 100%; min-height: 132px; display: grid; grid-template-columns: 40px minmax(0, 1fr) 16px; align-items: start; gap: 10px; padding: 14px; border: 1px solid rgba(103, 163, 197, .25); border-radius: var(--radius-sm); text-align: left; color: inherit; background: linear-gradient(145deg, rgba(10, 36, 54, .88), rgba(6, 23, 37, .84)); appearance: none; } +.finding-card:hover { border-color: rgba(111, 190, 234, .5); background: linear-gradient(145deg, rgba(13, 49, 73, .94), rgba(7, 29, 45, .92)); transform: translateY(-1px); } +.finding-card.critical { border-color: rgba(255, 119, 139, .45); } +.finding-card.warning { border-color: rgba(255, 202, 82, .42); } +.finding-card:focus-visible { outline: 2px solid #9edcff; outline-offset: 2px; } +.finding-icon { width: 30px; height: 30px; display: grid; place-items: center; margin-top: 2px; border-radius: 50%; font-size: 16px; font-weight: 900; } +.finding-icon.critical { background: rgba(150, 44, 65, .62); color: #ffd7de; } +.finding-icon.warning { background: rgba(126, 92, 21, .66); color: #ffe08b; } +.finding-icon.info { background: rgba(32, 105, 151, .62); color: #a5ddff; } +.finding-icon.good { background: rgba(24, 105, 74, .64); color: #91f4bd; } +.finding-title { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } +.finding-title strong { font-size: 14px; line-height: 1.28; } +.pill { display: inline-flex; align-items: center; padding: 3px 7px; border-radius: 999px; font-size: 10px; font-weight: 850; } +.pill.critical { background: rgba(101, 35, 49, .82); color: #ffc2cc; } +.pill.warning { background: rgba(95, 72, 18, .85); color: #ffdb70; } +.pill.info { background: rgba(18, 73, 108, .85); color: #7dd1ff; } +.pill.good { background: rgba(17, 79, 55, .85); color: #67edaa; } +.finding-body p { margin: 6px 0; color: var(--text-soft); font-size: 13px; line-height: 1.45; } +.remedy-box { padding: 8px 9px; border: 1px solid rgba(98, 159, 194, .22); border-radius: 7px; background: rgba(4, 18, 29, .45); color: #b7d3e5; font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.finding-chevron { align-self: center; color: var(--blue-soft); font-size: 24px; } +.show-all-button { display: block; margin: 12px auto 0; padding: 3px 8px; } +.empty-findings { margin: 0; padding: 18px 8px; color: var(--muted); } + +.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 14px; min-width: 0; } +.detail-card, .evidence-card, .collector-state { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: linear-gradient(155deg, rgba(10, 35, 53, .85), rgba(6, 22, 35, .84)); } +.detail-card { min-height: 278px; padding: 16px; } +.detail-card header, .single-detail > header, .evidence-card-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.detail-card h3, .evidence-card h3 { margin: 0; font-size: 15px; } +.evidence-card-heading p { margin: 0 0 5px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; } +.summary-list { display: grid; gap: 0; margin: 13px 0 12px; } +.summary-list > div { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 8px; padding: 7px 0; border-bottom: 1px solid var(--border-subtle); } +.summary-list b, .summary-list dt, .posture-list dt { color: var(--muted); font-size: 12px; font-weight: 650; } +.summary-list dd, .posture-list dd { margin: 0; color: #e1ebf3; font-size: 13px; overflow-wrap: anywhere; white-space: pre-line; } +.card-link { padding: 0; } +.value-good { color: var(--green) !important; } +.value-warning { color: var(--amber) !important; } +.value-bad { color: var(--red) !important; } +.value-muted, .value-neutral { color: var(--muted) !important; } + +.single-detail { margin-top: 16px; padding: 20px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: rgba(7, 26, 40, .76); min-width: 0; } +.single-detail h2 { margin: 0; font-size: 22px; letter-spacing: -.03em; } +.single-detail > header p { margin: 5px 0 0; color: var(--muted); font-size: 12px; } +.evidence-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 17px; } +.http-evidence-layout { grid-template-columns: minmax(0, 1.2fr) minmax(320px, .8fr); } +.evidence-card { padding: 16px; } +.evidence-table { margin-top: 13px; border-top: 1px solid var(--border-subtle); } +.evidence-row { display: grid; grid-template-columns: 70px minmax(0, 1fr); gap: 10px; align-items: start; padding: 9px 0; border-bottom: 1px solid var(--border-subtle); } +.evidence-row > strong { color: #cfdfeb; font-size: 12px; } +.evidence-value { display: grid; grid-template-columns: minmax(0, 1fr) 28px; gap: 7px; min-width: 0; } +.evidence-value > span { color: #c8d6e2; font: 13px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-line; overflow-wrap: anywhere; } +.evidence-value button, .dns-row button { width: 28px; height: 28px; padding: 5px; border: 0; border-radius: 6px; background: transparent; color: var(--blue-soft); } +.evidence-value button:hover, .dns-row button:hover { background: rgba(55, 126, 169, .38); color: white; } +.evidence-value svg { width: 16px; } +.posture-list { margin: 13px 0 0; } +.posture-list > div { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--border-subtle); } +.collector-state { grid-column: 1 / -1; padding: 14px; } +.collector-state > div { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.collector-state strong { font-size: 13px; } +.collector-state ul { margin: 10px 0 0; padding-left: 18px; color: #ffcad3; font-size: 11px; line-height: 1.5; } +.collector-state p { margin: 8px 0 0; color: var(--muted); font-size: 12px; } +.status-good, .status-warning, .status-error, .status-neutral { display: inline-flex; padding: 4px 8px; border-radius: 999px; font-size: 10px; font-weight: 750; } +.status-good { color: var(--green); background: rgba(18, 74, 54, .78); } +.status-warning { color: #ffdc77; background: rgba(92, 70, 19, .7); } +.status-error { color: #ffc0cb; background: rgba(89, 35, 47, .78); } +.status-neutral { color: #d1dee9; background: rgba(66, 88, 106, .72); } +.empty-evidence { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; } +.attempt-list { display: grid; gap: 7px; margin: 14px 0; padding: 0; list-style: none; } +.attempt-list li { display: grid; gap: 4px; padding: 9px; border: 1px solid rgba(255, 119, 139, .32); border-radius: 8px; background: rgba(78, 24, 36, .4); } +.attempt-list strong { color: #ffced6; font: 12px ui-monospace, SFMono-Regular, Consolas, monospace; } +.attempt-list span { color: #deb3bc; font-size: 11px; } +.redirect-flow { display: grid; gap: 0; margin: 16px 0 0; padding: 0; list-style: none; } +.redirect-flow > li { list-style: none; } +.redirect-hop { display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 11px; border: 1px solid rgba(96, 168, 208, .3); border-radius: var(--radius-sm); background: rgba(8, 36, 55, .75); } +.redirect-hop.red, .redirect-hop.error { border-color: rgba(255, 119, 139, .42); background: rgba(72, 27, 39, .56); } +.redirect-hop.amber { border-color: rgba(255, 202, 82, .38); } +.redirect-step { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; background: rgba(34, 108, 151, .7); color: #d8effe; font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; } +.redirect-hop > div { display: grid; gap: 3px; min-width: 0; } +.redirect-protocol { color: var(--blue-soft); font-size: 10px; font-weight: 850; letter-spacing: .08em; } +.redirect-hop strong { color: #d9ebf7; font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.redirect-hop small { color: var(--muted); font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.redirect-connector { height: 20px; display: grid; place-items: center; color: var(--blue); font-size: 16px; } +.status-chip { padding: 4px 7px; border-radius: 6px; font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; } +.status-chip.green { color: var(--green); background: rgba(18, 86, 62, .84); } +.status-chip.amber { color: #ffe07f; background: rgba(89, 67, 18, .84); } +.status-chip.red, .status-chip.error { color: #ffc2cc; background: rgba(99, 37, 51, .86); } +.status-chip.muted { color: #d1dde6; background: rgba(60, 84, 101, .84); } +.redirect-facts { margin-top: 14px; } +.inline-alert { margin: 13px 0 0; padding: 9px 10px; border: 1px solid rgba(255, 119, 139, .38); border-radius: 8px; background: rgba(79, 27, 40, .46); font-size: 11px; } +.inline-alert.error { color: #ffd0d8; } +.header-matrix { display: grid; gap: 7px; margin-top: 16px; } +.header-matrix > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 10px; border: 1px solid var(--border-subtle); border-radius: 8px; background: rgba(5, 22, 35, .42); } +.header-matrix span { color: var(--text-soft); font-size: 12px; } +.header-matrix strong { font: 11px ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; overflow-wrap: anywhere; } +.header-details { margin-top: 14px; } +.header-details summary { color: var(--blue-soft); cursor: pointer; font-size: 11px; font-weight: 700; } +.header-details[open] summary { margin-bottom: 8px; } +.header-list { display: grid; gap: 5px; margin: 0; padding: 0; list-style: none; } +.header-list li { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border-subtle); } +.header-list span { color: var(--muted); font-size: 11px; } +.header-list code { color: #cae4f4; font: 11px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.json-select { padding: 5px 9px; border: 1px solid rgba(95, 170, 215, .35); border-radius: 7px; background: rgba(12, 45, 67, .72); color: var(--text-soft); font: 10px ui-monospace, SFMono-Regular, Consolas, monospace; } +.single-detail > pre { max-width: 100%; max-height: 68vh; margin: 17px 0 0; padding: 16px; overflow: auto; border-radius: var(--radius-sm); white-space: pre-wrap; overflow-wrap: anywhere; background: #04121e; color: #b4daf4; font: 12px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; } +.http-status-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: 13px 0 0; color: var(--text-soft); font-size: 12px; } +.status-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); } +.status-dot.green { background: var(--green); } +.status-dot.amber { background: var(--amber); } +.status-dot.red { background: var(--red); } +.status-dot.muted { background: var(--muted); } + +.drawer-backdrop { position: fixed; inset: 0; z-index: 20; display: flex; justify-content: flex-end; background: rgba(1, 7, 13, .76); backdrop-filter: blur(5px); } +.diagnostic-drawer { width: min(500px, 100%); height: 100%; overflow-y: auto; padding: 28px; border-left: 1px solid var(--border-strong); outline: none; background: linear-gradient(180deg, #0c2639, #071a2a); box-shadow: -24px 0 60px rgba(0,0,0,.42); } +.diagnostic-drawer > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } +.drawer-kicker { margin: 0 0 7px; color: var(--blue-soft); font-size: 10px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.diagnostic-drawer h2 { margin: 0; color: white; font-size: 26px; line-height: 1.14; letter-spacing: -.035em; } +.drawer-close { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(109, 181, 220, .4); border-radius: 9px; background: rgba(13, 48, 72, .78); color: #c5deee; } +.drawer-close:hover { border-color: var(--blue); background: rgba(26, 79, 113, .92); } +.drawer-close svg { width: 18px; } +.drawer-summary { margin: 18px 0; color: var(--text-soft); line-height: 1.6; } +.drawer-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 0 0 23px; } +.drawer-facts div { padding: 11px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: rgba(5, 22, 35, .55); } +.drawer-facts dt { color: var(--muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; } +.drawer-facts dd { margin: 5px 0 0; color: var(--text); font-weight: 750; } +.diagnostic-drawer section { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--border-subtle); } +.diagnostic-drawer section h3 { margin: 0 0 10px; color: #dceaf4; font-size: 13px; } +.evidence-pointers { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; } +.evidence-pointers li { padding: 9px; border: 1px solid var(--border-subtle); border-radius: 7px; background: rgba(5, 22, 35, .54); } +.evidence-pointers code { color: var(--blue-soft); font: 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; } +.drawer-note { margin: 12px 0 0; color: var(--muted); font-size: 11px; line-height: 1.45; } +.drawer-raw-button, .drawer-evidence-button { flex: 1; min-height: 43px; border: 1px solid #4db3ff; border-radius: 10px; background: linear-gradient(180deg, #2395e9, #167bc5); color: white; font-weight: 750; } +.drawer-raw-button { border-color: rgba(96, 172, 217, .35); background: rgba(13, 48, 72, .72); color: #d5e7f4; } +.drawer-actions { display: flex; gap: 9px; margin-top: 24px; } + +/* Inspection workspace: dense evidence, one action surface and explicit state. */ +.provenance-badge { display: inline-flex; align-items: center; padding: 4px 8px; border: 1px solid rgba(77, 179, 255, .38); border-radius: 999px; background: rgba(18, 66, 96, .66); color: var(--blue-soft); font-size: 10px; font-weight: 800; } +.previous-report-banner { margin-top: 10px; } +.inline-expand { margin-left: 7px; padding: 0; border: 0; background: transparent; color: var(--blue-soft); font-size: inherit; font-weight: 750; text-decoration: underline; } +.inspect-another-btn { padding: 0 10px; font-size: 11px; } +.export-control { position: relative; } +.export-popover { position: absolute; top: calc(100% + 6px); right: 0; z-index: 12; display: grid; gap: 5px; min-width: 132px; padding: 7px; border: 1px solid var(--border-strong); border-radius: 8px; background: var(--surface-elevated); box-shadow: var(--shadow-2); } +.export-popover button { width: 100%; padding: 7px 9px; text-align: left; } +.severity-counts { display: flex; flex-wrap: wrap; gap: 5px; margin: 0; } +.finding-impact { margin-left: auto; color: var(--amber); font: 700 12px ui-monospace, SFMono-Regular, Consolas, monospace; } +.finding-body small { color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .04em; } +.finding-card { min-height: 98px; } +.finding-card .remedy-box { display: none; } +.evidence-card { border: 0; border-radius: 10px; background: var(--surface-evidence); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .025); } +.evidence-card:focus-visible, .evidence-highlight { outline: 2px solid var(--focus-ring); outline-offset: 3px; box-shadow: 0 0 0 5px rgba(77, 179, 255, .12); } +.header-matrix > div { display: grid; grid-template-columns: minmax(110px, .6fr) auto; align-items: start; } +.header-matrix small { grid-column: 1 / -1; color: var(--muted); font-size: 11px; line-height: 1.4; } +.raw-actions { display: flex; flex-wrap: wrap; gap: 7px; } +.raw-actions button { min-height: 33px; padding: 0 9px; border: 1px solid rgba(96, 172, 217, .35); border-radius: 8px; background: rgba(13, 48, 72, .72); color: #d5e7f4; font-size: 11px; } +.raw-actions button:hover { border-color: var(--blue); background: rgba(22, 79, 113, .9); } +.single-detail > pre { white-space: pre; overflow-wrap: normal; } +.single-detail > pre.wrap { white-space: pre-wrap; overflow-wrap: anywhere; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +button:focus-visible, input:focus-visible, a:focus-visible, [role="tab"]:focus-visible, summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; } +@keyframes inspection-pulse { 50% { filter: drop-shadow(0 0 10px rgba(77, 179, 255, .65)); transform: translateY(-1px); } } + +@media (max-width: 1100px) { + .hero-workspace { grid-template-columns: 1fr; gap: 30px; } + .report-context-bar { grid-template-columns: 1fr; gap: 15px; } + .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-card:last-child { grid-column: 1 / -1; } +} +@media (max-width: 860px) { + .app-topbar { padding-inline: 22px; gap: 16px; } + .product-context { display: none; } + .cert-main { padding-inline: 22px; } + .report-context-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .report-context-meta div:last-child { grid-column: 1 / -1; } + .score-row { grid-template-columns: 1fr; } + .category-score-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .findings-grid, .evidence-layout, .http-evidence-layout { grid-template-columns: 1fr; } + .collector-state { grid-column: auto; } + .report-actions { display: none; } + .mobile-action-menu { display: block; } + .security-boundary { grid-template-columns: 1fr; gap: 14px; } +} +@media (max-width: 620px) { + .app-topbar { min-height: 62px; padding-inline: 16px; } + .brand-lockup { min-width: 0; } + .brand-lockup strong { font-size: 21px; } + .brand-lockup small { font-size: 7px; } + .brand-mark { width: 29px; height: 33px; } + .repo-link { display: none; } + .cert-main { padding: 16px 16px 44px; } + .hero-workspace { min-height: 0; padding: 25px 18px; border-radius: 18px; } + .hero-copy h1 { font-size: 50px; } + .hero-statement { font-size: 23px; } + .hero-description { font-size: 14px; } + .inspect-cluster { padding: 17px; } + .inspect-actions { grid-template-columns: 1fr; } + .secondary-btn { width: 100%; } + .quick-hosts { gap: 6px; } + .inspection-progress { grid-template-columns: auto minmax(0, 1fr); padding: 14px; } + .progress-checks { grid-column: 1 / -1; } + .section-heading { align-items: flex-start; flex-direction: column; } + .capability-grid { grid-template-columns: 1fr; } + .capability-card { min-height: 126px; } + .demo-preview { grid-template-columns: 1fr; gap: 22px; padding: 22px 18px; border-radius: 18px; } + .report-context-bar { padding: 15px; } + .report-context-meta { grid-template-columns: 1fr; gap: 11px; } + .report-context-meta div:last-child { grid-column: auto; } + .workspace-host-form { grid-template-columns: 1fr; } + .category-score-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } + .metric-card.overall { min-height: 122px; padding: 17px; } + .category-score-grid .metric-card { min-height: 98px; padding: 12px; } + .category-score-grid .metric-card strong { font-size: 25px; } + .category-score-grid .metric-card strong small { font-size: 10px; } + .category-score-grid .metric-card .gauge { display: none; } + .category-score-grid .metric-card em { font-size: 11px; } + .metric-card.overall strong { font-size: 44px; } + .metric-card.overall .gauge { width: 72px; height: 72px; } + .report-tabs-bar { top: 62px; gap: 7px; } + .report-tabs-bar nav button { min-width: 76px; padding-inline: 10px; } + .report-context-meta dd, .metric-card em, .summary-list dd, .posture-list dd { font-size: 13px; } + .summary-list dt, .posture-list dt, .finding-body small { font-size: 12px; } + .findings-panel { padding: 13px; } + .findings-panel > header { align-items: flex-start; flex-direction: column; } + .detail-grid { grid-template-columns: 1fr; } + .detail-card:last-child { grid-column: auto; } + .single-detail { padding: 15px; } + .single-detail > header { flex-direction: column; } + .summary-list > div, .posture-list > div { grid-template-columns: 98px minmax(0, 1fr); } + .evidence-row { grid-template-columns: 52px minmax(0, 1fr); } + .redirect-hop { grid-template-columns: 26px minmax(0, 1fr); } + .redirect-hop .status-chip { grid-column: 2; justify-self: start; } + .header-list li { grid-template-columns: 1fr; gap: 3px; } + .diagnostic-drawer { padding: 22px; border-left: 0; } + .diagnostic-drawer h2 { font-size: 22px; } + .drawer-actions { flex-direction: column; } + .header-matrix > div { grid-template-columns: 1fr auto; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..dc0106a --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..aa6b377 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import "./globals.css"; +import type { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + title: { + default: "CertScope — Domain posture inspector", + template: "%s · CertScope", + }, + description: "Explainable DNS, TLS and HTTP posture inspection for one public hostname.", + applicationName: "CertScope", + icons: { + icon: [{ url: "/favicon.ico", sizes: "any" }, { url: "/icon.svg", type: "image/svg+xml" }], + }, +}; + +export const viewport: Viewport = { + themeColor: "#07131f", +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return {children}; +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..7ae0a93 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { ReportView } from "./report-view"; +import { demoInspection } from "@/lib/inspect/demo"; +import { isInspectionResult } from "@/lib/inspect/validation"; +import type { InspectionResult } from "@/lib/inspect/types"; +import { CERTSCOPE_VERSION } from "@/lib/version"; + +type ReportSource = "demo" | "live"; +type ActiveReport = { result: InspectionResult; source: ReportSource }; +type RequestState = + | { status: "idle" | "success" } + | { status: "loading"; requestedHost: string } + | { status: "error"; requestedHost: string; message: string }; + +function ShieldLogo() { + return ; +} +function SearchIcon() { + return ; +} +function CheckIcon() { + return ; +} +function ArrowIcon() { + return ; +} + +const capabilities = [ + { label: "DNS", detail: "A / AAAA, MX / NS, SPF and DMARC", icon: "01" }, + { label: "TLS", detail: "Trust, identity, expiry and connection", icon: "02" }, + { label: "HTTP", detail: "Redirect safety, HSTS, CSP and headers", icon: "03" }, +]; +const securityBoundaries = ["Public hosts only", "Ports 80 / 443", "Redirects revalidated", "Bounded deadlines", "No scan history"]; +const unavailableMessage = "Inspection service is temporarily unavailable. Try again shortly."; + +export default function Home() { + const [host, setHost] = useState(""); + const [report, setReport] = useState(null); + const [request, setRequest] = useState({ status: "idle" }); + const loading = request.status === "loading"; + + async function runInspection(rawHost: string) { + const requestedHost = rawHost.trim(); + if (!requestedHost) return; + setRequest({ status: "loading", requestedHost }); + try { + const response = await fetch(`/api/inspect?host=${encodeURIComponent(requestedHost)}`, { cache: "no-store" }); + const body = await response.json().catch(() => null) as unknown; + if (!response.ok) { + const message = typeof body === "object" && body !== null && "error" in body && typeof body.error === "string" + ? body.error + : unavailableMessage; + throw new Error(message); + } + if (!isInspectionResult(body)) throw new Error("Inspection returned an invalid response"); + setReport({ result: body, source: "live" }); + setHost(body.host); + setRequest({ status: "success" }); + } catch (error) { + const message = error instanceof Error ? error.message : unavailableMessage; + setRequest({ status: "error", requestedHost, message }); + } + } + + function inspect(event: FormEvent) { + event.preventDefault(); + void runInspection(host); + } + + function loadDemo() { + setReport({ result: demoInspection, source: "demo" }); + setRequest({ status: "idle" }); + } + + const demoWarning = demoInspection.findings.find((finding) => finding.severity === "warning") ?? demoInspection.findings[0]; + const landingError = request.status === "error" ? request.message : ""; + + return
+
+
CertScopeDOMAIN POSTURE INSPECTOR
+
DNS · TLS · HTTPEvidence-led checks for public infrastructure
+
v{CERTSCOPE_VERSION}Repository
+
+ +
+ {report ? void runInspection(currentHost)} + onInspectAnother={(nextHost) => void runInspection(nextHost)} + /> : <> +
+
+

Smaller risks. A more open internet.

+

Explainable domain inspection

+

CertScope

+

Public infrastructure posture, made explainable.

+

Inspect the DNS, TLS and HTTP posture of any public hostname. Every score change points back to evidence, not a black box.

+
Single-hostDeterministicEvidence-led
+
+ +
+

Start an inspection

Inspect a hostname
Public targets only
+
setHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} aria-describedby="hostname-help" />Enter a hostname, not a URL or IP address.
+
{["example.com", "github.com", "cloudflare.com"].map((example) => )}
+
+

DNS, TLS and HTTP checks stay bounded; no scan history is stored.

+ {landingError &&
Inspection unavailable{landingError}Adjust the hostname and try again.
} +
+
+ + {loading &&
Inspecting DNS, TLS and HTTP posture…

Collecting bounded evidence for {host.trim()}. Existing evidence will appear as soon as the report is ready.

} +

What CertScope checks

One clear report across the public-facing stack.

Evidence, not guesswork
{capabilities.map((capability) =>

{capability.label}

{capability.detail}

)}
+

Safe by design

Strong boundaries around every connection.
    {securityBoundaries.map((boundary) =>
  • {boundary}
  • )}
+

Deterministic demo

See the report before you inspect.

The built-in fixture is local, reproducible and ready to explore without a network request.
Demo preview{demoInspection.host}
{demoInspection.score.overall}/100
{(["dns", "tls", "http"] as const).map((category) =>
{category.toUpperCase()}{demoInspection.score.categories[category]}
)}
!
{demoWarning.title}

{demoWarning.summary}

+ } +
+
; +} diff --git a/app/report-view.tsx b/app/report-view.tsx new file mode 100644 index 0000000..4da38c4 --- /dev/null +++ b/app/report-view.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { inspectionToMarkdown } from "@/lib/inspect/report"; +import { analyzeHeaders, type HeaderMap } from "@/lib/inspect/headers"; +import { statusForHttp } from "@/lib/inspect/http-status"; +import type { Finding, InspectionResult } from "@/lib/inspect/types"; +import { ReportActions } from "./components/report/report-actions"; +import { DnsEvidence, HttpEvidence, TlsEvidence } from "./components/report/evidence-views"; +import { + CloseIcon, + Gauge, + type FindingFilter, + type Tab, + collectorLabel, + collectorMeta, + compact, + downloadText, + finiteNumber, + formatMxRecords, + findingIcon, + formatInspectedAt, + isRecord, + severityLabel, + tabs, + terminalHttpsResult, + toneForScore, +} from "./components/report/report-utils"; + +function headerRows(http: Record) { + const terminal = terminalHttpsResult(http); + return terminal && isRecord(terminal.headers) ? terminal.headers as HeaderMap : null; +} + +export function ReportView({ result, source, isLoading = false, requestedHost, requestError, onReinspect, onInspectAnother }: { + result: InspectionResult; + source: "demo" | "live"; + isLoading?: boolean; + requestedHost: string | null; + requestError: { requestedHost: string; message: string } | null; + onReinspect: (host: string) => void; + onInspectAnother: (host: string) => void; +}) { + const isDemo = source === "demo"; + const [tab, setTab] = useState("overview"); + const [selectedFindingId, setSelectedFindingId] = useState(null); + const [showAllFindings, setShowAllFindings] = useState(false); + const [showAllAddresses, setShowAllAddresses] = useState(false); + const [showAllIssues, setShowAllIssues] = useState(false); + const [filter, setFilter] = useState("all"); + const [wrapRaw, setWrapRaw] = useState(false); + const [highlightedEvidence, setHighlightedEvidence] = useState(null); + const [inspectAnotherOpen, setInspectAnotherOpen] = useState(false); + const [nextHost, setNextHost] = useState(""); + const [statusMessage, setStatusMessage] = useState(""); + const [manualCopy, setManualCopy] = useState<{ label: string; value: string } | null>(null); + const manualCopyRef = useRef(null); + const dialogRef = useRef(null); + const closeRef = useRef(null); + const originatingFindingRef = useRef(null); + const findings = useMemo(() => { + const severityRank: Record = { critical: 0, warning: 1, info: 2, good: 3 }; + return [...result.findings].sort((left, right) => severityRank[left.severity] - severityRank[right.severity] || left.scoreImpact - right.scoreImpact || left.id.localeCompare(right.id)); + }, [result.findings]); + const filteredFindings = useMemo(() => filter === "all" ? findings : findings.filter((finding) => finding.severity === filter), [findings, filter]); + const displayFindings = showAllFindings ? filteredFindings : filteredFindings.slice(0, 4); + const severityCounts = useMemo(() => (["critical", "warning", "info"] as const) + .map((severity) => ({ severity, count: findings.filter((finding) => finding.severity === severity).length })) + .filter(({ count }) => count > 0), [findings]); + const selectedFinding = findings.find((finding) => finding.id === selectedFindingId) ?? null; + const dns = result.dns as Record; + const tls = result.tls as Record; + const http = result.http as Record; + const headers = headerRows(http); + const httpsChain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : []; + const terminal = terminalHttpsResult(http); + const dnsCollectorStatus = collectorMeta(dns).status; + const tlsCollectorStatus = collectorMeta(tls).status; + const httpCollectorStatus = collectorMeta(http).status; + const tlsVerified = tls.authorized === true && tls.hostnameValid === true; + const tlsStatus = tls.error ? "TLS probe incomplete" : tlsVerified ? "Verified certificate" : "Certificate needs attention"; + const httpTerminalStatus = statusForHttp(terminal?.status); + const httpStatus = httpCollectorStatus === "ok" + ? httpTerminalStatus + : { label: httpCollectorStatus === "failed" ? "HTTP probe failed" : "HTTP evidence partial", tone: httpCollectorStatus === "failed" ? "red" as const : "muted" as const }; + const scoreTone = (category: "overall" | "dns" | "tls" | "http", value: number) => { + const complete = category === "overall" + ? result.completeness.status === "complete" + : ({ dns: dnsCollectorStatus, tls: tlsCollectorStatus, http: httpCollectorStatus }[category] === "ok"); + return complete ? toneForScore(value) : "muted" as const; + }; + const addresses = Array.isArray(dns.addresses) ? dns.addresses.map((address) => String(address)).filter(Boolean) : []; + const responses = [http.http, ...httpsChain].filter(isRecord).filter((hop) => finiteNumber(hop.status)); + const redirectCount = responses.filter((hop) => Number(hop.status) >= 300 && Number(hop.status) < 400).length; + + useEffect(() => { + if (manualCopy) manualCopyRef.current?.focus(); + }, [manualCopy]); + + useEffect(() => { + if (!selectedFinding) return; + const previousOverflow = document.body.style.overflow; + const backgroundElements = [...document.querySelectorAll(".app-topbar, .cert-main > *:not(.drawer-backdrop)")]; + const previousInert = backgroundElements.map((element) => element.hasAttribute("inert")); + backgroundElements.forEach((element) => element.setAttribute("inert", "")); + document.body.style.overflow = "hidden"; + const focusTarget = window.setTimeout(() => closeRef.current?.focus(), 0); + const dialog = dialogRef.current; + const closeOnKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setSelectedFindingId(null); + return; + } + if (event.key !== "Tab" || !dialog) return; + const focusable = [...dialog.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")].filter((element) => !element.hasAttribute("disabled")); + if (!focusable.length) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + dialog?.addEventListener("keydown", closeOnKey); + return () => { + window.clearTimeout(focusTarget); + dialog?.removeEventListener("keydown", closeOnKey); + backgroundElements.forEach((element, index) => { + if (!previousInert[index]) element.removeAttribute("inert"); + }); + document.body.style.overflow = previousOverflow; + if (originatingFindingRef.current?.isConnected) originatingFindingRef.current.focus(); + else document.getElementById("raw-tab")?.focus(); + }; + }, [selectedFinding]); + + function announce(message: string) { + setStatusMessage(message); + } + async function copyText(label: string, value: string) { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + announce(label + " copied"); + return; + } + } catch { /* Try the selection-based browser fallback below. */ } + const field = document.createElement("textarea"); + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + field.value = value; + field.readOnly = true; + field.style.position = "fixed"; + field.style.opacity = "0"; + document.body.appendChild(field); + field.select(); + let copied = false; + try { copied = document.execCommand("copy"); } catch { /* Manual copy remains available. */ } + field.remove(); + previousFocus?.focus(); + if (copied) { + announce(label + " copied"); + } else { + setManualCopy({ label, value }); + announce(label + " ready to copy manually"); + } + } + async function share() { + const text = inspectionToMarkdown(result); + try { + if (navigator.share) { + await navigator.share({ title: "CertScope — " + result.host, text }); + announce("Report shared"); + } else await copyText("Report", text); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") announce("Share cancelled"); + else await copyText("Report", text); + } + } + function exportJson() { + downloadText(result.host + "-certscope.json", JSON.stringify(result, null, 2), "application/json"); + announce("JSON export downloaded"); + } + function exportMarkdown() { + downloadText(result.host + "-certscope.md", inspectionToMarkdown(result), "text/markdown"); + announce("Markdown export downloaded"); + } + function switchTab(next: Tab) { + setTab(next); + } + function submitAnotherHost(event: FormEvent) { + event.preventDefault(); + if (!nextHost.trim()) return; + onInspectAnother(nextHost.trim()); + } + function handleTabKey(event: React.KeyboardEvent, index: number) { + const direction = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (event.key === "Home") { + event.preventDefault(); + switchTab(tabs[0]); + document.getElementById(tabs[0] + "-tab")?.focus(); + return; + } + if (event.key === "End") { + event.preventDefault(); + const last = tabs.at(-1)!; + switchTab(last); + document.getElementById(last + "-tab")?.focus(); + return; + } + if (!direction) return; + event.preventDefault(); + const nextIndex = (index + direction + tabs.length) % tabs.length; + switchTab(tabs[nextIndex]); + document.getElementById(tabs[nextIndex] + "-tab")?.focus(); + } + function evidenceTargetFor(finding: Finding) { + const pointer = finding.evidence.join(" "); + if (pointer.includes("tls.daysRemaining") || pointer.includes("tls.validTo") || pointer.includes("tls.validFrom")) return { tab: "tls" as const, id: "tls-certificate" }; + if (pointer.includes("tls.")) return { tab: "tls" as const, id: "tls-certificate" }; + if (pointer.includes("dns.dmarc") || pointer.includes("dns.spf")) return { tab: "dns" as const, id: "dns-mail-posture" }; + if (pointer.includes("dns.")) return { tab: "dns" as const, id: "dns-records" }; + if (pointer.includes("headers") || pointer.includes("header")) return { tab: "http" as const, id: "http-header-posture" }; + if (pointer.includes("httpsChain") || pointer.includes("http.http")) return { tab: "http" as const, id: "http-redirect-chain" }; + return { tab: "raw" as const, id: "raw-evidence" }; + } + function viewEvidence(finding: Finding) { + const target = evidenceTargetFor(finding); + setSelectedFindingId(null); + switchTab(target.tab); + window.setTimeout(() => { + const element = document.getElementById(target.id); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); + element?.focus(); + setHighlightedEvidence(target.id); + window.setTimeout(() => setHighlightedEvidence((current) => current === target.id ? null : current), 2_000); + }, 0); + } + + + return <> +
+

CertScope inspection report for {result.host}

+

{isDemo ? "Deterministic demo report" : "Live inspection report"}

{result.host}

Coverage
{result.completeness.status === "complete" ? "Complete" : result.completeness.status === "partial" ? "Partial" : "Incomplete"} {result.completeness.complete}/{result.completeness.total} collectors
Addresses
{addresses.length ? <>{(showAllAddresses ? addresses : addresses.slice(0, 4)).join(" · ")}{addresses.length > 4 && } : "Unavailable"}
Last inspected
{formatInspectedAt(result.inspectedAt)}
{inspectAnotherOpen &&
setNextHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} />
}
+
+ {(() => { const tone = scoreTone("overall", result.score.overall); return
Overall posture{result.score.overall}/ 100{result.completeness.status === "complete" ? `(DNS ${result.score.categories.dns} + TLS ${result.score.categories.tls} + HTTP ${result.score.categories.http}) ÷ 3 = ${result.score.overall} (rounded)` : "Review incomplete evidence before acting"}
; })()} +
{[["DNS", result.score.categories.dns, result.completeness.collectors.dns === "ok" ? "DNS evidence complete" : "DNS evidence partial"], ["TLS", result.score.categories.tls, tlsStatus], ["HTTP", result.score.categories.http, httpStatus.label]].map(([label, value, description]) => { const tone = scoreTone(String(label).toLowerCase() as "dns" | "tls" | "http", Number(value)); return
{String(label)}{Number(value)}/100{String(description)}
; })}
+
+ +
{result.completeness.status === "complete" ? "Complete inspection" : result.completeness.status === "partial" ? "Partial inspection" : "Inspection incomplete"}{result.completeness.complete}/{result.completeness.total} collectors complete{isDemo ? "Deterministic demo" : "Live inspection"}{isLoading && requestedHost && {requestedHost === result.host ? "Re-inspecting" : "Inspecting"} {requestedHost}…}
+ {requestError &&
Previous report remains displayed{requestError.message}The {isDemo ? "deterministic demo" : "live"} report for {result.host} is still available while {requestError.requestedHost} could not be inspected.
} + {result.completeness.issues.length > 0 &&

Inspection incomplete{(showAllIssues ? result.completeness.issues : result.completeness.issues.slice(0, 3)).join(" · ")}{result.completeness.issues.length > 3 && } Remaining evidence is still valid.

} + +
{ announce("Re-inspection started"); onReinspect(result.host); }} onInspectAnother={() => setInspectAnotherOpen((open) => !open)} onShare={() => void share()} onExportJson={exportJson} onExportMarkdown={exportMarkdown} />
+

{statusMessage}

+ {manualCopy &&
{manualCopy.label} ready to copy

Use Ctrl+C (or Copy on your device) to copy the selected text.