Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e
This commit is contained in:
commit
7348faf6c0
65 files changed
+12509
No files matched your search
@@ -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
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
test-results/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
certscope-sbom.cdx.json
|
||||||
|
artifacts/
|
||||||
|
.DS_Store
|
||||||
+61
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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.
|
||||||
+30
@@ -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.
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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<string, string> = {}) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <div className="evidence-value"><span>{text}</span><button type="button" aria-label={`Copy ${label} evidence`} onClick={() => onCopy(label, text)}><CopyIcon /></button></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CollectorState({ value, label }: { value: Record<string, unknown>; label: string }) {
|
||||||
|
const meta = collectorMeta(value);
|
||||||
|
return <div className="collector-state"><div><strong>{label}</strong><span className={`status-${meta.status === "ok" ? "good" : meta.status === "partial" ? "warning" : "error"}`}>{collectorLabel(meta.status)}</span></div>{meta.errors.length ? <ul>{meta.errors.map((error, index) => <li key={index}>{isRecord(error) && hasValue(error.message) ? String(error.message) : String(error)}</li>)}</ul> : <p>No collector errors recorded.</p>}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DnsEvidence({ dns, highlightedEvidence, onCopy }: { dns: Record<string, unknown>; 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 <div className="evidence-layout"><section id="dns-records" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "dns-records" ? "evidence-highlight" : ""}`}><h3>DNS records</h3><div className="evidence-table">{rows.map(([label, value]) => <div className="evidence-row" key={label}><strong>{label}</strong><EvidenceValue label={label} value={value} onCopy={onCopy} /></div>)}</div></section><section id="dns-mail-posture" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "dns-mail-posture" ? "evidence-highlight" : ""}`}><h3>Parsed mail posture</h3><dl className="posture-list"><div><dt>SPF</dt><dd>{compact(spfPosture?.status ?? dns.spf)}</dd></div><div><dt>SPF terminal all</dt><dd>{compact(spfPosture?.allQualifier ?? "Unavailable")}</dd></div><div><dt>DMARC</dt><dd>{compact(dmarcPosture?.status ?? dns.dmarc)}</dd></div><div><dt>Policy</dt><dd>{compact(dmarcPosture?.policy ?? "Unavailable")}</dd></div><div><dt>Records</dt><dd>{Array.isArray(dns.dmarc) ? dns.dmarc.length : 0} DMARC / {Array.isArray(dns.spf) ? dns.spf.length : 0} SPF</dd></div></dl></section><CollectorState value={dns} label="DNS collector" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TlsEvidence({ tls, highlightedEvidence }: { tls: Record<string, unknown>; highlightedEvidence: string | null }) {
|
||||||
|
const attempts = Array.isArray(tls.attempts) ? tls.attempts : [];
|
||||||
|
const status = collectorMeta(tls).status;
|
||||||
|
return <div className="evidence-layout"><section id="tls-certificate" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "tls-certificate" ? "evidence-highlight" : ""}`}><h3>Certificate</h3><dl className="posture-list"><div><dt>Protocol</dt><dd>{compact(tls.protocol)}</dd></div><div><dt>Endpoint</dt><dd>{compact(tls.address)}</dd></div><div><dt>Authorized</dt><dd className={tls.authorized === true ? "value-good" : tls.authorized === false ? "value-bad" : "value-muted"}>{triState(tls.authorized, status !== "failed")}</dd></div><div><dt>Hostname match</dt><dd className={tls.hostnameValid === true ? "value-good" : tls.hostnameValid === false ? "value-bad" : "value-muted"}>{triState(tls.hostnameValid, status !== "failed")}</dd></div><div><dt>Issuer</dt><dd>{formatCertificateName(tls.issuer)}</dd></div><div><dt>Subject</dt><dd>{formatCertificateName(tls.subject)}</dd></div><div><dt>SANs</dt><dd>{compact(tls.subjectaltname)}</dd></div><div><dt>Valid from</dt><dd>{compact(tls.validFrom)}</dd></div><div><dt>Valid to</dt><dd>{compact(tls.validTo)}</dd></div><div><dt>Days remaining</dt><dd className={finiteNumber(tls.daysRemaining) && tls.daysRemaining <= 30 ? "value-warning" : "value-muted"}>{finiteNumber(tls.daysRemaining) ? `${tls.daysRemaining} days` : "Unavailable"}</dd></div><div><dt>Fingerprint</dt><dd>{compact(tls.fingerprint256)}</dd></div></dl></section><section id="tls-attempts" tabIndex={-1} className={`evidence-card ${highlightedEvidence === "tls-attempts" ? "evidence-highlight" : ""}`}><h3>Connection attempts</h3>{attempts.length ? <ul className="attempt-list">{attempts.map((attempt, index) => <li key={index}><strong>{compact(isRecord(attempt) ? attempt.address : null)}</strong><span>{compact(isRecord(attempt) ? attempt.error : attempt)}</span></li>)}</ul> : <p className="empty-evidence">The first validated endpoint completed the TLS handshake.</p>}<CollectorState value={tls} label="TLS collector" /></section></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function controlTone(status: HeaderControlStatus) {
|
||||||
|
return status === "strong" || status === "good" ? "good" : status === "unavailable" ? "muted" : "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HttpEvidence({ http, highlightedEvidence }: { http: Record<string, unknown>; 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<string, unknown> => hop !== null && hasValue(hop.url));
|
||||||
|
const observedPlain = plain !== null && !("error" in plain);
|
||||||
|
const observedRedirectTarget = isRecord(http.httpRedirectTarget) && "validated" in http.httpRedirectTarget;
|
||||||
|
const labels: Record<string, string> = { 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 <div className="evidence-layout http-evidence-layout"><section id="http-redirect-chain" tabIndex={-1} className={`evidence-card redirect-evidence ${highlightedEvidence === "http-redirect-chain" ? "evidence-highlight" : ""}`}><header className="evidence-card-heading"><div><p>Redirect path</p><h3>HTTP to HTTPS</h3></div><span className={`status-badge ${plainStatus.tone}`}>{plainStatus.label}</span></header>{redirectHops.length ? <ol className="redirect-flow">{redirectHops.map((hop, index) => <li key={index}><article className={`redirect-hop ${"error" in hop ? "error" : statusForHttp(hop.status).tone}`}><span className="redirect-step">{index + 1}</span><div><span className="redirect-protocol">{String(hop.url).startsWith("https:") ? "HTTPS" : "HTTP"}</span><strong>{compact(hop.url)}</strong>{hasValue(hop.address) && <small>Validated endpoint · {String(hop.address)}</small>}</div><b className={`status-chip ${"error" in hop ? "error" : statusForHttp(hop.status).tone}`}>{"error" in hop ? compact(hop.code ?? "Error") : `HTTP ${compact(hop.status)}`}</b></article>{index < redirectHops.length - 1 && <div className="redirect-connector" aria-hidden="true">↓</div>}</li>)}</ol> : <p className="empty-evidence">No HTTP(S) response was recorded.</p>}<dl className="posture-list redirect-facts"><div><dt>Validated target</dt><dd>{triState(isRecord(http.httpRedirectTarget) ? http.httpRedirectTarget.validated : undefined, observedRedirectTarget)}</dd></div><div><dt>HTTPS redirect</dt><dd>{triState(http.httpRedirectsToHttps, observedPlain)}</dd></div></dl>{http.httpsDowngrade === true && <p className="inline-alert error">HTTPS downgrade blocked before the HTTP target was requested.</p>}</section><section id="http-header-posture" tabIndex={-1} className={`evidence-card header-evidence ${highlightedEvidence === "http-header-posture" ? "evidence-highlight" : ""}`}><header className="evidence-card-heading"><div><p>Final HTTPS response</p><h3>Security header posture</h3></div><span className="status-badge">{posture.aggregate.label}</span></header><div className="header-matrix">{posture.controls.map((control) => <div key={control.id} title={control.reason}><span>{labels[control.id]}</span><strong className={`value-${controlTone(control.status)}`}>{control.status}</strong><small>{control.reason}</small></div>)}</div>{headers ? <details className="header-details"><summary>Show normalized header evidence</summary><ul className="header-list">{headerNames.map((name) => <li key={name}><span>{name}</span><code>{compact(headers[name])}</code></li>)}</ul></details> : <p className="empty-evidence">No terminal HTTPS response; controls are unavailable rather than missing.</p>}</section><CollectorState value={http} label="HTTP collector" /></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function RefreshIcon() {
|
||||||
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0 2 5.3" fill="none" stroke="currentColor" strokeWidth="2"/><path d="M20 5v6h-6" fill="none" stroke="currentColor" strokeWidth="2"/></svg>;
|
||||||
|
}
|
||||||
|
function ShareIcon() {
|
||||||
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V3m0 0L7 8m5-5 5 5M5 13v7h14v-7" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLDivElement | null>(null);
|
||||||
|
const mobileRef = useRef<HTMLDivElement | null>(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 <>
|
||||||
|
<button type="button" className="action-reinspect" onClick={() => { close(); onReinspect(); }} disabled={loading}><RefreshIcon />{loading ? "Inspecting…" : "Re-inspect"}</button>
|
||||||
|
<button type="button" className="inspect-another-btn" onClick={() => { close(); onInspectAnother(); }}>Inspect another host</button>
|
||||||
|
<button type="button" className="share-btn" onClick={() => { close(); onShare(); }}><ShareIcon />Share</button>
|
||||||
|
<div className="export-control"><button type="button" className="export-btn" aria-expanded={exportOpen} aria-controls={exportId} onClick={() => setExportOpen((open) => !open)}>Export <span aria-hidden="true">⌄</span></button>{exportOpen && <div id={exportId} className="export-popover" aria-label="Export report"><button type="button" onClick={() => { onExportJson(); close(); }}>JSON</button><button type="button" onClick={() => { onExportMarkdown(); close(); }}>Markdown</button></div>}</div>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return <><div ref={desktopRef} className="report-actions">{controls("desktop")}</div><div ref={mobileRef} className="mobile-action-menu"><button type="button" className="actions-toggle" aria-expanded={mobileOpen} aria-controls="mobile-report-actions" onClick={() => setMobileOpen((open) => !open)}>Actions <span aria-hidden="true">⌄</span></button>{mobileOpen && <div id="mobile-report-actions" className="actions-popover" aria-label="Report actions">{controls("mobile")}</div>}</div></>;
|
||||||
|
}
|
||||||
@@ -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 <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="7" width="10" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="1.6"/><path d="M6 16H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" fill="none" stroke="currentColor" strokeWidth="1.6"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseIcon() {
|
||||||
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Gauge({ value, tone }: { value: number; tone: "green" | "amber" | "red" | "muted" }) {
|
||||||
|
const degrees = Math.max(0, Math.min(100, value)) * 3.6;
|
||||||
|
return <div className={`gauge ${tone}`} style={{ "--gauge": `${degrees}deg` } as CSSProperties}><div /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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<string, string> = { 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<string, unknown>) {
|
||||||
|
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<string, unknown>) {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+491
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 72" role="img" aria-label="CertScope shield">
|
||||||
|
<path fill="#42aaff" d="M32 3 58 12v20.4C58 48.8 47.4 61.8 32 69 16.6 61.8 6 48.8 6 32.4V12L32 3Z"/>
|
||||||
|
<path fill="none" stroke="#06111f" stroke-linecap="round" stroke-linejoin="round" stroke-width="7" d="m20.6 35.2 7.2 7.4 15.6-18"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 346 B |
@@ -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 <html lang="en"><body>{children}</body></html>;
|
||||||
|
}
|
||||||
+124
@@ -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 <svg viewBox="0 0 32 36" aria-hidden="true"><path d="M16 1.5 29 6v10.2c0 8.2-5.3 14.7-13 18.3C8.3 30.9 3 24.4 3 16.2V6L16 1.5Z" fill="currentColor" opacity=".95"/><path d="m10.3 17.6 3.6 3.7 7.8-9" fill="none" stroke="#06111f" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/></svg>;
|
||||||
|
}
|
||||||
|
function SearchIcon() {
|
||||||
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5" fill="none" stroke="currentColor" strokeWidth="2"/><path d="m16 16 4 4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
|
||||||
|
}
|
||||||
|
function CheckIcon() {
|
||||||
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 12.5 4.2 4.2L19.5 6.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"/></svg>;
|
||||||
|
}
|
||||||
|
function ArrowIcon() {
|
||||||
|
return <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h13m-5-5 5 5-5 5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ActiveReport | null>(null);
|
||||||
|
const [request, setRequest] = useState<RequestState>({ 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 <main className="cert-app-shell" aria-busy={loading}>
|
||||||
|
<header className="app-topbar">
|
||||||
|
<div className="brand-lockup"><span className="brand-mark"><ShieldLogo /></span><div><strong>CertScope</strong><small>DOMAIN POSTURE INSPECTOR</small></div></div>
|
||||||
|
<div className="product-context"><strong>DNS · TLS · HTTP</strong><span>Evidence-led checks for public infrastructure</span></div>
|
||||||
|
<div className="topbar-right"><span className="version-chip">v{CERTSCOPE_VERSION}</span><a className="repo-link" href="https://gitea.itworx.tech/Jens/CertScope-Public" target="_blank" rel="noreferrer">Repository</a></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="cert-main">
|
||||||
|
{report ? <ReportView key={report.result.host + report.result.inspectedAt + report.source}
|
||||||
|
result={report.result}
|
||||||
|
source={report.source}
|
||||||
|
isLoading={loading}
|
||||||
|
requestedHost={request.status === "loading" ? request.requestedHost : null}
|
||||||
|
requestError={request.status === "error" ? request : null}
|
||||||
|
onReinspect={(currentHost) => void runInspection(currentHost)}
|
||||||
|
onInspectAnother={(nextHost) => void runInspection(nextHost)}
|
||||||
|
/> : <>
|
||||||
|
<section className="hero-workspace" aria-labelledby="certscope-title">
|
||||||
|
<div className="hero-copy">
|
||||||
|
<p className="tagline">Smaller risks. A more open internet.</p>
|
||||||
|
<p className="hero-kicker"><span aria-hidden="true">◆</span> Explainable domain inspection</p>
|
||||||
|
<h1 id="certscope-title">CertScope</h1>
|
||||||
|
<p className="hero-statement">Public infrastructure posture, made explainable.</p>
|
||||||
|
<p className="hero-description">Inspect the DNS, TLS and HTTP posture of any public hostname. Every score change points back to evidence, not a black box.</p>
|
||||||
|
<div className="hero-signals" aria-label="Inspection characteristics"><span>Single-host</span><span>Deterministic</span><span>Evidence-led</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={inspect} className="inspect-cluster" aria-label="Inspect a hostname">
|
||||||
|
<div className="inspect-card-heading"><div><p>Start an inspection</p><strong>Inspect a hostname</strong></div><span className="inspect-card-status">Public targets only</span></div>
|
||||||
|
<div className="host-field-wrap"><label htmlFor="hostname">Public hostname</label><input id="hostname" value={host} onChange={(event) => setHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} aria-describedby="hostname-help" /><small id="hostname-help">Enter a hostname, not a URL or IP address.</small></div>
|
||||||
|
<div className="quick-hosts" aria-label="Example hostnames">{["example.com", "github.com", "cloudflare.com"].map((example) => <button type="button" key={example} onClick={() => setHost(example)} aria-label={"Use " + example}>{example}</button>)}</div>
|
||||||
|
<div className="inspect-actions"><button type="submit" className="primary-btn" disabled={loading || !host.trim()}><SearchIcon />{loading ? "Inspecting…" : "Inspect"}</button><button type="button" className="secondary-btn" onClick={loadDemo} disabled={loading}>Load demo <ArrowIcon /></button></div>
|
||||||
|
<p className="inspect-card-note">DNS, TLS and HTTP checks stay bounded; no scan history is stored.</p>
|
||||||
|
{landingError && <div className="error-banner" role="alert"><strong>Inspection unavailable</strong><span>{landingError}</span><small>Adjust the hostname and try again.</small></div>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{loading && <section className="inspection-progress" role="status" aria-live="polite"><div className="progress-orb" aria-hidden="true"><ShieldLogo /></div><div><strong>Inspecting DNS, TLS and HTTP posture…</strong><p>Collecting bounded evidence for <code>{host.trim()}</code>. Existing evidence will appear as soon as the report is ready.</p></div><div className="progress-checks" aria-hidden="true"><span>Resolve</span><span>Verify</span><span>Explain</span></div></section>}
|
||||||
|
<section className="capability-section" aria-labelledby="capabilities-title"><div className="section-heading"><div><p>What CertScope checks</p><h2 id="capabilities-title">One clear report across the public-facing stack.</h2></div><span>Evidence, not guesswork</span></div><div className="capability-grid">{capabilities.map((capability) => <article className="capability-card" key={capability.label}><span className="capability-index" aria-hidden="true">{capability.icon}</span><h3>{capability.label}</h3><p>{capability.detail}</p></article>)}</div></section>
|
||||||
|
<section className="security-boundary" aria-label="Inspection security boundary"><div><p>Safe by design</p><strong>Strong boundaries around every connection.</strong></div><ul>{securityBoundaries.map((boundary) => <li key={boundary}><CheckIcon />{boundary}</li>)}</ul></section>
|
||||||
|
<section className="demo-preview" aria-labelledby="demo-preview-title"><div className="demo-preview-copy"><p>Deterministic demo</p><h2 id="demo-preview-title">See the report before you inspect.</h2><span>The built-in fixture is local, reproducible and ready to explore without a network request.</span><button type="button" className="preview-demo-button" onClick={loadDemo}>Open demo report <ArrowIcon /></button></div><div className="demo-preview-report" aria-label="Preview of the deterministic demo report"><header><div><span>Demo preview</span><strong>{demoInspection.host}</strong></div><b>{demoInspection.score.overall}<small>/100</small></b></header><div className="preview-score-grid">{(["dns", "tls", "http"] as const).map((category) => <div key={category}><span>{category.toUpperCase()}</span><strong>{demoInspection.score.categories[category]}</strong></div>)}</div><div className="preview-finding"><span className="preview-finding-icon">!</span><div><strong>{demoWarning.title}</strong><p>{demoWarning.summary}</p></div></div></div></section>
|
||||||
|
</>}
|
||||||
|
</section>
|
||||||
|
</main>;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>) {
|
||||||
|
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<Tab>("overview");
|
||||||
|
const [selectedFindingId, setSelectedFindingId] = useState<string | null>(null);
|
||||||
|
const [showAllFindings, setShowAllFindings] = useState(false);
|
||||||
|
const [showAllAddresses, setShowAllAddresses] = useState(false);
|
||||||
|
const [showAllIssues, setShowAllIssues] = useState(false);
|
||||||
|
const [filter, setFilter] = useState<FindingFilter>("all");
|
||||||
|
const [wrapRaw, setWrapRaw] = useState(false);
|
||||||
|
const [highlightedEvidence, setHighlightedEvidence] = useState<string | null>(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<HTMLTextAreaElement | null>(null);
|
||||||
|
const dialogRef = useRef<HTMLElement | null>(null);
|
||||||
|
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const originatingFindingRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const findings = useMemo(() => {
|
||||||
|
const severityRank: Record<Finding["severity"], number> = { 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<string, unknown>;
|
||||||
|
const tls = result.tls as Record<string, unknown>;
|
||||||
|
const http = result.http as Record<string, unknown>;
|
||||||
|
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<HTMLElement>(".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<HTMLElement>("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<HTMLButtonElement>, 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 <>
|
||||||
|
<section className="report-workspace" aria-label="Inspection report">
|
||||||
|
<h1 className="sr-only">CertScope inspection report for {result.host}</h1>
|
||||||
|
<header className="report-context-bar"><div className="report-identity"><p>{isDemo ? "Deterministic demo report" : "Live inspection report"}</p><h2>{result.host}</h2></div><dl className="report-context-meta"><div><dt>Coverage</dt><dd><span className={"status-badge " + result.completeness.status}>{result.completeness.status === "complete" ? "Complete" : result.completeness.status === "partial" ? "Partial" : "Incomplete"}</span> {result.completeness.complete}/{result.completeness.total} collectors</dd></div><div><dt>Addresses</dt><dd className="address-list">{addresses.length ? <>{(showAllAddresses ? addresses : addresses.slice(0, 4)).join(" · ")}{addresses.length > 4 && <button type="button" className="inline-expand" onClick={() => setShowAllAddresses((show) => !show)}>{showAllAddresses ? "Show fewer addresses" : `+${addresses.length - 4} more`}</button>}</> : "Unavailable"}</dd></div><div><dt>Last inspected</dt><dd title={result.inspectedAt}>{formatInspectedAt(result.inspectedAt)}</dd></div></dl>{inspectAnotherOpen && <form className="workspace-host-form" onSubmit={submitAnotherHost}><label htmlFor="workspace-hostname">Another public hostname</label><input id="workspace-hostname" value={nextHost} onChange={(event) => setNextHost(event.target.value)} placeholder="example.com" autoComplete="off" spellCheck={false} /><button type="submit" disabled={isLoading || !nextHost.trim()}>{isLoading ? "Inspecting…" : "Inspect"}</button></form>}</header>
|
||||||
|
<div className="score-row">
|
||||||
|
{(() => { const tone = scoreTone("overall", result.score.overall); return <div className={"metric-card overall " + tone}><div><span>Overall posture</span><strong>{result.score.overall}<small>/ 100</small></strong><em>{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"}</em></div><Gauge value={result.score.overall} tone={tone} /></div>; })()}
|
||||||
|
<div className="category-score-grid">{[["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 <div className={"metric-card " + String(label).toLowerCase() + " " + tone} key={String(label)}><div><span>{String(label)}</span><strong>{Number(value)}<small>/100</small></strong><em>{String(description)}</em></div><Gauge value={Number(value)} tone={tone} /></div>; })}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="report-status-row"><span className={"status-badge " + result.completeness.status}>{result.completeness.status === "complete" ? "Complete inspection" : result.completeness.status === "partial" ? "Partial inspection" : "Inspection incomplete"}</span><span>{result.completeness.complete}/{result.completeness.total} collectors complete</span><span className="provenance-badge">{isDemo ? "Deterministic demo" : "Live inspection"}</span>{isLoading && requestedHost && <span role="status">{requestedHost === result.host ? "Re-inspecting" : "Inspecting"} {requestedHost}…</span>}</div>
|
||||||
|
{requestError && <div className="error-banner previous-report-banner" role="alert"><strong>Previous report remains displayed</strong><span>{requestError.message}</span><small>The {isDemo ? "deterministic demo" : "live"} report for {result.host} is still available while {requestError.requestedHost} could not be inspected.</small></div>}
|
||||||
|
{result.completeness.issues.length > 0 && <p className={"completeness-note " + result.completeness.status}><strong>Inspection incomplete</strong>{(showAllIssues ? result.completeness.issues : result.completeness.issues.slice(0, 3)).join(" · ")}{result.completeness.issues.length > 3 && <button type="button" className="inline-expand" onClick={() => setShowAllIssues((show) => !show)}>{showAllIssues ? "Show fewer issues" : `+${result.completeness.issues.length - 3} more`}</button>} Remaining evidence is still valid.</p>}
|
||||||
|
|
||||||
|
<div className="report-tabs-bar"><nav aria-label="Report sections" role="tablist">{tabs.map((item, index) => <button type="button" role="tab" aria-selected={tab === item} aria-controls={"panel-" + item} id={item + "-tab"} className={tab === item ? "active" : ""} key={item} onClick={() => switchTab(item)} onKeyDown={(event) => handleTabKey(event, index)}>{item === "raw" ? "Raw" : item.toUpperCase()}</button>)}</nav><ReportActions loading={isLoading} onReinspect={() => { announce("Re-inspection started"); onReinspect(result.host); }} onInspectAnother={() => setInspectAnotherOpen((open) => !open)} onShare={() => void share()} onExportJson={exportJson} onExportMarkdown={exportMarkdown} /></div>
|
||||||
|
<p className="action-status" role="status" aria-live="polite">{statusMessage}</p>
|
||||||
|
{manualCopy && <div className="manual-copy-panel" role="region" aria-label={`Copy ${manualCopy.label} manually`}><div><strong>{manualCopy.label} ready to copy</strong><button type="button" onClick={() => setManualCopy(null)}>Close</button></div><p>Use Ctrl+C (or Copy on your device) to copy the selected text.</p><textarea ref={manualCopyRef} readOnly value={manualCopy.value} onFocus={(event) => event.currentTarget.select()} aria-label={`${manualCopy.label} text`} /></div>}
|
||||||
|
|
||||||
|
{tab === "overview" && <div id="panel-overview" role="tabpanel" aria-labelledby="overview-tab">
|
||||||
|
<section className="findings-panel" aria-labelledby="findings-title"><header><div><h2 id="findings-title">Findings</h2><span>{findings.length} total</span><p className="severity-counts" aria-label="Finding severity counts">{severityCounts.map(({ severity, count }) => <span className={"pill " + severity} key={severity}>{count} {severityLabel(severity).toLowerCase()}{count === 1 ? "" : "s"}</span>)}</p></div><button type="button" onClick={() => switchTab("raw")}>Open raw report →</button></header><div className="finding-filters" aria-label="Filter findings">{(["all", "critical", "warning", "info"] as const).map((item) => <button type="button" key={item} className={filter === item ? "active" : ""} aria-pressed={filter === item} onClick={() => { setFilter(item); setShowAllFindings(true); }}>{item === "all" ? "All" : severityLabel(item)}</button>)}</div>{displayFindings.length ? <div className="findings-grid">{displayFindings.map((finding) => <button type="button" className={"finding-card " + finding.severity} key={finding.id} onClick={(event) => { originatingFindingRef.current = event.currentTarget; setSelectedFindingId(finding.id); }} aria-label={"Open diagnostic details for " + finding.title}><div className={"finding-icon " + finding.severity}>{findingIcon(finding.severity)}</div><div className="finding-body"><div className="finding-title"><strong>{finding.title}</strong><span className={"pill " + finding.severity}>{severityLabel(finding.severity)}</span><b className="finding-impact">{finding.scoreImpact}</b></div><p>{finding.summary}</p><small>{finding.category.toUpperCase()} · View diagnostic and remediation</small></div><span className="finding-chevron" aria-hidden="true">›</span></button>)}</div> : <p className="empty-findings">No findings match this filter.</p>}{filteredFindings.length > 4 && <button type="button" className="show-all-button" onClick={() => setShowAllFindings((show) => !show)}>{showAllFindings ? "Show fewer findings" : "Show all " + filteredFindings.length + " findings"}</button>}</section>
|
||||||
|
<section className="detail-grid" aria-label="Evidence summary"><article className="detail-card dns-card"><header><h3>DNS summary</h3><span className="status-badge">{collectorLabel(collectorMeta(dns).status)}</span></header><dl className="summary-list">{[["A", dns.a], ["AAAA", dns.aaaa], ["MX", dns.mx], ["NS", dns.ns], ["SPF", dns.spf], ["DMARC", dns.dmarc]].map(([key, value]) => <div className="summary-row" key={String(key)}><dt>{String(key)}</dt><dd>{key === "MX" ? formatMxRecords(value) : compact(value)}</dd></div>)}</dl><button type="button" className="card-link" onClick={() => switchTab("dns")}>View DNS evidence →</button></article><article className="detail-card tls-card"><header><h3>TLS summary</h3><span className={"status-badge " + (tlsVerified ? "complete" : "partial")}>{tlsStatus}</span></header><dl className="summary-list"><div><dt>Protocol</dt><dd>{compact(tls.protocol)}</dd></div><div><dt>Endpoint</dt><dd>{compact(tls.address)}</dd></div><div><dt>Hostname match</dt><dd>{tls.hostnameValid === true ? "Yes" : tls.hostnameValid === false ? "No" : "Unavailable"}</dd></div><div><dt>Days remaining</dt><dd>{finiteNumber(tls.daysRemaining) ? tls.daysRemaining : "Unavailable"}</dd></div></dl><button type="button" className="card-link" onClick={() => switchTab("tls")}>View TLS evidence →</button></article><article className="detail-card http-card"><header><h3>HTTP summary</h3><span className={"status-badge " + httpStatus.tone}>{httpStatus.label}</span></header><dl className="summary-list"><div><dt>HTTPS endpoint</dt><dd>{compact(http.finalUrl)}</dd></div><div><dt>Redirects</dt><dd>{redirectCount} of {responses.length} responses</dd></div><div><dt>HSTS</dt><dd>{headers ? analyzeHeaders(headers).hsts.status : "Unavailable"}</dd></div><div><dt>Content-Security-Policy</dt><dd>{headers ? analyzeHeaders(headers).csp.status : "Unavailable"}</dd></div></dl><button type="button" className="card-link" onClick={() => switchTab("http")}>View HTTP evidence →</button></article></section>
|
||||||
|
</div>}
|
||||||
|
{tab === "dns" && <section id="panel-dns" role="tabpanel" aria-labelledby="dns-tab" className="single-detail"><header><div><h2>DNS evidence</h2><p>Structured record data and collector health.</p></div></header><DnsEvidence dns={dns} highlightedEvidence={highlightedEvidence} onCopy={copyText} /></section>}
|
||||||
|
{tab === "tls" && <section id="panel-tls" role="tabpanel" aria-labelledby="tls-tab" className="single-detail"><header><div><h2>TLS evidence</h2><p>Certificate identity, validity and validated connection attempts.</p></div></header><TlsEvidence tls={tls} highlightedEvidence={highlightedEvidence} /></section>}
|
||||||
|
{tab === "http" && <section id="panel-http" role="tabpanel" aria-labelledby="http-tab" className="single-detail"><header><div><h2>HTTP evidence</h2><p>Redirect behavior, response semantics and parsed security headers.</p></div></header><HttpEvidence http={http} highlightedEvidence={highlightedEvidence} /></section>}
|
||||||
|
{tab === "raw" && <section id="panel-raw" role="tabpanel" aria-labelledby="raw-tab" className="single-detail"><header><div><h2>Raw normalized report</h2><p>Complete JSON representation used by the diagnostics and exports.</p></div><div className="raw-actions"><button type="button" onClick={() => void copyText("Raw JSON", JSON.stringify(result, null, 2))}>Copy JSON</button><button type="button" onClick={exportJson}>Download JSON</button><button type="button" aria-pressed={wrapRaw} onClick={() => setWrapRaw((wrap) => !wrap)}>{wrapRaw ? "Disable line wrap" : "Wrap lines"}</button></div></header><pre id="raw-evidence" tabIndex={-1} className={(wrapRaw ? "wrap " : "") + (highlightedEvidence === "raw-evidence" ? "evidence-highlight" : "")}>{JSON.stringify(result, null, 2)}</pre></section>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedFinding && <div className="drawer-backdrop" role="presentation" onMouseDown={() => setSelectedFindingId(null)}><aside ref={dialogRef} className="diagnostic-drawer" role="dialog" aria-modal="true" aria-labelledby="diagnostic-title" aria-describedby="diagnostic-summary" tabIndex={-1} onMouseDown={(event) => event.stopPropagation()}><header><div><p className="drawer-kicker">Diagnostic detail</p><h2 id="diagnostic-title">{selectedFinding.title}</h2></div><button ref={closeRef} type="button" className="drawer-close" aria-label="Close diagnostic details" onClick={() => setSelectedFindingId(null)}><CloseIcon /></button></header><span className={"pill " + selectedFinding.severity}>{severityLabel(selectedFinding.severity)}</span><p id="diagnostic-summary" className="drawer-summary">{selectedFinding.summary}</p><dl className="drawer-facts"><div><dt>Category</dt><dd>{selectedFinding.category.toUpperCase()}</dd></div><div><dt>Score impact</dt><dd>{selectedFinding.scoreImpact > 0 ? "+" : ""}{selectedFinding.scoreImpact}</dd></div></dl><section><h3>Evidence pointers</h3><ul className="evidence-pointers">{selectedFinding.evidence.length ? selectedFinding.evidence.map((pointer) => <li key={pointer}><code>{pointer}</code></li>) : <li>No evidence pointer was recorded.</li>}</ul><p className="drawer-note">View evidence opens the structured location when a stable pointer is available, otherwise Raw.</p></section><section><h3>Recommended action</h3><div className="remedy-box">{selectedFinding.remediation ?? "No action required."}</div></section><div className="drawer-actions"><button type="button" className="drawer-evidence-button" onClick={() => viewEvidence(selectedFinding)}>View evidence</button><button type="button" className="drawer-raw-button" onClick={() => { switchTab("raw"); setSelectedFindingId(null); }}>Open raw evidence</button></div></aside></div>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: certscope
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_VERSION: ${CERTSCOPE_VERSION:-1.0.4}
|
||||||
|
BUILD_REVISION: ${GITEA_COMMIT_SHA:-${CERTSCOPE_BUILD_REVISION:-local}}
|
||||||
|
container_name: certscope
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "1251:3000"
|
||||||
|
labels:
|
||||||
|
net.unraid.docker.webui: "http://localhost:1251"
|
||||||
|
net.unraid.docker.icon: "http://localhost:1251/dockerman-icon.png"
|
||||||
|
org.opencontainers.image.revision: "${GITEA_COMMIT_SHA:-${CERTSCOPE_BUILD_REVISION:-local}}"
|
||||||
|
org.opencontainers.image.source: "https://gitea.itworx.tech/Jens/CertScope-Public.git"
|
||||||
|
org.opencontainers.image.version: "${CERTSCOPE_VERSION:-1.0.4}"
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
init: true
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=64m
|
||||||
|
mem_limit: 768m
|
||||||
|
cpus: 1.5
|
||||||
|
stop_grace_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "3"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Browser -> /api/inspect -> hostname normalization and policy
|
||||||
|
-> DNS A/AAAA + mail-record collectors
|
||||||
|
-> validate every resolved public address
|
||||||
|
-> TLS and HTTP(S) collectors
|
||||||
|
-> direct IP connection with hostname SNI/Host
|
||||||
|
-> bounded, revalidated redirect hops
|
||||||
|
-> normalized evidence + collector metadata
|
||||||
|
-> deterministic diagnostics
|
||||||
|
-> category scores + equal-weighted overall score
|
||||||
|
-> dashboard, JSON and Markdown exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
All probing is server-side. The API accepts a hostname rather than an arbitrary URL. `policy.ts` normalizes lower-case DNS/IDN names and rejects URL/IP input; it validates every A/AAAA answer as public unicast before a connection. Collectors receive validated candidate addresses and connect directly to those addresses while retaining the normalized hostname for TLS SNI and the HTTP Host header.
|
||||||
|
|
||||||
|
DNS runs the six required record queries under one four-second aggregate deadline. A or AAAA can be absent or fail independently; one working family is retained as partial evidence. No usable safe address is a hard resolution error. HTTP and TLS try multiple validated candidates with bounded attempts and total budgets. HTTP has a 15-second budget, TLS an eight-second budget, and DNS a four-second budget. HTTP response bodies are destroyed after headers, and TLS sockets are destroyed after each result or failure.
|
||||||
|
|
||||||
|
Each redirect is parsed as a URL, restricted to HTTP(S) and default ports, normalized into a visited set, checked for loops, and re-resolved before its direct connection. Policy-rejected targets become `http.redirect_blocked` evidence without a request being made. HTTPS-to-HTTP redirects are stopped before the downgraded request and become `http.https_downgrade` evidence. Redirect depth is capped at three.
|
||||||
|
|
||||||
|
Redirect `Location` values are retained in raw form only while the next target is validated; returned evidence removes URL userinfo so credentials cannot appear in reports. Certificate validity dates are checked for parseability and chronological order, and MX answer shape is checked before mail posture findings are derived.
|
||||||
|
|
||||||
|
## Evidence and scoring
|
||||||
|
|
||||||
|
Collectors return normalized evidence and `_meta` status (`ok`, `partial` or `failed`) with safe probe errors. Diagnostics only score observations that exist; collector incompleteness is shown separately. Category scores are clamped after their own negative findings, then the overall score is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DNS = clamp(100 + DNS finding impacts, 0, 100)
|
||||||
|
TLS = clamp(100 + TLS finding impacts, 0, 100)
|
||||||
|
HTTP = clamp(100 + HTTP finding impacts, 0, 100)
|
||||||
|
overall = round((DNS + TLS + HTTP) / 3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Thus an HTTP deduction is not subtracted a second time from the global score. Stable finding IDs, deterministic severity/impact ordering and evidence pointers keep reports reproducible.
|
||||||
|
|
||||||
|
## Presentation and runtime
|
||||||
|
|
||||||
|
`app/page.tsx` owns the landing, request state, validated active report, demo and re-inspection states. It promotes a demo/live report only after a structurally valid successful response; an error preserves the prior report and provenance. Its capability, public-boundary and demo-preview content is static product explanation or derived from the deterministic fixture; it does not imply scan history or hidden services. `app/report-view.tsx` orchestrates report-local state, contextual navigation and the accessible diagnostic drawer. `app/components/report/` contains focused actions, shared display utilities and structured DNS/TLS/HTTP evidence views; none performs probes or reimplements header policy. `app/api/health` is a side-effect-free runtime check exposing status, package version and the build revision.
|
||||||
|
|
||||||
|
Header parsing produces typed control posture for HSTS, CSP, `nosniff`, Referrer-Policy and frame protection. Diagnostics and the HTTP evidence view consume that same model, so the aggregate is derived from evaluated controls rather than display text. Missing evidence from a partial collector is represented as unavailable rather than a negative observation.
|
||||||
|
|
||||||
|
The production image uses Next.js standalone output. The local start helper stages generated static assets into `.next/standalone`; Docker copies those assets directly into the final non-root runtime image. `package.json` is the version source for the application and health response; deployment supplies the same release version and exact commit as image build metadata.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Threat model
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
CertScope protects the host running the inspector from becoming a generic network pivot while preserving enough evidence to troubleshoot public DNS/TLS/HTTP posture. It also protects report consumers from misleading scores and unescaped exported content.
|
||||||
|
|
||||||
|
## Primary threats
|
||||||
|
|
||||||
|
1. **SSRF into private infrastructure.** A user-controlled hostname or redirect could resolve to loopback, link-local, RFC1918, CGNAT, ULA, multicast or other non-public space.
|
||||||
|
2. **DNS rebinding / TOCTOU.** A safe first lookup could be followed by a different answer before a socket is opened, or a redirect target could resolve privately.
|
||||||
|
3. **Port and scheme escalation.** A redirect could attempt `file:`, credentials, protocol confusion or an arbitrary port.
|
||||||
|
4. **Unbounded work.** Redirect loops, many addresses or stalled DNS/sockets could consume server resources or create concurrent connection pressure.
|
||||||
|
5. **Misleading partial evidence or stale provenance.** A failed record family or hop could be mistaken for an absent control, or a failed new request could make a prior demo report appear live.
|
||||||
|
6. **Report/content injection.** Hostnames, headers and diagnostic text could break the UI or Markdown exports if rendered without escaping.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- hostname-only input validation, lower-case normalization and safe IDN-to-ASCII conversion;
|
||||||
|
- public-unicast validation for every resolved A/AAAA answer before any candidate is attempted;
|
||||||
|
- direct IP connection after validation, retaining the normalized hostname only for Host/SNI, so the connect path does not ask the OS to resolve the hostname again;
|
||||||
|
- bounded multi-address fallback with a hard total deadline and recorded candidate failures;
|
||||||
|
- re-resolution and target validation for every redirect hop;
|
||||||
|
- HTTP(S)-only, default ports only, maximum three redirect hops, normalized visited-URL loop detection and explicit HTTPS downgrade blocking;
|
||||||
|
- four-second DNS, eight-second TLS and 15-second HTTP aggregate budgets, plus per-operation timers and socket/response teardown;
|
||||||
|
- explicit collector `_meta.status` and error evidence, with score deductions applied only to observed evidence;
|
||||||
|
- client-side structural validation before a successful API payload can replace a valid report, plus retained and explicitly labelled prior evidence on request failure;
|
||||||
|
- typed header posture shared by diagnostics and UI, including CSP first-directive-wins behavior, browser-style Referrer-Policy fallback lists and `Unavailable` rather than inferred negative controls;
|
||||||
|
- deterministic, non-AI diagnostic rules with stable IDs and an equal-weighted category score;
|
||||||
|
- React escaping for UI values and length-safe Markdown escaping/fenced JSON exports;
|
||||||
|
- process-local four-slot admission and 30-per-minute fair-use limiting. Forwarded headers are ignored unless an operator explicitly trusts the reverse proxy;
|
||||||
|
- no persistence, credential handling, arbitrary shell execution or background jobs in the MVP;
|
||||||
|
- container non-root, read-only root filesystem, dropped capabilities, no-new-privileges and bounded temporary storage.
|
||||||
|
|
||||||
|
## Residual risk and assumptions
|
||||||
|
|
||||||
|
The deployment is intentionally unauthenticated and should be kept behind the intended network boundary or an operator-controlled access layer. The process-local limiter is not shared across replicas. DNS and public routing can change after an individual validated connection; CertScope makes no claim about future state. It also does not inspect response bodies or prove application-level authorization.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
CertScope does not perform port scanning, subdomain enumeration, vulnerability exploitation, authenticated crawling, private-network inspection, history persistence, monitoring daemon work or multi-host inventory.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 289 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 392 KiB |
@@ -0,0 +1,221 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
import { demoInspection } from "../lib/inspect/demo";
|
||||||
|
|
||||||
|
async function loadDemo(page: import("@playwright/test").Page) {
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "Load demo" }).click();
|
||||||
|
await expect(page.getByText("Deterministic demo report", { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("landing, metadata and favicon present a truthful product entry point", async ({ page, request }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") consoleErrors.push(message.text());
|
||||||
|
});
|
||||||
|
const homeResponse = await page.goto("/");
|
||||||
|
expect(homeResponse?.status()).toBe(200);
|
||||||
|
expect(homeResponse?.headers()["content-security-policy"]).toContain("frame-ancestors 'none'");
|
||||||
|
expect(homeResponse?.headers()["x-content-type-options"]).toBe("nosniff");
|
||||||
|
expect(homeResponse?.headers()["cross-origin-opener-policy"]).toBeUndefined();
|
||||||
|
await expect(page).toHaveTitle("CertScope — Domain posture inspector");
|
||||||
|
await expect(page.getByRole("heading", { name: "CertScope", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("region", { name: "Inspection security boundary" })).toContainText("Redirects revalidated");
|
||||||
|
await expect(page.getByLabel("Public hostname")).toHaveValue("");
|
||||||
|
await expect(page.getByRole("button", { name: "Inspect", exact: true })).toBeDisabled();
|
||||||
|
await expect(page.locator("link[rel~='icon']").first()).toHaveAttribute("href", /favicon\.ico/);
|
||||||
|
expect((await request.get("/favicon.ico")).status()).toBe(200);
|
||||||
|
expect((await request.get("/icon.svg")).headers()["content-type"]).toContain("image/svg+xml");
|
||||||
|
const dockerManIcon = await request.get("/dockerman-icon.png");
|
||||||
|
expect(dockerManIcon.status()).toBe(200);
|
||||||
|
expect(dockerManIcon.headers()["content-type"]).toContain("image/png");
|
||||||
|
expect((await dockerManIcon.body()).subarray(0, 8).toString("hex")).toBe("89504e470d0a1a0a");
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
expect(consoleErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("demo report is an evidence-led workspace with accessible drawer and exports", async ({ page }) => {
|
||||||
|
await loadDemo(page);
|
||||||
|
await expect(page.locator(".metric-card.overall strong")).toHaveText("98/ 100");
|
||||||
|
await expect(page.locator(".metric-card.overall em")).toContainText("(DNS 100 + TLS 100 + HTTP 94) ÷ 3 = 98 (rounded)");
|
||||||
|
await expect(page.locator(".metric-card")).toHaveCount(4);
|
||||||
|
await expect(page.getByRole("heading", { name: "CertScope", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByText("5 / 5 controls healthy")).toHaveCount(0);
|
||||||
|
await expect(page.getByText("Complete inspection")).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "Findings" })).toBeVisible();
|
||||||
|
|
||||||
|
const firstFinding = page.getByRole("button", { name: /Open diagnostic details/ }).first();
|
||||||
|
await firstFinding.click();
|
||||||
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Close diagnostic details" })).toBeFocused();
|
||||||
|
await expect(page.locator(".report-workspace")).toHaveAttribute("inert", "");
|
||||||
|
await page.keyboard.press("Tab");
|
||||||
|
await expect(page.getByRole("button", { name: "View evidence" })).toBeFocused();
|
||||||
|
await page.getByRole("button", { name: "View evidence" }).click();
|
||||||
|
await expect(page.getByRole("tab", { name: "HTTP", exact: true })).toHaveAttribute("aria-selected", "true");
|
||||||
|
await expect(page.locator("#http-header-posture")).toBeFocused();
|
||||||
|
await expect(page.getByRole("heading", { name: "Security header posture" })).toBeVisible();
|
||||||
|
await expect(page.getByText("1 control needs attention")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "Raw", exact: true }).click();
|
||||||
|
await expect(page.locator("#raw-evidence")).toContainText('"host": "portfolio-demo.invalid"');
|
||||||
|
await page.getByRole("button", { name: "Wrap lines" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Disable line wrap" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
await page.getByRole("button", { name: "Copy JSON" }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText(/Raw JSON copied|Raw JSON ready to copy manually/);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "OVERVIEW", exact: true }).click();
|
||||||
|
await expect(page.locator(".dns-card .summary-row").filter({ hasText: "MX" })).toContainText("10 · mail.portfolio-demo.invalid");
|
||||||
|
await expect(page.locator(".http-card .summary-list > div").filter({ hasText: "Redirects" })).toContainText("1 of 2 responses");
|
||||||
|
await page.locator(".report-actions").getByRole("button", { name: "Export", exact: true }).click();
|
||||||
|
const jsonDownload = page.waitForEvent("download");
|
||||||
|
await page.locator("#desktop-export-options").getByRole("button", { name: "JSON", exact: true }).click();
|
||||||
|
expect((await jsonDownload).suggestedFilename()).toBe("portfolio-demo.invalid-certscope.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("demo/live provenance changes only after a validated successful inspection", async ({ page }) => {
|
||||||
|
await page.route("**/api/inspect**", async (route) => {
|
||||||
|
const host = new URL(route.request().url()).searchParams.get("host");
|
||||||
|
if (host === "fail.example") return route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Inspection service is temporarily unavailable. Try again shortly." }) });
|
||||||
|
if (host === "invalid.example") return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ host: "invalid.example" }) });
|
||||||
|
if (host === "success.example") return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...demoInspection, host: "success.example", inspectedAt: "2026-09-13T12:00:00.000Z" }) });
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await loadDemo(page);
|
||||||
|
await page.getByRole("button", { name: "Inspect another host" }).click();
|
||||||
|
await page.getByLabel("Another public hostname").fill("fail.example");
|
||||||
|
await page.getByRole("button", { name: "Inspect", exact: true }).last().click();
|
||||||
|
await expect(page.locator(".previous-report-banner")).toContainText("Previous report remains displayed");
|
||||||
|
await expect(page.getByText("Deterministic demo report", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText("portfolio-demo.invalid", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Another public hostname").fill("invalid.example");
|
||||||
|
await page.getByRole("button", { name: "Inspect", exact: true }).last().click();
|
||||||
|
await expect(page.locator(".previous-report-banner")).toContainText("Inspection returned an invalid response");
|
||||||
|
await expect(page.getByText("Deterministic demo report", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Another public hostname").fill("success.example");
|
||||||
|
await page.getByRole("button", { name: "Inspect", exact: true }).last().click();
|
||||||
|
await expect(page.getByText("Live inspection report")).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: "success.example", exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator(".previous-report-banner")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findings reset safely on reinspection and structured tabs remain reachable", async ({ page }) => {
|
||||||
|
let calls = 0;
|
||||||
|
await page.route("**/api/inspect**", async (route) => {
|
||||||
|
if (new URL(route.request().url()).searchParams.get("host") !== "e2e.example") return route.continue();
|
||||||
|
calls += 1;
|
||||||
|
const findings = calls === 1 ? [{ ...demoInspection.findings[0], id: "old.finding", title: "Old finding" }] : [{ ...demoInspection.findings[1], id: "new.finding", title: "New finding" }];
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...demoInspection, host: "e2e.example", findings, inspectedAt: `2026-09-13T12:0${calls}:00.000Z` }) });
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("Public hostname").fill("e2e.example");
|
||||||
|
await page.getByRole("button", { name: "Inspect", exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: /Old finding/ }).click();
|
||||||
|
await page.getByRole("button", { name: "Close diagnostic details" }).click();
|
||||||
|
await page.getByRole("button", { name: "Re-inspect", exact: true }).click();
|
||||||
|
await expect.poll(() => calls).toBe(2);
|
||||||
|
await expect(page.getByText("New finding")).toBeVisible();
|
||||||
|
await expect(page.getByText("Old finding")).toHaveCount(0);
|
||||||
|
await page.getByRole("tab", { name: "DNS", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "DNS evidence" })).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: "TLS", exact: true }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Connection attempts" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile actions are a keyboard-dismissible disclosure with export controls", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await loadDemo(page);
|
||||||
|
const categories = page.locator(".category-score-grid .metric-card");
|
||||||
|
const firstCategory = await categories.first().boundingBox();
|
||||||
|
const lastCategory = await categories.last().boundingBox();
|
||||||
|
expect(firstCategory && lastCategory && Math.abs(firstCategory.y - lastCategory.y)).toBeLessThan(2);
|
||||||
|
const actions = page.getByRole("button", { name: "Actions", exact: true });
|
||||||
|
await actions.click();
|
||||||
|
await expect(actions).toHaveAttribute("aria-expanded", "true");
|
||||||
|
await expect(page.getByRole("button", { name: "Export", exact: true })).toBeVisible();
|
||||||
|
expect(await page.evaluate(() => {
|
||||||
|
const firstAction = document.querySelector(".actions-popover button");
|
||||||
|
if (!firstAction) return false;
|
||||||
|
const bounds = firstAction.getBoundingClientRect();
|
||||||
|
return firstAction.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2));
|
||||||
|
})).toBe(true);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(actions).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await actions.click();
|
||||||
|
await page.getByRole("button", { name: "Export", exact: true }).click();
|
||||||
|
const download = page.waitForEvent("download");
|
||||||
|
await page.locator("#mobile-export-options").getByRole("button", { name: "Markdown", exact: true }).click();
|
||||||
|
expect((await download).suggestedFilename()).toBe("portfolio-demo.invalid-certscope.md");
|
||||||
|
await expect(actions).toHaveAttribute("aria-expanded", "false");
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copy and share offer a manual path when browser clipboard APIs are unavailable", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
|
||||||
|
document.execCommand = () => false;
|
||||||
|
});
|
||||||
|
await loadDemo(page);
|
||||||
|
await page.getByRole("button", { name: "Share", exact: true }).click();
|
||||||
|
const manualCopy = page.getByRole("region", { name: "Copy Report manually" });
|
||||||
|
await expect(manualCopy).toBeVisible();
|
||||||
|
await expect(manualCopy.getByRole("textbox", { name: "Report text" })).toHaveValue(/portfolio-demo.invalid/);
|
||||||
|
await manualCopy.getByRole("button", { name: "Close" }).click();
|
||||||
|
await expect(manualCopy).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("share copies through the selection fallback without the secure clipboard API", async ({ page }) => {
|
||||||
|
await page.addInitScript(() => Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined }));
|
||||||
|
await loadDemo(page);
|
||||||
|
await page.getByRole("button", { name: "Share", exact: true }).click();
|
||||||
|
await expect(page.getByRole("status")).toContainText("Report copied");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("loading and controlled errors are communicated without information leakage", async ({ page }) => {
|
||||||
|
await page.route("**/api/inspect**", async (route) => {
|
||||||
|
const host = new URL(route.request().url()).searchParams.get("host");
|
||||||
|
if (host === "loading.example") {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ...demoInspection, host: "loading.example" }) });
|
||||||
|
}
|
||||||
|
if (host === "rate.example") return route.fulfill({ status: 429, contentType: "application/json", body: JSON.stringify({ error: "Request rate limit reached. Try again shortly." }) });
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByLabel("Public hostname").fill("loading.example");
|
||||||
|
await page.getByRole("button", { name: "Inspect", exact: true }).click();
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: "Inspecting DNS, TLS and HTTP posture" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Live inspection report")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Inspect another host" }).click();
|
||||||
|
await page.getByLabel("Another public hostname").fill("rate.example");
|
||||||
|
await page.getByRole("button", { name: "Inspect", exact: true }).last().click();
|
||||||
|
await expect(page.locator(".previous-report-banner")).toContainText("rate limit");
|
||||||
|
await expect(page.locator(".previous-report-banner")).not.toContainText("stack");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("API blocks unsafe host inputs while retaining normal error semantics", async ({ request }) => {
|
||||||
|
for (const host of ["127.0.0.1", "localhost", "https://example.com", "https://user:pass@example.com"]) {
|
||||||
|
const response = await request.get("/api/inspect?host=" + encodeURIComponent(host));
|
||||||
|
expect(response.status(), host).toBe(400);
|
||||||
|
await expect(response.json()).resolves.toHaveProperty("error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("landing and report have no automated accessibility violations", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||||
|
await page.getByRole("button", { name: "Load demo" }).click();
|
||||||
|
expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("landing and report have no horizontal overflow at supported viewports", async ({ page }) => {
|
||||||
|
for (const [width, height] of [[360, 800], [390, 844], [768, 900], [1024, 768], [1366, 768], [1440, 900], [1920, 1080]]) {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
await page.goto("/");
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), { message: `landing overflow at ${width}px` }).toBe(true);
|
||||||
|
await page.getByRole("button", { name: "Load demo" }).click();
|
||||||
|
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), { message: `report overflow at ${width}px` }).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTypeScript from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
...nextCoreWebVitals,
|
||||||
|
...nextTypeScript,
|
||||||
|
globalIgnores([".next/**", "node_modules/**", "playwright-report/**", "test-results/**"]),
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { checkRateLimit, clientKeyFromHeaders, MAX_IN_FLIGHT_INSPECTIONS, RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MS, tryAcquireInspection } from "./admission";
|
||||||
|
|
||||||
|
describe("inspection admission", () => {
|
||||||
|
it("rejects work beyond the bounded in-flight budget", () => {
|
||||||
|
const releases = Array.from({ length: MAX_IN_FLIGHT_INSPECTIONS }, () => tryAcquireInspection());
|
||||||
|
|
||||||
|
expect(releases.every(Boolean)).toBe(true);
|
||||||
|
expect(tryAcquireInspection()).toBeNull();
|
||||||
|
|
||||||
|
releases.forEach((release) => release?.());
|
||||||
|
const release = tryAcquireInspection();
|
||||||
|
expect(release).not.toBeNull();
|
||||||
|
release?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("releases capacity at most once", () => {
|
||||||
|
const release = tryAcquireInspection();
|
||||||
|
expect(release).not.toBeNull();
|
||||||
|
release?.();
|
||||||
|
release?.();
|
||||||
|
|
||||||
|
const releases = Array.from({ length: MAX_IN_FLIGHT_INSPECTIONS }, () => tryAcquireInspection());
|
||||||
|
expect(releases.every(Boolean)).toBe(true);
|
||||||
|
releases.forEach((item) => item?.());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a bounded per-client sliding-window limit", () => {
|
||||||
|
const key = "test-client-" + Math.random();
|
||||||
|
const now = 1_000_000;
|
||||||
|
for (let index = 0; index < RATE_LIMIT_MAX_REQUESTS; index += 1) {
|
||||||
|
expect(checkRateLimit(key, now + index)).toMatchObject({ allowed: true });
|
||||||
|
}
|
||||||
|
expect(checkRateLimit(key, now + RATE_LIMIT_MAX_REQUESTS)).toMatchObject({ allowed: false });
|
||||||
|
expect(checkRateLimit(key, now + RATE_LIMIT_WINDOW_MS + 1)).toMatchObject({ allowed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not trust forwarded client identity unless proxy trust is enabled", () => {
|
||||||
|
const headers = { get: (name: string) => name === "x-forwarded-for" ? "198.51.100.10, 10.0.0.1" : null };
|
||||||
|
const previous = process.env.CERTSCOPE_TRUST_PROXY;
|
||||||
|
delete process.env.CERTSCOPE_TRUST_PROXY;
|
||||||
|
expect(clientKeyFromHeaders(headers)).toBe("direct");
|
||||||
|
process.env.CERTSCOPE_TRUST_PROXY = "true";
|
||||||
|
expect(clientKeyFromHeaders(headers)).toBe("proxy:198.51.100.10");
|
||||||
|
if (previous === undefined) delete process.env.CERTSCOPE_TRUST_PROXY;
|
||||||
|
else process.env.CERTSCOPE_TRUST_PROXY = previous;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
export const MAX_IN_FLIGHT_INSPECTIONS = 4;
|
||||||
|
export const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||||
|
export const RATE_LIMIT_MAX_REQUESTS = 30;
|
||||||
|
const MAX_TRACKED_CLIENTS = 1_024;
|
||||||
|
|
||||||
|
let inFlight = 0;
|
||||||
|
const requestWindows = new Map<string, number[]>();
|
||||||
|
|
||||||
|
export function tryAcquireInspection() {
|
||||||
|
if (inFlight >= MAX_IN_FLIGHT_INSPECTIONS) return null;
|
||||||
|
|
||||||
|
inFlight += 1;
|
||||||
|
let released = false;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
inFlight = Math.max(0, inFlight - 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientKeyFromHeaders(headers: { get(name: string): string | null }) {
|
||||||
|
if (process.env.CERTSCOPE_TRUST_PROXY !== "true") return "direct";
|
||||||
|
const forwarded = headers.get("x-forwarded-for") ?? headers.get("x-real-ip") ?? "unknown";
|
||||||
|
const client = forwarded.split(",", 1)[0]?.trim().slice(0, 128) || "unknown";
|
||||||
|
return "proxy:" + client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkRateLimit(key: string, now = Date.now()) {
|
||||||
|
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||||
|
const previous = requestWindows.get(key) ?? [];
|
||||||
|
const recent = previous.filter((timestamp) => timestamp > windowStart);
|
||||||
|
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
const retryAfterMs = Math.max(1_000, (recent[0] ?? now) + RATE_LIMIT_WINDOW_MS - now);
|
||||||
|
requestWindows.set(key, recent);
|
||||||
|
return { allowed: false, retryAfterSeconds: Math.ceil(retryAfterMs / 1_000) };
|
||||||
|
}
|
||||||
|
recent.push(now);
|
||||||
|
requestWindows.set(key, recent);
|
||||||
|
if (requestWindows.size > MAX_TRACKED_CLIENTS) {
|
||||||
|
for (const [client, timestamps] of requestWindows) {
|
||||||
|
if (!timestamps.some((timestamp) => timestamp > windowStart)) requestWindows.delete(client);
|
||||||
|
if (requestWindows.size <= MAX_TRACKED_CLIENTS) break;
|
||||||
|
}
|
||||||
|
if (requestWindows.size > MAX_TRACKED_CLIENTS) {
|
||||||
|
const oldestClient = requestWindows.keys().next().value;
|
||||||
|
if (typeof oldestClient === "string" && oldestClient !== key) requestWindows.delete(oldestClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export const DEADLINE_EXPIRED_CODE = "ETIMEOUT";
|
||||||
|
|
||||||
|
export function createDeadline(budgetMs: number, now = Date.now()) {
|
||||||
|
return now + Math.max(0, budgetMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remainingMs(deadline: number, now = Date.now()) {
|
||||||
|
return Math.max(0, deadline - now);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeoutError(message: string) {
|
||||||
|
const error = new Error(message) as Error & { code: string };
|
||||||
|
error.code = DEADLINE_EXPIRED_CODE;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withDeadline<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
deadline: number,
|
||||||
|
onTimeout: () => void,
|
||||||
|
message: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const remaining = remainingMs(deadline);
|
||||||
|
if (!remaining) {
|
||||||
|
onTimeout();
|
||||||
|
throw timeoutError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<T>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
onTimeout();
|
||||||
|
reject(timeoutError(message));
|
||||||
|
}, remaining);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([operation, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { InspectionResult } from "./types";
|
||||||
|
|
||||||
|
export const demoInspection: InspectionResult = {
|
||||||
|
host: "portfolio-demo.invalid",
|
||||||
|
inspectedAt: "2026-09-09T16:00:00.000Z",
|
||||||
|
addresses: ["203.0.113.42"],
|
||||||
|
completeness: {
|
||||||
|
status: "complete",
|
||||||
|
complete: 3,
|
||||||
|
total: 3,
|
||||||
|
collectors: { dns: "ok", tls: "ok", http: "ok" },
|
||||||
|
issues: [],
|
||||||
|
},
|
||||||
|
dns: {
|
||||||
|
addresses: ["203.0.113.42"],
|
||||||
|
families: {
|
||||||
|
ipv4: { addresses: ["203.0.113.42"], status: "ok", error: null },
|
||||||
|
ipv6: { addresses: [], status: "absent", error: null },
|
||||||
|
},
|
||||||
|
a: ["203.0.113.42"],
|
||||||
|
aaaa: [],
|
||||||
|
mx: [{ exchange: "mail.portfolio-demo.invalid", priority: 10 }],
|
||||||
|
ns: ["ns1.example.net", "ns2.example.net"],
|
||||||
|
txt: ["v=spf1 include:_spf.example.net -all"],
|
||||||
|
spf: ["v=spf1 include:_spf.example.net -all"],
|
||||||
|
dmarc: ["v=DMARC1; p=none; rua=mailto:dmarc@example.net"],
|
||||||
|
spfPosture: { status: "present", reason: "One SPF record was observed." },
|
||||||
|
dmarcPosture: { status: "monitor", policy: "none", pct: 100, record: "v=DMARC1; p=none; rua=mailto:dmarc@example.net", reason: "p=none monitors failures without enforcement." },
|
||||||
|
_meta: { status: "ok", partial: false, errors: [] },
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
address: "203.0.113.42",
|
||||||
|
protocol: "TLSv1.3",
|
||||||
|
authorized: true,
|
||||||
|
authorizationError: null,
|
||||||
|
hostnameValid: true,
|
||||||
|
hostnameError: null,
|
||||||
|
subject: { CN: "portfolio-demo.invalid" },
|
||||||
|
issuer: { O: "Example Trust Services", CN: "Example RSA CA" },
|
||||||
|
subjectaltname: "DNS:portfolio-demo.invalid",
|
||||||
|
validFrom: "Sep 01 00:00:00 2026 GMT",
|
||||||
|
validTo: "Dec 01 23:59:59 2026 GMT",
|
||||||
|
daysRemaining: 83,
|
||||||
|
fingerprint256: "2A:8D:DE:MO:FI:XT:UR:E0",
|
||||||
|
_meta: { status: "ok", errors: [] },
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
http: { url: "http://portfolio-demo.invalid/", address: "203.0.113.42", addresses: ["203.0.113.42"], status: 301, location: "https://portfolio-demo.invalid/" },
|
||||||
|
httpRedirectsToHttps: true,
|
||||||
|
httpRedirectTarget: { url: "https://portfolio-demo.invalid/", addresses: ["203.0.113.42"], validated: true, resolution: { status: "ok", partial: false, errors: [] } },
|
||||||
|
httpsChain: [{
|
||||||
|
url: "https://portfolio-demo.invalid/",
|
||||||
|
address: "203.0.113.42",
|
||||||
|
addresses: ["203.0.113.42"],
|
||||||
|
status: 200,
|
||||||
|
location: null,
|
||||||
|
headers: {
|
||||||
|
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||||
|
"content-security-policy": null,
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"referrer-policy": "strict-origin-when-cross-origin",
|
||||||
|
"x-frame-options": "DENY",
|
||||||
|
server: "nginx",
|
||||||
|
"x-powered-by": null,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
finalUrl: "https://portfolio-demo.invalid/",
|
||||||
|
_meta: { status: "ok", errors: [] },
|
||||||
|
},
|
||||||
|
findings: [
|
||||||
|
{
|
||||||
|
id: "http.csp_missing",
|
||||||
|
category: "http",
|
||||||
|
severity: "warning",
|
||||||
|
title: "Content-Security-Policy is missing",
|
||||||
|
summary: "No CSP header was observed on the final HTTPS response.",
|
||||||
|
evidence: ["http.httpsChain[].headers.content-security-policy"],
|
||||||
|
remediation: "Deploy a tested CSP appropriate for the application.",
|
||||||
|
scoreImpact: -6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.dmarc_monitor",
|
||||||
|
category: "dns",
|
||||||
|
severity: "info",
|
||||||
|
title: "DMARC is not fully enforcing",
|
||||||
|
summary: "DMARC uses p=none, so failing mail is monitored rather than rejected or quarantined.",
|
||||||
|
evidence: ["dns.dmarc"],
|
||||||
|
remediation: "Move to quarantine or reject after validating legitimate mail sources.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
score: { overall: 98, deductions: 2, categories: { dns: 100, tls: 100, http: 94 } },
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DIAGNOSTIC_RULES, evaluateDiagnostics } from "./diagnostics";
|
||||||
|
|
||||||
|
const okMeta: Record<string, unknown> = { status: "ok", errors: [] };
|
||||||
|
const baseDns: Record<string, unknown> = { mx: [], spf: [], dmarc: [], _meta: okMeta };
|
||||||
|
const baseTls: Record<string, unknown> = { authorized: true, hostnameValid: true, daysRemaining: 90, _meta: okMeta };
|
||||||
|
const secureHeaders: Record<string, string> = {
|
||||||
|
"strict-transport-security": "max-age=31536000; includeSubDomains",
|
||||||
|
"content-security-policy": "default-src 'self'; object-src 'none'; base-uri 'self'",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"referrer-policy": "strict-origin-when-cross-origin",
|
||||||
|
"x-frame-options": "DENY",
|
||||||
|
};
|
||||||
|
const baseHttp: Record<string, unknown> = { http: { status: 301 }, httpRedirectsToHttps: true, httpsChain: [{ status: 200, headers: secureHeaders }], _meta: okMeta };
|
||||||
|
|
||||||
|
function ids(dns: Record<string, unknown> = baseDns, tls: Record<string, unknown> = baseTls, http: Record<string, unknown> = baseHttp) {
|
||||||
|
return evaluateDiagnostics(dns, tls, http).map((finding) => finding.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("diagnostics", () => {
|
||||||
|
it("has unique stable rule identifiers", () => {
|
||||||
|
const ids = DIAGNOSTIC_RULES.map((rule) => rule.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds mail and HTTP posture gaps deterministically", () => {
|
||||||
|
const found = ids(
|
||||||
|
{ mx: [{ exchange: "mail.example.com" }], spf: [], dmarc: [], _meta: okMeta },
|
||||||
|
baseTls,
|
||||||
|
{ http: { status: 200 }, httpRedirectsToHttps: false, httpsChain: [{ status: 200, headers: { "strict-transport-security": null, "content-security-policy": null, "x-content-type-options": null } }], _meta: okMeta },
|
||||||
|
);
|
||||||
|
expect(found).toEqual([
|
||||||
|
"http.hsts_missing",
|
||||||
|
"http.csp_missing",
|
||||||
|
"http.redirect",
|
||||||
|
"dns.dmarc_missing",
|
||||||
|
"dns.spf_missing",
|
||||||
|
"http.frame_protection",
|
||||||
|
"http.nosniff_missing",
|
||||||
|
"http.referrer_policy_missing",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps partial collectors informational and does not deduct missing evidence", () => {
|
||||||
|
const findings = evaluateDiagnostics(
|
||||||
|
{ mx: [{ exchange: "mail.example.com" }], spf: [], dmarc: [], _meta: { status: "partial", errors: [{ probe: "TXT", message: "timeout" }, { probe: "DMARC", message: "timeout" }] } },
|
||||||
|
{ authorized: true, hostnameValid: true, daysRemaining: 90, _meta: okMeta },
|
||||||
|
{ http: { error: "timeout" }, httpRedirectsToHttps: false, httpsChain: [{ error: "timeout" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "timeout" }] } },
|
||||||
|
);
|
||||||
|
expect(findings.map((finding) => finding.id)).toEqual(["collector.dns_partial", "collector.http_partial"]);
|
||||||
|
expect(findings.every((finding) => finding.scoreImpact === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not score headers when the HTTPS chain has no terminal response", () => {
|
||||||
|
expect(ids(baseDns, baseTls, { http: { status: 301 }, httpRedirectsToHttps: true, httpsChain: [{ status: 302, location: "/next", headers: {} }, { url: "/next", error: "redirect failed" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "redirect failed" }] } })).toEqual(["collector.http_partial"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates the generic TLS verification penalty when specific causes exist", () => {
|
||||||
|
const found = ids(baseDns, { authorized: false, authorizationError: "CERT_HAS_EXPIRED", hostnameValid: false, hostnameError: "hostname mismatch", daysRemaining: -2, _meta: okMeta }, baseHttp);
|
||||||
|
expect(found).toContain("tls.expired");
|
||||||
|
expect(found).toContain("tls.hostname");
|
||||||
|
expect(found).not.toContain("tls.verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an untrusted issuer when no more specific TLS cause is present", () => {
|
||||||
|
expect(ids(baseDns, { authorized: false, authorizationError: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", hostnameValid: true, daysRemaining: 90, _meta: okMeta }, baseHttp)).toContain("tls.verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports malformed certificate validity dates without adding generic trust noise", () => {
|
||||||
|
const found = ids(baseDns, { authorized: false, authorizationError: "CERT_INVALID", hostnameValid: true, validityDatesValid: false, _meta: okMeta }, baseHttp);
|
||||||
|
expect(found).toContain("tls.validity_dates");
|
||||||
|
expect(found).not.toContain("tls.verification");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not score expiry states from an incoherent certificate window", () => {
|
||||||
|
const found = ids(baseDns, {
|
||||||
|
authorized: true,
|
||||||
|
hostnameValid: true,
|
||||||
|
validFrom: "Jul 02 00:00:00 2026 GMT",
|
||||||
|
validTo: "Jul 01 00:00:00 2026 GMT",
|
||||||
|
validityDatesValid: false,
|
||||||
|
notYetValid: true,
|
||||||
|
daysRemaining: -1,
|
||||||
|
_meta: okMeta,
|
||||||
|
}, baseHttp);
|
||||||
|
expect(found).toContain("tls.validity_dates");
|
||||||
|
expect(found).not.toContain("tls.expired");
|
||||||
|
expect(found).not.toContain("tls.not_yet_valid");
|
||||||
|
expect(found).not.toContain("tls.expiry_critical");
|
||||||
|
expect(found).not.toContain("tls.expiry_warning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports HTTPS downgrades and loops as findings", () => {
|
||||||
|
const downgrade = ids(baseDns, baseTls, { ...baseHttp, httpsDowngrade: true, httpsChain: [{ status: 302, location: "http://example.com/", headers: secureHeaders }, { error: "HTTPS redirect downgrade is not allowed.", code: "HTTPS_DOWNGRADE" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "downgrade" }] } });
|
||||||
|
expect(downgrade).toContain("http.https_downgrade");
|
||||||
|
const loop = ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 302, location: "/", headers: secureHeaders }, { error: "Redirect loop detected.", code: "REDIRECT_LOOP" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "loop" }] } });
|
||||||
|
expect(loop).toContain("http.redirect_loop");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces redirects rejected by the target policy", () => {
|
||||||
|
expect(ids(baseDns, baseTls, {
|
||||||
|
...baseHttp,
|
||||||
|
httpsChain: [{ status: 302, location: "http://127.0.0.1/", headers: secureHeaders }, { code: "REDIRECT_TARGET_BLOCKED", error: "Target rejected" }],
|
||||||
|
_meta: { status: "partial", errors: [{ probe: "HTTPS", message: "target blocked" }] },
|
||||||
|
})).toContain("http.redirect_blocked");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies terminal HTTP status without using a green status", () => {
|
||||||
|
expect(ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 404, headers: secureHeaders }] })).toContain("http.terminal_client_error");
|
||||||
|
expect(ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 503, headers: secureHeaders }] })).toContain("http.terminal_server_error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates HSTS, CSP and nosniff values rather than presence alone", () => {
|
||||||
|
const findings = ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 200, headers: { ...secureHeaders, "strict-transport-security": "max-age=0", "content-security-policy": "default-src * 'unsafe-eval'", "x-content-type-options": "text/plain" } }] });
|
||||||
|
expect(findings).toContain("http.hsts_disabled");
|
||||||
|
expect(findings).toContain("http.csp_weak");
|
||||||
|
expect(findings).toContain("http.nosniff_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require mail authentication for an explicit null MX", () => {
|
||||||
|
const findings = ids({ mx: [{ exchange: ".", priority: 0 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp);
|
||||||
|
expect(findings).not.toContain("dns.spf_missing");
|
||||||
|
expect(findings).not.toContain("dns.dmarc_missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports multiple SPF records as a distinct configuration error", () => {
|
||||||
|
expect(ids({ mx: [{ exchange: "mail.example.com" }], spf: ["v=spf1 -all", "v=spf1 ~all"], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).toContain("dns.spf_multiple");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports malformed MX answers while preserving null-MX semantics", () => {
|
||||||
|
expect(ids({ mx: [{ exchange: "", priority: 10 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).toContain("dns.mx_invalid");
|
||||||
|
expect(ids({ mx: [{ exchange: "", priority: 0 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).not.toContain("dns.mx_invalid");
|
||||||
|
expect(ids({ mx: [{ exchange: ".", priority: 0 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).not.toContain("dns.mx_invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes DMARC pct=0 as non-enforcing without mislabeling the record invalid", () => {
|
||||||
|
const findings = ids({ mx: [{ exchange: "mail.example.com" }], spf: ["v=spf1 -all"], dmarc: ["v=DMARC1; p=reject; pct=0"], _meta: okMeta }, baseTls, baseHttp);
|
||||||
|
expect(findings).toContain("dns.dmarc_monitor");
|
||||||
|
expect(findings).not.toContain("dns.dmarc_invalid");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import { parseDmarcRecord, summarizeSpf } from "./dns";
|
||||||
|
import { analyzeHeaders, type HeaderMap } from "./headers";
|
||||||
|
import type { Finding } from "./types";
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
dns: Record<string, unknown>;
|
||||||
|
tls: Record<string, unknown>;
|
||||||
|
http: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
type ProbeError = { probe?: string; message?: string };
|
||||||
|
|
||||||
|
export type DiagnosticRule = { id: string; evaluate: (context: Context) => Finding | null };
|
||||||
|
|
||||||
|
const meta = (value: Record<string, unknown>) => value._meta as Record<string, unknown> | undefined;
|
||||||
|
const metaStatus = (value: Record<string, unknown>) => {
|
||||||
|
const status = meta(value)?.status;
|
||||||
|
return status === "ok" || status === "partial" || status === "failed" ? status : "failed";
|
||||||
|
};
|
||||||
|
const probeFailed = (value: Record<string, unknown>, probe: string) => {
|
||||||
|
const errors = meta(value)?.errors;
|
||||||
|
return Array.isArray(errors) && errors.some((error) =>
|
||||||
|
typeof error === "object" && error !== null &&
|
||||||
|
String((error as ProbeError).probe ?? "").toUpperCase() === probe.toUpperCase(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const finiteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
||||||
|
const httpStatus = (value: unknown): value is number => finiteNumber(value) && Number.isInteger(value) && value >= 100 && value <= 599;
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
|
||||||
|
const hasMailService = (dns: Record<string, unknown>) => Array.isArray(dns.mx) && dns.mx.some((record) => {
|
||||||
|
if (!isRecord(record)) return false;
|
||||||
|
const exchange = record.exchange;
|
||||||
|
const normalizedExchange = typeof exchange === "string" ? exchange.trim() : "";
|
||||||
|
return normalizedExchange !== "" && normalizedExchange !== ".";
|
||||||
|
});
|
||||||
|
const hasInvalidMx = (dns: Record<string, unknown>) => Array.isArray(dns.mx) && dns.mx.some((record) => {
|
||||||
|
if (!isRecord(record) || typeof record.exchange !== "string") return true;
|
||||||
|
const exchange = record.exchange.trim();
|
||||||
|
if (!exchange || exchange === ".") return record.priority !== 0;
|
||||||
|
return record.priority !== undefined && (!finiteNumber(record.priority) || !Number.isInteger(record.priority) || record.priority < 0 || record.priority > 65_535);
|
||||||
|
});
|
||||||
|
const isSpfRecord = (value: unknown) => typeof value === "string" && value.trimStart().toLowerCase().startsWith("v=spf1");
|
||||||
|
|
||||||
|
const plainHttpResult = (http: Record<string, unknown>) => {
|
||||||
|
const result = http.http;
|
||||||
|
return isRecord(result) && !("error" in result) ? result : null;
|
||||||
|
};
|
||||||
|
const terminalHttpsResult = (http: Record<string, unknown>) => {
|
||||||
|
const chain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : [];
|
||||||
|
const last = chain.at(-1);
|
||||||
|
return last && !("error" in last) ? last : null;
|
||||||
|
};
|
||||||
|
const terminalHeaders = (http: Record<string, unknown>) => {
|
||||||
|
const terminal = terminalHttpsResult(http);
|
||||||
|
if (!terminal || !isRecord(terminal.headers)) return null;
|
||||||
|
return terminal.headers as HeaderMap;
|
||||||
|
};
|
||||||
|
const hasChainCode = (http: Record<string, unknown>, code: string) =>
|
||||||
|
Array.isArray(http.httpsChain) && http.httpsChain.some((hop) => isRecord(hop) && hop.code === code);
|
||||||
|
const hasBlockedRedirect = (http: Record<string, unknown>) =>
|
||||||
|
hasChainCode(http, "REDIRECT_TARGET_BLOCKED") || hasChainCode(http, "INVALID_REDIRECT") ||
|
||||||
|
(isRecord(http.httpRedirectTarget) && (http.httpRedirectTarget.code === "REDIRECT_TARGET_BLOCKED" || http.httpRedirectTarget.code === "INVALID_REDIRECT"));
|
||||||
|
const tlsHasError = (tls: Record<string, unknown>, code: string) => String(tls.authorizationError ?? "").toUpperCase().includes(code);
|
||||||
|
|
||||||
|
export const DIAGNOSTIC_RULES: DiagnosticRule[] = [
|
||||||
|
{
|
||||||
|
id: "collector.dns_partial",
|
||||||
|
evaluate: ({ dns }) => metaStatus(dns) !== "ok" ? {
|
||||||
|
id: "collector.dns_partial", category: "dns", severity: "info",
|
||||||
|
title: "Some DNS probes were incomplete",
|
||||||
|
summary: "At least one DNS record-type query failed; available evidence is still shown.",
|
||||||
|
evidence: ["dns._meta"],
|
||||||
|
remediation: "Retry later or compare against an authoritative resolver before acting on missing records.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collector.tls_partial",
|
||||||
|
evaluate: ({ tls }) => metaStatus(tls) !== "ok" ? {
|
||||||
|
id: "collector.tls_partial", category: "tls", severity: "info",
|
||||||
|
title: "TLS probe was incomplete",
|
||||||
|
summary: "The TLS connection did not produce a complete certificate result; no certificate deduction is applied.",
|
||||||
|
evidence: ["tls._meta"],
|
||||||
|
remediation: "Retry from the expected network path and inspect the TLS probe evidence.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "collector.http_partial",
|
||||||
|
evaluate: ({ http }) => metaStatus(http) !== "ok" ? {
|
||||||
|
id: "collector.http_partial", category: "http", severity: "info",
|
||||||
|
title: "Some HTTP probes were incomplete",
|
||||||
|
summary: "One or more HTTP/HTTPS requests or redirect hops failed; successful evidence is preserved.",
|
||||||
|
evidence: ["http._meta"],
|
||||||
|
remediation: "Inspect the failed hop and retry from the expected network path.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.verification",
|
||||||
|
evaluate: ({ tls }) => tls.authorized === false &&
|
||||||
|
tls.hostnameValid !== false &&
|
||||||
|
tls.notYetValid !== true &&
|
||||||
|
tls.validityDatesValid !== false &&
|
||||||
|
!(finiteNumber(tls.daysRemaining) && tls.daysRemaining < 0) &&
|
||||||
|
!tlsHasError(tls, "CERT_HAS_EXPIRED") ? {
|
||||||
|
id: "tls.verification", category: "tls", severity: "critical",
|
||||||
|
title: "TLS trust verification failed",
|
||||||
|
summary: String(tls.authorizationError ?? "The certificate chain could not be verified."),
|
||||||
|
evidence: ["tls.authorized", "tls.authorizationError"],
|
||||||
|
remediation: "Verify the certificate chain and issuer configuration.",
|
||||||
|
scoreImpact: -30,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.validity_dates",
|
||||||
|
evaluate: ({ tls }) => metaStatus(tls) === "ok" && tls.validityDatesValid === false ? {
|
||||||
|
id: "tls.validity_dates", category: "tls", severity: "warning",
|
||||||
|
title: "Certificate validity dates are malformed",
|
||||||
|
summary: "The peer certificate did not expose a coherent valid-from/valid-to window.",
|
||||||
|
evidence: ["tls.validFrom", "tls.validTo", "tls.validityDatesValid"],
|
||||||
|
remediation: "Serve a certificate with parseable validity dates and a valid chronological window.",
|
||||||
|
scoreImpact: -12,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.hostname",
|
||||||
|
evaluate: ({ tls }) => tls.hostnameValid === false ? {
|
||||||
|
id: "tls.hostname", category: "tls", severity: "critical",
|
||||||
|
title: "Certificate hostname mismatch",
|
||||||
|
summary: String(tls.hostnameError ?? "The certificate does not match the requested hostname."),
|
||||||
|
evidence: ["tls.hostnameValid", "tls.subjectaltname"],
|
||||||
|
remediation: "Issue a certificate whose SANs cover the hostname.",
|
||||||
|
scoreImpact: -25,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.expired",
|
||||||
|
evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining < 0 ? {
|
||||||
|
id: "tls.expired", category: "tls", severity: "critical",
|
||||||
|
title: "Certificate has expired",
|
||||||
|
summary: "The certificate expired " + Math.abs(tls.daysRemaining) + " day(s) ago.",
|
||||||
|
evidence: ["tls.daysRemaining", "tls.validTo"],
|
||||||
|
remediation: "Renew and deploy a valid certificate immediately.",
|
||||||
|
scoreImpact: -35,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.not_yet_valid",
|
||||||
|
evaluate: ({ tls }) => tls.validityDatesValid !== false && tls.notYetValid === true ? {
|
||||||
|
id: "tls.not_yet_valid", category: "tls", severity: "critical",
|
||||||
|
title: "Certificate is not valid yet",
|
||||||
|
summary: "The certificate valid-from date is in the future.",
|
||||||
|
evidence: ["tls.validFrom", "tls.notYetValid"],
|
||||||
|
remediation: "Deploy a certificate whose validity window includes the current time.",
|
||||||
|
scoreImpact: -20,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.expiry_critical",
|
||||||
|
evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining >= 0 && tls.daysRemaining <= 14 ? {
|
||||||
|
id: "tls.expiry_critical", category: "tls", severity: "critical",
|
||||||
|
title: "Certificate expires very soon",
|
||||||
|
summary: "Only " + tls.daysRemaining + " day(s) of certificate validity remain.",
|
||||||
|
evidence: ["tls.daysRemaining", "tls.validTo"],
|
||||||
|
remediation: "Renew and deploy the replacement certificate now.",
|
||||||
|
scoreImpact: -15,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tls.expiry_warning",
|
||||||
|
evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining > 14 && tls.daysRemaining <= 30 ? {
|
||||||
|
id: "tls.expiry_warning", category: "tls", severity: "warning",
|
||||||
|
title: "Certificate expires soon",
|
||||||
|
summary: tls.daysRemaining + " day(s) of certificate validity remain.",
|
||||||
|
evidence: ["tls.daysRemaining", "tls.validTo"],
|
||||||
|
remediation: "Schedule certificate renewal.",
|
||||||
|
scoreImpact: -8,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.https_downgrade",
|
||||||
|
evaluate: ({ http }) => http.httpsDowngrade === true || hasChainCode(http, "HTTPS_DOWNGRADE") ? {
|
||||||
|
id: "http.https_downgrade", category: "http", severity: "critical",
|
||||||
|
title: "HTTPS redirect downgrades to HTTP",
|
||||||
|
summary: "The HTTPS redirect chain attempted to leave HTTPS; the downgraded response was not treated as a secure terminal result.",
|
||||||
|
evidence: ["http.httpsChain[].code", "http.httpsChain[].url"],
|
||||||
|
remediation: "Keep every redirect in the HTTPS scheme and remove the downgrade hop.",
|
||||||
|
scoreImpact: -15,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.redirect_loop",
|
||||||
|
evaluate: ({ http }) => hasChainCode(http, "REDIRECT_LOOP") ? {
|
||||||
|
id: "http.redirect_loop", category: "http", severity: "warning",
|
||||||
|
title: "HTTPS redirect loop detected",
|
||||||
|
summary: "The normalized redirect chain revisited a URL and was stopped safely.",
|
||||||
|
evidence: ["http.httpsChain[].code", "http.httpsChain[].url"],
|
||||||
|
remediation: "Remove the redirect cycle and leave one canonical HTTPS destination.",
|
||||||
|
scoreImpact: -8,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.redirect_blocked",
|
||||||
|
evaluate: ({ http }) => hasBlockedRedirect(http) ? {
|
||||||
|
id: "http.redirect_blocked", category: "http", severity: "warning",
|
||||||
|
title: "Redirect target was blocked by policy",
|
||||||
|
summary: "A redirect target did not satisfy the public-host, scheme or port policy; no request was made to that target.",
|
||||||
|
evidence: ["http.httpsChain[].code", "http.httpRedirectTarget.code"],
|
||||||
|
remediation: "Keep redirects on public hostnames and default HTTP(S) ports without credentials.",
|
||||||
|
scoreImpact: -8,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.redirect_limit",
|
||||||
|
evaluate: ({ http }) => hasChainCode(http, "REDIRECT_LIMIT") ? {
|
||||||
|
id: "http.redirect_limit", category: "http", severity: "info",
|
||||||
|
title: "HTTPS redirect limit reached",
|
||||||
|
summary: "The redirect chain was bounded before a terminal response was observed.",
|
||||||
|
evidence: ["http.httpsChain[].code"],
|
||||||
|
remediation: "Reduce redirect hops so the canonical HTTPS response is reached directly.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.redirect",
|
||||||
|
evaluate: ({ http }) => plainHttpResult(http) && http.httpRedirectsToHttps === false ? {
|
||||||
|
id: "http.redirect", category: "http", severity: "warning",
|
||||||
|
title: "HTTP does not redirect directly to HTTPS",
|
||||||
|
summary: "The port 80 response did not advertise a validated HTTPS redirect.",
|
||||||
|
evidence: ["http.httpRedirectsToHttps", "http.http.location"],
|
||||||
|
remediation: "Redirect plain HTTP requests to the HTTPS origin.",
|
||||||
|
scoreImpact: -5,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.terminal_client_error",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const terminal = terminalHttpsResult(http);
|
||||||
|
const status = terminal?.status;
|
||||||
|
return httpStatus(status) && status >= 400 && status < 500 ? {
|
||||||
|
id: "http.terminal_client_error", category: "http", severity: "warning",
|
||||||
|
title: "HTTPS response returned a client error",
|
||||||
|
summary: "The terminal HTTPS response returned HTTP " + status + ".",
|
||||||
|
evidence: ["http.httpsChain[].status"],
|
||||||
|
remediation: "Confirm that the inspected hostname serves the intended resource and status.",
|
||||||
|
scoreImpact: -4,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.terminal_server_error",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const terminal = terminalHttpsResult(http);
|
||||||
|
const status = terminal?.status;
|
||||||
|
return httpStatus(status) && status >= 500 ? {
|
||||||
|
id: "http.terminal_server_error", category: "http", severity: "critical",
|
||||||
|
title: "HTTPS response returned a server error",
|
||||||
|
summary: "The terminal HTTPS response returned HTTP " + status + ".",
|
||||||
|
evidence: ["http.httpsChain[].status"],
|
||||||
|
remediation: "Investigate the origin service and restore a healthy HTTPS response.",
|
||||||
|
scoreImpact: -10,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.hsts_missing",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).hsts : null;
|
||||||
|
return posture?.status === "missing" ? {
|
||||||
|
id: "http.hsts_missing", category: "http", severity: "warning",
|
||||||
|
title: "HSTS header is missing",
|
||||||
|
summary: "The final HTTPS response does not advertise HTTP Strict Transport Security.",
|
||||||
|
evidence: ["http.httpsChain[].headers.strict-transport-security"],
|
||||||
|
remediation: "Enable HSTS after confirming HTTPS is correct for the site.",
|
||||||
|
scoreImpact: -8,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.hsts_invalid",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).hsts : null;
|
||||||
|
return posture?.status === "invalid" ? {
|
||||||
|
id: "http.hsts_invalid", category: "http", severity: "warning",
|
||||||
|
title: "HSTS policy is invalid",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.strict-transport-security"],
|
||||||
|
remediation: "Publish one HSTS header with a non-negative numeric max-age.",
|
||||||
|
scoreImpact: -6,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.hsts_disabled",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).hsts : null;
|
||||||
|
return posture?.status === "disabled" ? {
|
||||||
|
id: "http.hsts_disabled", category: "http", severity: "warning",
|
||||||
|
title: "HSTS is disabled",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.strict-transport-security"],
|
||||||
|
remediation: "Use a positive HSTS max-age after confirming HTTPS coverage.",
|
||||||
|
scoreImpact: -8,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.hsts_weak",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).hsts : null;
|
||||||
|
return posture?.status === "weak" || posture?.status === "invalid" ? {
|
||||||
|
id: "http.hsts_weak", category: "http", severity: "warning",
|
||||||
|
title: "HSTS policy is short-lived",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.strict-transport-security"],
|
||||||
|
remediation: "After validating HTTPS coverage, use a long-lived HSTS max-age.",
|
||||||
|
scoreImpact: -4,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.csp_missing",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).csp : null;
|
||||||
|
return posture?.status === "missing" ? {
|
||||||
|
id: "http.csp_missing", category: "http", severity: "warning",
|
||||||
|
title: "Content-Security-Policy is missing",
|
||||||
|
summary: "No CSP header was observed on the final HTTPS response.",
|
||||||
|
evidence: ["http.httpsChain[].headers.content-security-policy"],
|
||||||
|
remediation: "Deploy a tested CSP appropriate for the application.",
|
||||||
|
scoreImpact: -6,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.csp_weak",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).csp : null;
|
||||||
|
return posture?.status === "weak" ? {
|
||||||
|
id: "http.csp_weak", category: "http", severity: "warning",
|
||||||
|
title: "Content-Security-Policy is weak",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.content-security-policy"],
|
||||||
|
remediation: "Replace broad sources and unsafe directives with a tested restrictive policy.",
|
||||||
|
scoreImpact: -4,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.nosniff_missing",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const hasHeader = headers && headers["x-content-type-options"] !== null && headers["x-content-type-options"] !== undefined && headers["x-content-type-options"] !== "";
|
||||||
|
return headers && !hasHeader ? {
|
||||||
|
id: "http.nosniff_missing", category: "http", severity: "warning",
|
||||||
|
title: "X-Content-Type-Options is missing",
|
||||||
|
summary: "The final HTTPS response does not set nosniff protection.",
|
||||||
|
evidence: ["http.httpsChain[].headers.x-content-type-options"],
|
||||||
|
remediation: "Set X-Content-Type-Options: nosniff.",
|
||||||
|
scoreImpact: -3,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.nosniff_invalid",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const value = headers?.["x-content-type-options"];
|
||||||
|
const present = value !== null && value !== undefined && value !== "";
|
||||||
|
return headers && present && analyzeHeaders(headers).nosniff.status !== "strong" ? {
|
||||||
|
id: "http.nosniff_invalid", category: "http", severity: "warning",
|
||||||
|
title: "X-Content-Type-Options is invalid",
|
||||||
|
summary: "The header is present but does not have the required nosniff value.",
|
||||||
|
evidence: ["http.httpsChain[].headers.x-content-type-options"],
|
||||||
|
remediation: "Set the exact value X-Content-Type-Options: nosniff.",
|
||||||
|
scoreImpact: -3,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.referrer_policy",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).referrerPolicy : null;
|
||||||
|
return posture?.status === "weak" || posture?.status === "invalid" ? {
|
||||||
|
id: "http.referrer_policy", category: "http", severity: "warning",
|
||||||
|
title: "Referrer-Policy needs attention",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.referrer-policy"],
|
||||||
|
remediation: "Use a recognized restrictive policy such as strict-origin-when-cross-origin.",
|
||||||
|
scoreImpact: -2,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.referrer_policy_missing",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).referrerPolicy : null;
|
||||||
|
return posture?.status === "missing" ? {
|
||||||
|
id: "http.referrer_policy_missing", category: "http", severity: "warning",
|
||||||
|
title: "Referrer-Policy is missing",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.referrer-policy"],
|
||||||
|
remediation: "Set a restrictive Referrer-Policy such as strict-origin-when-cross-origin.",
|
||||||
|
scoreImpact: -2,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.frame_protection",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
const posture = headers ? analyzeHeaders(headers).frameProtection : null;
|
||||||
|
return posture?.status === "missing" || posture?.status === "weak" || posture?.status === "invalid" ? {
|
||||||
|
id: "http.frame_protection", category: "http", severity: "warning",
|
||||||
|
title: "Clickjacking protection needs attention",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["http.httpsChain[].headers.content-security-policy", "http.httpsChain[].headers.x-frame-options"],
|
||||||
|
remediation: "Use CSP frame-ancestors or X-Frame-Options: DENY/SAMEORIGIN.",
|
||||||
|
scoreImpact: -4,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.server_disclosure",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
return headers?.server ? {
|
||||||
|
id: "http.server_disclosure", category: "http", severity: "info",
|
||||||
|
title: "Server identity is disclosed",
|
||||||
|
summary: "The response exposes a Server header, which may help fingerprint the origin.",
|
||||||
|
evidence: ["http.httpsChain[].headers.server"],
|
||||||
|
remediation: "Remove unnecessary version detail from server identification where practical.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "http.powered_by_disclosure",
|
||||||
|
evaluate: ({ http }) => {
|
||||||
|
const headers = terminalHeaders(http);
|
||||||
|
return headers?.["x-powered-by"] ? {
|
||||||
|
id: "http.powered_by_disclosure", category: "http", severity: "info",
|
||||||
|
title: "X-Powered-By is disclosed",
|
||||||
|
summary: "The response exposes an implementation-identifying X-Powered-By header.",
|
||||||
|
evidence: ["http.httpsChain[].headers.x-powered-by"],
|
||||||
|
remediation: "Remove X-Powered-By unless the disclosure is intentional.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.spf_missing",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : [];
|
||||||
|
return hasMailService(dns) && !spf.length && !probeFailed(dns, "TXT") ? {
|
||||||
|
id: "dns.spf_missing", category: "dns", severity: "warning",
|
||||||
|
title: "Mail domain has no SPF record",
|
||||||
|
summary: "MX records exist but no v=spf1 TXT record was found.",
|
||||||
|
evidence: ["dns.mx", "dns.spf"],
|
||||||
|
remediation: "Publish one SPF record that covers all legitimate senders.",
|
||||||
|
scoreImpact: -4,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.spf_multiple",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : [];
|
||||||
|
return spf.length > 1 ? {
|
||||||
|
id: "dns.spf_multiple", category: "dns", severity: "critical",
|
||||||
|
title: "Multiple SPF records were found",
|
||||||
|
summary: "Multiple v=spf1 records are invalid because receivers cannot combine them reliably.",
|
||||||
|
evidence: ["dns.spf"],
|
||||||
|
remediation: "Merge the mechanisms into one SPF record and remove the duplicates.",
|
||||||
|
scoreImpact: -10,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.spf_permissive",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : [];
|
||||||
|
const posture = summarizeSpf(spf);
|
||||||
|
return posture.status === "permissive" ? {
|
||||||
|
id: "dns.spf_permissive", category: "dns", severity: "warning",
|
||||||
|
title: "SPF authorizes every sender",
|
||||||
|
summary: posture.reason,
|
||||||
|
evidence: ["dns.spf", "dns.spfPosture"],
|
||||||
|
remediation: "Replace +all with a sender policy that ends in -all after validating legitimate sources.",
|
||||||
|
scoreImpact: -5,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.dmarc_missing",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc : [];
|
||||||
|
return hasMailService(dns) && !dmarc.length && !probeFailed(dns, "DMARC") ? {
|
||||||
|
id: "dns.dmarc_missing", category: "dns", severity: "warning",
|
||||||
|
title: "Mail domain has no DMARC record",
|
||||||
|
summary: "MX records exist but no _dmarc TXT record was found.",
|
||||||
|
evidence: ["dns.mx", "dns.dmarc"],
|
||||||
|
remediation: "Publish a DMARC policy and validate legitimate senders first.",
|
||||||
|
scoreImpact: -4,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.dmarc_multiple",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc : [];
|
||||||
|
return dmarc.length > 1 ? {
|
||||||
|
id: "dns.dmarc_multiple", category: "dns", severity: "warning",
|
||||||
|
title: "Multiple DMARC records were found",
|
||||||
|
summary: "Multiple DMARC records make policy selection ambiguous.",
|
||||||
|
evidence: ["dns.dmarc"],
|
||||||
|
remediation: "Publish one authoritative DMARC record at _dmarc.",
|
||||||
|
scoreImpact: -6,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.dmarc_invalid",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc.filter((value): value is string => typeof value === "string") : [];
|
||||||
|
return dmarc.length === 1 && !parseDmarcRecord(dmarc[0]).valid ? {
|
||||||
|
id: "dns.dmarc_invalid", category: "dns", severity: "warning",
|
||||||
|
title: "DMARC record is invalid",
|
||||||
|
summary: "The DMARC record is malformed or does not contain a supported p= policy.",
|
||||||
|
evidence: ["dns.dmarc"],
|
||||||
|
remediation: "Correct the DMARC version, policy and tag syntax.",
|
||||||
|
scoreImpact: -6,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.dmarc_monitor",
|
||||||
|
evaluate: ({ dns }) => {
|
||||||
|
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc.filter((value): value is string => typeof value === "string") : [];
|
||||||
|
const parsed = dmarc.length === 1 ? parseDmarcRecord(dmarc[0]) : null;
|
||||||
|
return parsed && parsed.valid && (parsed.policy === "none" || parsed.pct === 0) ? {
|
||||||
|
id: "dns.dmarc_monitor", category: "dns", severity: "info",
|
||||||
|
title: "DMARC is not fully enforcing",
|
||||||
|
summary: parsed.policy === "none" ? "DMARC uses p=none, so failing mail is monitored rather than rejected or quarantined." : "DMARC uses pct=0, so the policy applies to no messages.",
|
||||||
|
evidence: ["dns.dmarc"],
|
||||||
|
remediation: "Move to quarantine or reject after validating legitimate senders.",
|
||||||
|
scoreImpact: 0,
|
||||||
|
} : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dns.mx_invalid",
|
||||||
|
evaluate: ({ dns }) => hasInvalidMx(dns) ? {
|
||||||
|
id: "dns.mx_invalid", category: "dns", severity: "warning",
|
||||||
|
title: "MX record data is malformed",
|
||||||
|
summary: "At least one MX answer has an invalid exchange or priority value.",
|
||||||
|
evidence: ["dns.mx"],
|
||||||
|
remediation: "Publish valid MX records, or use a null MX with exchange '.' and priority 0 when mail is not accepted.",
|
||||||
|
scoreImpact: -6,
|
||||||
|
} : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const severityRank: Record<Finding["severity"], number> = { critical: 0, warning: 1, info: 2, good: 3 };
|
||||||
|
|
||||||
|
export function evaluateDiagnostics(
|
||||||
|
dns: Record<string, unknown>,
|
||||||
|
tls: Record<string, unknown>,
|
||||||
|
http: Record<string, unknown>,
|
||||||
|
): Finding[] {
|
||||||
|
const context = { dns, tls, http };
|
||||||
|
return DIAGNOSTIC_RULES
|
||||||
|
.map((rule) => rule.evaluate(context))
|
||||||
|
.filter((value): value is Finding => Boolean(value))
|
||||||
|
.sort((left, right) => severityRank[left.severity] - severityRank[right.severity] || left.scoreImpact - right.scoreImpact || left.id.localeCompare(right.id));
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { collectDns, isAbsentDnsError, parseDmarcRecord, summarizeDmarc, summarizeSpf, type ResolverFactory, type ResolverLike } from "./dns";
|
||||||
|
|
||||||
|
function codedError(code: string) {
|
||||||
|
return Object.assign(new Error(code), { code });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DNS error and family handling", () => {
|
||||||
|
it.each(["ENODATA", "ENOTFOUND", "EAI_NONAME"])('treats %s as legitimate record absence', (code) => {
|
||||||
|
expect(isAbsentDnsError({ code })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["ESERVFAIL", "ETIMEOUT", "EREFUSED"])('treats %s as a probe failure', (code) => {
|
||||||
|
expect(isAbsentDnsError({ code })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps IPv4 evidence when the IPv6 family times out", async () => {
|
||||||
|
const resolverFactory: ResolverFactory = () => ({
|
||||||
|
resolve4: (async () => ["1.1.1.1"]) as unknown as ResolverLike["resolve4"],
|
||||||
|
resolve6: (async () => { throw codedError("ETIMEOUT"); }) as ResolverLike["resolve6"],
|
||||||
|
resolveMx: (async () => []) as ResolverLike["resolveMx"],
|
||||||
|
resolveNs: (async () => ["ns.example.com"]) as ResolverLike["resolveNs"],
|
||||||
|
resolveTxt: (async () => [["v=spf1 -all"]]) as ResolverLike["resolveTxt"],
|
||||||
|
cancel: () => undefined,
|
||||||
|
});
|
||||||
|
const result = await collectDns("example.com", { resolverFactory });
|
||||||
|
expect(result.a).toEqual(["1.1.1.1"]);
|
||||||
|
expect(result.aaaa).toEqual([]);
|
||||||
|
expect(result.addresses).toEqual(["1.1.1.1"]);
|
||||||
|
expect(result.families).toMatchObject({
|
||||||
|
ipv4: { status: "ok", addresses: ["1.1.1.1"], error: null },
|
||||||
|
ipv6: { status: "failed", addresses: [] },
|
||||||
|
});
|
||||||
|
expect(result._meta.status).toBe("partial");
|
||||||
|
expect(result._meta.partial).toBe(true);
|
||||||
|
expect(result._meta.errors).toEqual(expect.arrayContaining([expect.objectContaining({ probe: "AAAA" })]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("represents an all-family failure as failed without fabricating addresses", async () => {
|
||||||
|
const resolverFactory: ResolverFactory = () => ({
|
||||||
|
resolve4: (async () => { throw codedError("ESERVFAIL"); }) as ResolverLike["resolve4"],
|
||||||
|
resolve6: (async () => { throw codedError("ETIMEOUT"); }) as ResolverLike["resolve6"],
|
||||||
|
resolveMx: (async () => []) as ResolverLike["resolveMx"],
|
||||||
|
resolveNs: (async () => []) as ResolverLike["resolveNs"],
|
||||||
|
resolveTxt: (async () => { throw codedError("ESERVFAIL"); }) as ResolverLike["resolveTxt"],
|
||||||
|
cancel: () => undefined,
|
||||||
|
});
|
||||||
|
const result = await collectDns("example.com", { resolverFactory });
|
||||||
|
expect(result.a).toEqual([]);
|
||||||
|
expect(result.aaaa).toEqual([]);
|
||||||
|
expect(result.families.ipv4.status).toBe("failed");
|
||||||
|
expect(result.families.ipv6.status).toBe("failed");
|
||||||
|
expect(result._meta.status).toBe("failed");
|
||||||
|
expect(result._meta.partial).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mail record parsing", () => {
|
||||||
|
it("parses enforcing and monitor-only DMARC policies", () => {
|
||||||
|
expect(parseDmarcRecord("v=DMARC1; p=reject; pct=50")).toMatchObject({ valid: true, policy: "reject", pct: 50 });
|
||||||
|
expect(summarizeDmarc(["v=DMARC1; p=none"])).toMatchObject({ status: "monitor", policy: "none" });
|
||||||
|
expect(summarizeDmarc(["v=DMARC1; p=quarantine"])).toMatchObject({ status: "quarantine", policy: "quarantine" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed or duplicate DMARC records", () => {
|
||||||
|
expect(parseDmarcRecord("v=DMARC1; p=invalid").valid).toBe(false);
|
||||||
|
expect(summarizeDmarc(["v=DMARC1; p=reject", "v=DMARC1; p=none"]).status).toBe("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("describes only the visible terminal SPF all mechanism", () => {
|
||||||
|
expect(summarizeSpf(["v=spf1 include:_spf.example -all"])).toMatchObject({ status: "hardfail", allQualifier: "-" });
|
||||||
|
expect(summarizeSpf(["v=spf1 ~all"])).toMatchObject({ status: "softfail", allQualifier: "~" });
|
||||||
|
expect(summarizeSpf(["v=spf1 ?all"])).toMatchObject({ status: "neutral", allQualifier: "?" });
|
||||||
|
expect(summarizeSpf(["v=spf1 +all"])).toMatchObject({ status: "permissive", allQualifier: "+" });
|
||||||
|
expect(summarizeSpf(["v=spf1 mx"])).toMatchObject({ status: "incomplete" });
|
||||||
|
expect(summarizeSpf(["v=spf1 -all include:_spf.example"])).toMatchObject({ status: "invalid" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { Resolver } from "node:dns/promises";
|
||||||
|
import { createDeadline, remainingMs, withDeadline } from "./deadline";
|
||||||
|
|
||||||
|
export const DNS_TIMEOUT_MS = 4_000;
|
||||||
|
|
||||||
|
export type ProbeError = { probe: string; message: string };
|
||||||
|
export type DnsFamilyResult = { addresses: string[]; status: "ok" | "absent" | "failed"; error: ProbeError | null };
|
||||||
|
export type ResolverLike = Pick<Resolver, "resolve4" | "resolve6" | "resolveMx" | "resolveNs" | "resolveTxt" | "cancel">;
|
||||||
|
export type ResolverFactory = () => ResolverLike;
|
||||||
|
type Captured<T> = { value: T | null; error: ProbeError | null };
|
||||||
|
|
||||||
|
export type DmarcPosture = {
|
||||||
|
status: "missing" | "invalid" | "monitor" | "quarantine" | "reject";
|
||||||
|
policy: "none" | "quarantine" | "reject" | null;
|
||||||
|
pct: number | null;
|
||||||
|
record: string | null;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpfPosture = {
|
||||||
|
status: "missing" | "invalid" | "present" | "hardfail" | "softfail" | "neutral" | "permissive" | "incomplete";
|
||||||
|
allQualifier: "-" | "~" | "?" | "+" | null;
|
||||||
|
record: string | null;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAbsentDnsError(error: unknown): boolean {
|
||||||
|
const code = typeof error === "object" && error && "code" in error
|
||||||
|
? String((error as { code?: unknown }).code)
|
||||||
|
: "";
|
||||||
|
return code === "ENODATA" || code === "ENOTFOUND" || code === "EAI_NONAME";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCode(error: unknown) {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
|
? String((error as { code?: unknown }).code ?? "")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function failureMessage(probe: string, error: unknown) {
|
||||||
|
return errorCode(error) === "ETIMEOUT"
|
||||||
|
? probe + " DNS query timed out."
|
||||||
|
: probe + " DNS probe failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWithBudget<T>(
|
||||||
|
probe: string,
|
||||||
|
operation: (resolver: ResolverLike) => Promise<T>,
|
||||||
|
deadline: number,
|
||||||
|
resolverFactory: ResolverFactory,
|
||||||
|
): Promise<Captured<T>> {
|
||||||
|
const resolver = resolverFactory();
|
||||||
|
try {
|
||||||
|
if (!remainingMs(deadline)) return { value: null, error: { probe, message: probe + " DNS query timed out." } };
|
||||||
|
const value = await withDeadline(
|
||||||
|
operation(resolver),
|
||||||
|
deadline,
|
||||||
|
() => resolver.cancel(),
|
||||||
|
"DNS resolution timed out.",
|
||||||
|
);
|
||||||
|
return { value, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
if (isAbsentDnsError(error)) return { value: null, error: null };
|
||||||
|
return { value: null, error: { probe, message: failureMessage(probe, error) } };
|
||||||
|
} finally {
|
||||||
|
resolver.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveDnsFamily(host: string, family: 4 | 6, deadline = createDeadline(DNS_TIMEOUT_MS), resolverFactory: ResolverFactory = () => new Resolver()): Promise<DnsFamilyResult> {
|
||||||
|
const probe = family === 4 ? "A" : "AAAA";
|
||||||
|
const captured = await resolveWithBudget(probe, (resolver) => family === 4 ? resolver.resolve4(host) : resolver.resolve6(host), deadline, resolverFactory);
|
||||||
|
return familyResult(captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capture<T>(probe: string, fn: (resolver: ResolverLike) => Promise<T>, deadline: number, resolverFactory: ResolverFactory): Promise<Captured<T>> {
|
||||||
|
return resolveWithBudget(probe, fn, deadline, resolverFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattenTxt = (records: string[][]) => records.map((parts) => parts.join(""));
|
||||||
|
const isSpfRecord = (value: string) => value.trimStart().toLowerCase().startsWith("v=spf1");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This deliberately reads only the visible terminal `all` mechanism. It does
|
||||||
|
* not resolve includes, macros or SPF lookup chains.
|
||||||
|
*/
|
||||||
|
export function summarizeSpf(records: string[]): SpfPosture {
|
||||||
|
if (!records.length) return { status: "missing", allQualifier: null, record: null, reason: "No SPF record was observed." };
|
||||||
|
if (records.length !== 1) return { status: "invalid", allQualifier: null, record: records[0] ?? null, reason: "Multiple SPF records were observed." };
|
||||||
|
const record = records[0];
|
||||||
|
const mechanisms = record.trim().split(/\s+/).slice(1).filter(Boolean);
|
||||||
|
const allIndexes = mechanisms.map((mechanism, index) => /^[+\-~?]?all$/i.test(mechanism) ? index : -1).filter((index) => index >= 0);
|
||||||
|
if (allIndexes.length !== 1) return { status: "incomplete", allQualifier: null, record, reason: "No single terminal all mechanism was observed." };
|
||||||
|
const index = allIndexes[0];
|
||||||
|
if (index !== mechanisms.length - 1) return { status: "invalid", allQualifier: null, record, reason: "The SPF all mechanism is not terminal." };
|
||||||
|
type SpfQualifier = NonNullable<SpfPosture["allQualifier"]>;
|
||||||
|
const qualifier: SpfQualifier = (mechanisms[index][0] === "-" || mechanisms[index][0] === "~" || mechanisms[index][0] === "?" || mechanisms[index][0] === "+")
|
||||||
|
? mechanisms[index][0] as SpfQualifier
|
||||||
|
: "+";
|
||||||
|
const posture: Record<SpfQualifier, Pick<SpfPosture, "status" | "reason">> = {
|
||||||
|
"-": { status: "hardfail", reason: "The terminal -all mechanism rejects unauthorized senders." },
|
||||||
|
"~": { status: "softfail", reason: "The terminal ~all mechanism soft-fails unauthorized senders." },
|
||||||
|
"?": { status: "neutral", reason: "The terminal ?all mechanism is neutral for unauthorized senders." },
|
||||||
|
"+": { status: "permissive", reason: "The terminal +all mechanism authorizes every sender." },
|
||||||
|
};
|
||||||
|
return { ...posture[qualifier], allQualifier: qualifier, record };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDmarcRecord(record: string): { valid: boolean; policy: DmarcPosture["policy"]; pct: number | null; tags: Record<string, string> } {
|
||||||
|
const tags = new Map<string, string>();
|
||||||
|
let invalid = false;
|
||||||
|
for (const part of record.split(";")) {
|
||||||
|
const trimmed = part.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const [rawName, ...rawValue] = trimmed.split("=");
|
||||||
|
const name = rawName.trim().toLowerCase();
|
||||||
|
const value = rawValue.join("=").trim().toLowerCase();
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(name) || !value || tags.has(name)) invalid = true;
|
||||||
|
else tags.set(name, value);
|
||||||
|
}
|
||||||
|
if (tags.get("v") !== "dmarc1") invalid = true;
|
||||||
|
const policyValue = tags.get("p");
|
||||||
|
const policy = policyValue === "none" || policyValue === "quarantine" || policyValue === "reject" ? policyValue : null;
|
||||||
|
if (!policy) invalid = true;
|
||||||
|
const pctRaw = tags.get("pct");
|
||||||
|
const pct = pctRaw === undefined ? 100 : /^\d+$/.test(pctRaw) ? Number(pctRaw) : null;
|
||||||
|
if (pct === null || !Number.isInteger(pct) || pct < 0 || pct > 100) invalid = true;
|
||||||
|
return { valid: !invalid, policy, pct, tags: Object.fromEntries(tags) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeDmarc(records: string[]): DmarcPosture {
|
||||||
|
if (!records.length) return { status: "missing", policy: null, pct: null, record: null, reason: "No DMARC record was observed." };
|
||||||
|
if (records.length !== 1) return { status: "invalid", policy: null, pct: null, record: records[0] ?? null, reason: "Multiple DMARC records were observed." };
|
||||||
|
const parsed = parseDmarcRecord(records[0]);
|
||||||
|
if (!parsed.valid || !parsed.policy) return { status: "invalid", policy: parsed.policy, pct: parsed.pct, record: records[0], reason: "The DMARC record is malformed or uses an unsupported policy." };
|
||||||
|
const monitorOnly = parsed.policy === "none" || parsed.pct === 0;
|
||||||
|
const status = monitorOnly ? "monitor" : parsed.policy === "quarantine" ? "quarantine" : "reject";
|
||||||
|
return { status, policy: parsed.policy, pct: parsed.pct, record: records[0], reason: parsed.policy === "none" ? "p=none monitors failures without enforcement." : parsed.pct === 0 ? "pct=0 applies the policy to no messages." : "An enforcing DMARC policy was observed." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsCollectionOptions {
|
||||||
|
deadline?: number;
|
||||||
|
resolverFactory?: ResolverFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function familyResult(captured: Captured<string[] | null>): DnsFamilyResult {
|
||||||
|
const addresses = captured.value ?? [];
|
||||||
|
return { addresses, status: captured.error ? "failed" : addresses.length ? "ok" : "absent", error: captured.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectDns(host: string, options: DnsCollectionOptions = {}) {
|
||||||
|
const deadline = options.deadline ?? createDeadline(DNS_TIMEOUT_MS);
|
||||||
|
const resolverFactory = options.resolverFactory ?? (() => new Resolver());
|
||||||
|
const [a, aaaa, mx, ns, txtRaw, dmarcRaw] = await Promise.all([
|
||||||
|
capture("A", (resolver) => resolver.resolve4(host), deadline, resolverFactory),
|
||||||
|
capture("AAAA", (resolver) => resolver.resolve6(host), deadline, resolverFactory),
|
||||||
|
capture("MX", (resolver) => resolver.resolveMx(host), deadline, resolverFactory),
|
||||||
|
capture("NS", (resolver) => resolver.resolveNs(host), deadline, resolverFactory),
|
||||||
|
capture("TXT", (resolver) => resolver.resolveTxt(host), deadline, resolverFactory),
|
||||||
|
capture("DMARC", (resolver) => resolver.resolveTxt("_dmarc." + host), deadline, resolverFactory),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const txt = flattenTxt(txtRaw.value ?? []);
|
||||||
|
const dmarc = flattenTxt(dmarcRaw.value ?? []);
|
||||||
|
const errors = [a.error, aaaa.error, mx.error, ns.error, txtRaw.error, dmarcRaw.error]
|
||||||
|
.filter((item): item is ProbeError => Boolean(item));
|
||||||
|
const valuesAvailable = Boolean((a.value?.length ?? 0) + (aaaa.value?.length ?? 0) + (mx.value?.length ?? 0) + (ns.value?.length ?? 0) + txt.length + dmarc.length);
|
||||||
|
const status = errors.length ? (valuesAvailable ? "partial" : "failed") : "ok";
|
||||||
|
const ipv4 = familyResult(a);
|
||||||
|
const ipv6 = familyResult(aaaa);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addresses: [...ipv4.addresses, ...ipv6.addresses],
|
||||||
|
families: { ipv4, ipv6 },
|
||||||
|
a: a.value ?? [],
|
||||||
|
aaaa: aaaa.value ?? [],
|
||||||
|
mx: mx.value ?? [],
|
||||||
|
ns: ns.value ?? [],
|
||||||
|
txt,
|
||||||
|
spf: txt.filter(isSpfRecord),
|
||||||
|
dmarc,
|
||||||
|
spfPosture: summarizeSpf(txt.filter(isSpfRecord)),
|
||||||
|
dmarcPosture: summarizeDmarc(dmarc),
|
||||||
|
_meta: { status, partial: status === "partial", errors },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { analyzeHeaders, frameProtectionStatus, isNosniff, parseCsp, parseFrameProtection, parseHsts, parseReferrerPolicy, referrerPolicyStatus } from "./headers";
|
||||||
|
|
||||||
|
describe("security header parsing", () => {
|
||||||
|
it("distinguishes missing, disabled, weak and strong HSTS", () => {
|
||||||
|
expect(parseHsts(null).status).toBe("missing");
|
||||||
|
expect(parseHsts("max-age=0").status).toBe("disabled");
|
||||||
|
expect(parseHsts("max-age=3600").status).toBe("weak");
|
||||||
|
expect(parseHsts("max-age=31536000; includeSubDomains; preload")).toMatchObject({
|
||||||
|
status: "strong",
|
||||||
|
maxAge: 31_536_000,
|
||||||
|
includeSubDomains: true,
|
||||||
|
preload: true,
|
||||||
|
});
|
||||||
|
expect(parseHsts("max-age=abc").status).toBe("invalid");
|
||||||
|
expect(parseHsts("max-age=10; max-age=20").status).toBe("invalid");
|
||||||
|
expect(parseHsts("max-age=31536000; includeSubDomains=true").status).toBe("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates CSP sources in their effective directive context", () => {
|
||||||
|
expect(parseCsp(null).status).toBe("missing");
|
||||||
|
expect(parseCsp("default-src *").status).toBe("weak");
|
||||||
|
expect(parseCsp("default-src https://*").wildcard).toBe(true);
|
||||||
|
expect(parseCsp("default-src https:").wildcard).toBe(true);
|
||||||
|
expect(parseCsp("default-src 'self'; script-src 'self' 'unsafe-eval'").unsafeEval).toBe(true);
|
||||||
|
expect(parseCsp("default-src 'self'; script-src 'self' 'unsafe-inline'").status).toBe("weak");
|
||||||
|
expect(parseCsp("default-src 'self'; script-src data:; object-src 'none'; base-uri 'self'").status).toBe("weak");
|
||||||
|
expect(parseCsp("default-src 'self'; img-src data: blob:; object-src 'none'; base-uri 'self'").status).toBe("strong");
|
||||||
|
expect(parseCsp("default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'").status).toBe("strong");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the first duplicate CSP directive as browsers do", () => {
|
||||||
|
const csp = parseCsp("default-src 'self'; object-src 'none'; base-uri 'self'; script-src 'self'; script-src 'unsafe-eval'");
|
||||||
|
expect(csp.directives["script-src"]).toEqual(["'self'"]);
|
||||||
|
expect(csp.duplicates).toEqual(["script-src"]);
|
||||||
|
expect(csp.unsafeEval).toBe(false);
|
||||||
|
expect(csp.status).toBe("strong");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("evaluates value semantics and modern frame protection", () => {
|
||||||
|
expect(isNosniff("NoSniff")).toBe(true);
|
||||||
|
expect(isNosniff("text/plain")).toBe(false);
|
||||||
|
expect(referrerPolicyStatus("strict-origin-when-cross-origin")).toBe("good");
|
||||||
|
expect(referrerPolicyStatus("unsafe-url")).toBe("weak");
|
||||||
|
expect(referrerPolicyStatus("not-a-policy")).toBe("invalid");
|
||||||
|
expect(frameProtectionStatus({ "content-security-policy": "default-src 'self'; frame-ancestors 'none'" })).toBe("strong");
|
||||||
|
expect(frameProtectionStatus({ "x-frame-options": "SAMEORIGIN" })).toBe("good");
|
||||||
|
expect(frameProtectionStatus({ "x-frame-options": "ALLOW-FROM https://example.com" })).toBe("invalid");
|
||||||
|
expect(frameProtectionStatus({})).toBe("missing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the last recognized Referrer-Policy fallback value", () => {
|
||||||
|
expect(parseReferrerPolicy("no-referrer, strict-origin-when-cross-origin")).toMatchObject({ effective: "strict-origin-when-cross-origin", status: "good" });
|
||||||
|
expect(parseReferrerPolicy("future-policy, no-referrer")).toMatchObject({ effective: "no-referrer", status: "strong" });
|
||||||
|
expect(parseReferrerPolicy("strict-origin, future-policy")).toMatchObject({ effective: "strict-origin", status: "good" });
|
||||||
|
expect(parseReferrerPolicy(" unsafe-url , unknown ")).toMatchObject({ effective: "unsafe-url", status: "weak" });
|
||||||
|
expect(parseReferrerPolicy("future-policy")).toMatchObject({ effective: null, status: "invalid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes broad and restrictive frame-ancestor policies", () => {
|
||||||
|
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors 'self'" }).status).toBe("strong");
|
||||||
|
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors https://portal.example" }).status).toBe("good");
|
||||||
|
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors https:" }).status).toBe("weak");
|
||||||
|
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors *" }).status).toBe("weak");
|
||||||
|
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors 'none' https://portal.example" }).status).toBe("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a complete normalized posture object", () => {
|
||||||
|
const posture = analyzeHeaders({
|
||||||
|
"strict-transport-security": "max-age=31536000",
|
||||||
|
"content-security-policy": "default-src 'self'; object-src 'none'; base-uri 'self'",
|
||||||
|
"x-content-type-options": "nosniff",
|
||||||
|
"referrer-policy": "no-referrer",
|
||||||
|
"x-frame-options": "DENY",
|
||||||
|
});
|
||||||
|
expect(posture.hsts.status).toBe("strong");
|
||||||
|
expect(posture.csp.status).toBe("strong");
|
||||||
|
expect(posture.nosniff.status).toBe("strong");
|
||||||
|
expect(posture.frameProtection.status).toBe("strong");
|
||||||
|
expect(posture.aggregate).toMatchObject({ healthy: 5, attention: 0, label: "5 / 5 controls healthy" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
export type HeaderValue = string | string[] | null | undefined;
|
||||||
|
export type HeaderMap = Record<string, HeaderValue>;
|
||||||
|
|
||||||
|
const DAY_SECONDS = 86_400;
|
||||||
|
const STRONG_HSTS_SECONDS = 31_536_000;
|
||||||
|
|
||||||
|
export type HeaderControlId = "hsts" | "csp" | "nosniff" | "referrer-policy" | "frame-protection";
|
||||||
|
export type HeaderControlStatus = "strong" | "good" | "weak" | "missing" | "invalid" | "unavailable";
|
||||||
|
|
||||||
|
export type HeaderControlPosture = {
|
||||||
|
id: HeaderControlId;
|
||||||
|
status: HeaderControlStatus;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HeaderAggregate = {
|
||||||
|
evidenceAvailable: boolean;
|
||||||
|
healthy: number;
|
||||||
|
attention: number;
|
||||||
|
unavailable: number;
|
||||||
|
total: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function firstHeader(value: HeaderValue) {
|
||||||
|
if (Array.isArray(value)) return value.join(", ");
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directiveMap(value: string) {
|
||||||
|
const directives = new Map<string, string | null>();
|
||||||
|
const duplicate = new Set<string>();
|
||||||
|
for (const raw of value.split(";")) {
|
||||||
|
const part = raw.trim();
|
||||||
|
if (!part) continue;
|
||||||
|
const [name, ...rawValue] = part.split("=");
|
||||||
|
const normalizedName = name.trim().toLowerCase();
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(normalizedName)) return { directives, duplicate, invalid: true };
|
||||||
|
if (directives.has(normalizedName)) {
|
||||||
|
duplicate.add(normalizedName);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
directives.set(normalizedName, rawValue.length ? rawValue.join("=").trim() : null);
|
||||||
|
}
|
||||||
|
return { directives, duplicate, invalid: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HstsPosture = {
|
||||||
|
status: "missing" | "invalid" | "disabled" | "weak" | "strong";
|
||||||
|
maxAge: number | null;
|
||||||
|
includeSubDomains: boolean;
|
||||||
|
preload: boolean;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseHsts(value: HeaderValue): HstsPosture {
|
||||||
|
const raw = firstHeader(value);
|
||||||
|
if (!raw) return { status: "missing", maxAge: null, includeSubDomains: false, preload: false, reason: "Header is missing." };
|
||||||
|
const parsed = directiveMap(raw);
|
||||||
|
const maxAgeRaw = parsed.directives.get("max-age");
|
||||||
|
const maxAge = maxAgeRaw !== null && maxAgeRaw !== undefined && /^\d+$/.test(maxAgeRaw) ? Number(maxAgeRaw) : null;
|
||||||
|
const invalid = parsed.invalid || parsed.duplicate.has("max-age") || maxAge === null || !Number.isSafeInteger(maxAge)
|
||||||
|
|| (parsed.directives.has("includesubdomains") && parsed.directives.get("includesubdomains") !== null)
|
||||||
|
|| (parsed.directives.has("preload") && parsed.directives.get("preload") !== null);
|
||||||
|
const includeSubDomains = parsed.directives.has("includesubdomains");
|
||||||
|
const preload = parsed.directives.has("preload");
|
||||||
|
if (invalid) return { status: "invalid", maxAge, includeSubDomains, preload, reason: "max-age must be a single non-negative integer." };
|
||||||
|
if (maxAge === 0) return { status: "disabled", maxAge, includeSubDomains, preload, reason: "max-age=0 disables HSTS." };
|
||||||
|
if (maxAge < DAY_SECONDS) return { status: "weak", maxAge, includeSubDomains, preload, reason: "The policy expires in less than one day." };
|
||||||
|
if (maxAge < STRONG_HSTS_SECONDS) return { status: "weak", maxAge, includeSubDomains, preload, reason: "The policy is shorter than the recommended one-year duration." };
|
||||||
|
return { status: "strong", maxAge, includeSubDomains, preload, reason: "Long-lived HSTS policy observed." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CspPosture = {
|
||||||
|
status: "missing" | "invalid" | "weak" | "strong";
|
||||||
|
directives: Record<string, string[]>;
|
||||||
|
duplicates: string[];
|
||||||
|
wildcard: boolean;
|
||||||
|
unsafeEval: boolean;
|
||||||
|
unsafeInline: boolean;
|
||||||
|
frameAncestors: string[];
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HIGH_RISK_DIRECTIVES = new Set(["default-src", "script-src", "script-src-elem", "object-src", "base-uri"]);
|
||||||
|
const BROAD_SOURCES = new Set(["*", "http:", "https:", "http://*", "https://*"]);
|
||||||
|
|
||||||
|
function sourceIsBroad(source: string, directive: string) {
|
||||||
|
if (BROAD_SOURCES.has(source)) return true;
|
||||||
|
return HIGH_RISK_DIRECTIVES.has(directive) && (source === "data:" || source === "blob:");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one CSP policy. Browser CSP keeps the first occurrence of a duplicate
|
||||||
|
* directive, so later duplicates are retained only as configuration evidence.
|
||||||
|
*/
|
||||||
|
export function parseCsp(value: HeaderValue): CspPosture {
|
||||||
|
const raw = firstHeader(value);
|
||||||
|
if (!raw) return { status: "missing", directives: {}, duplicates: [], wildcard: false, unsafeEval: false, unsafeInline: false, frameAncestors: [], reason: "Header is missing." };
|
||||||
|
|
||||||
|
const directives: Record<string, string[]> = {};
|
||||||
|
const duplicates = new Set<string>();
|
||||||
|
let malformed = false;
|
||||||
|
for (const rawDirective of raw.split(";")) {
|
||||||
|
const tokens = rawDirective.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!tokens.length) continue;
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/i.test(tokens[0])) {
|
||||||
|
malformed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const name = tokens.shift()!.toLowerCase();
|
||||||
|
if (name in directives) {
|
||||||
|
duplicates.add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
directives[name] = tokens.map((token) => token.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveScript = directives["script-src"] ?? directives["default-src"] ?? [];
|
||||||
|
const effectiveScriptElement = directives["script-src-elem"] ?? effectiveScript;
|
||||||
|
const unsafeEval = effectiveScript.includes("'unsafe-eval'") || effectiveScriptElement.includes("'unsafe-eval'");
|
||||||
|
const unsafeInline = effectiveScript.includes("'unsafe-inline'") || effectiveScriptElement.includes("'unsafe-inline'");
|
||||||
|
const broadDirectives = Object.entries(directives).filter(([name, sources]) =>
|
||||||
|
sources.some((source) => sourceIsBroad(source, name)),
|
||||||
|
);
|
||||||
|
const wildcard = broadDirectives.length > 0;
|
||||||
|
const frameAncestors = directives["frame-ancestors"] ?? [];
|
||||||
|
const missingObjectSrc = !directives["object-src"]?.includes("'none'");
|
||||||
|
const missingBaseUri = !directives["base-uri"]?.includes("'self'") && !directives["base-uri"]?.includes("'none'");
|
||||||
|
const weak = wildcard || unsafeEval || unsafeInline || missingObjectSrc || missingBaseUri;
|
||||||
|
const reason = malformed
|
||||||
|
? "The policy contains malformed directives."
|
||||||
|
: unsafeEval
|
||||||
|
? "The effective script policy permits unsafe-eval."
|
||||||
|
: unsafeInline
|
||||||
|
? "The effective script policy permits unsafe-inline."
|
||||||
|
: wildcard
|
||||||
|
? "A high-impact directive permits broad sources."
|
||||||
|
: missingObjectSrc
|
||||||
|
? "object-src 'none' is missing."
|
||||||
|
: missingBaseUri
|
||||||
|
? "base-uri 'self' or 'none' is missing."
|
||||||
|
: "Restrictive policy observed.";
|
||||||
|
return { status: malformed ? "invalid" : weak ? "weak" : "strong", directives, duplicates: [...duplicates], wildcard, unsafeEval, unsafeInline, frameAncestors, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReferrerPolicyPosture = HeaderControlPosture & { effective: string | null };
|
||||||
|
|
||||||
|
const STRONG_REFERRER_POLICIES = new Set(["no-referrer", "same-origin"]);
|
||||||
|
const GOOD_REFERRER_POLICIES = new Set(["strict-origin", "strict-origin-when-cross-origin"]);
|
||||||
|
const WEAK_REFERRER_POLICIES = new Set(["origin", "origin-when-cross-origin", "no-referrer-when-downgrade", "unsafe-url"]);
|
||||||
|
|
||||||
|
export function parseReferrerPolicy(value: HeaderValue): ReferrerPolicyPosture {
|
||||||
|
const raw = firstHeader(value);
|
||||||
|
if (!raw?.trim()) return { id: "referrer-policy", status: "missing", effective: null, reason: "Header is missing." };
|
||||||
|
const policies = raw.split(",").map((policy) => policy.trim().toLowerCase()).filter(Boolean);
|
||||||
|
const recognized = policies.filter((policy) => STRONG_REFERRER_POLICIES.has(policy) || GOOD_REFERRER_POLICIES.has(policy) || WEAK_REFERRER_POLICIES.has(policy));
|
||||||
|
const effective = recognized.at(-1) ?? null;
|
||||||
|
if (!effective) return { id: "referrer-policy", status: "invalid", effective: null, reason: "No supported Referrer-Policy value was observed." };
|
||||||
|
if (STRONG_REFERRER_POLICIES.has(effective)) return { id: "referrer-policy", status: "strong", effective, reason: "The effective policy minimizes referrer disclosure." };
|
||||||
|
if (GOOD_REFERRER_POLICIES.has(effective)) return { id: "referrer-policy", status: "good", effective, reason: "The effective policy limits cross-origin referrer detail." };
|
||||||
|
return { id: "referrer-policy", status: "weak", effective, reason: effective === "unsafe-url" ? "The effective policy may disclose full URLs cross-origin." : "The effective policy permits more referrer detail than strict-origin policies." };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backwards-compatible concise status for rule callers. */
|
||||||
|
export function referrerPolicyStatus(value: HeaderValue) {
|
||||||
|
return parseReferrerPolicy(value).status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrameProtectionPosture = HeaderControlPosture & { source: "csp" | "x-frame-options" | "none" };
|
||||||
|
|
||||||
|
function frameAncestorsPosture(csp: CspPosture): FrameProtectionPosture | null {
|
||||||
|
if (!("frame-ancestors" in csp.directives)) return null;
|
||||||
|
const sources = csp.frameAncestors;
|
||||||
|
if (!sources.length) return { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors has no usable source expression." };
|
||||||
|
if (sources.includes("*")) return { id: "frame-protection", status: "weak", source: "csp", reason: "frame-ancestors permits every embedding origin." };
|
||||||
|
if (sources.some((source) => source === "http:" || source === "https:" || source === "data:" || source === "blob:" || source.includes("*"))) {
|
||||||
|
return { id: "frame-protection", status: "weak", source: "csp", reason: "frame-ancestors permits a broad scheme or wildcard origin." };
|
||||||
|
}
|
||||||
|
if (sources.includes("'none'")) return sources.length === 1
|
||||||
|
? { id: "frame-protection", status: "strong", source: "csp", reason: "frame-ancestors 'none' blocks framing." }
|
||||||
|
: { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors 'none' cannot be combined with other sources." };
|
||||||
|
if (sources.includes("'self'")) return { id: "frame-protection", status: "strong", source: "csp", reason: "frame-ancestors restricts framing to the same origin." };
|
||||||
|
const explicitOrigins = sources.every((source) => /^https?:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(source));
|
||||||
|
return explicitOrigins
|
||||||
|
? { id: "frame-protection", status: "good", source: "csp", reason: "frame-ancestors lists explicit embedding origins." }
|
||||||
|
: { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors contains an unsupported source expression." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseFrameProtection(headers: HeaderMap): FrameProtectionPosture {
|
||||||
|
const csp = parseCsp(headers["content-security-policy"]);
|
||||||
|
const cspPosture = frameAncestorsPosture(csp);
|
||||||
|
if (cspPosture) return cspPosture;
|
||||||
|
const xFrame = firstHeader(headers["x-frame-options"])?.trim().toLowerCase();
|
||||||
|
if (xFrame === "deny") return { id: "frame-protection", status: "strong", source: "x-frame-options", reason: "X-Frame-Options: DENY blocks framing." };
|
||||||
|
if (xFrame === "sameorigin") return { id: "frame-protection", status: "good", source: "x-frame-options", reason: "X-Frame-Options restricts framing to the same origin." };
|
||||||
|
if (xFrame) return { id: "frame-protection", status: "invalid", source: "x-frame-options", reason: "X-Frame-Options has an unsupported value." };
|
||||||
|
return { id: "frame-protection", status: "missing", source: "none", reason: "No frame-ancestors policy or X-Frame-Options protection was observed." };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backwards-compatible concise status for rule callers. */
|
||||||
|
export function frameProtectionStatus(headers: HeaderMap) {
|
||||||
|
return parseFrameProtection(headers).status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hstsControl(hsts: HstsPosture): HeaderControlPosture {
|
||||||
|
const status: HeaderControlStatus = hsts.status === "disabled" ? "weak" : hsts.status;
|
||||||
|
return { id: "hsts", status, reason: hsts.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nosniffControl(value: HeaderValue): HeaderControlPosture {
|
||||||
|
const raw = firstHeader(value);
|
||||||
|
if (!raw?.trim()) return { id: "nosniff", status: "missing", reason: "Header is missing." };
|
||||||
|
return raw.trim().toLowerCase() === "nosniff"
|
||||||
|
? { id: "nosniff", status: "strong", reason: "The required nosniff value was observed." }
|
||||||
|
: { id: "nosniff", status: "invalid", reason: "The header does not use the required nosniff value." };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNosniff(value: HeaderValue) {
|
||||||
|
return nosniffControl(value).status === "strong";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cspControl(csp: CspPosture): HeaderControlPosture {
|
||||||
|
return { id: "csp", status: csp.status, reason: csp.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeHeaderControls(controls: HeaderControlPosture[]): HeaderAggregate {
|
||||||
|
const healthy = controls.filter((control) => control.status === "strong" || control.status === "good").length;
|
||||||
|
const unavailable = controls.filter((control) => control.status === "unavailable").length;
|
||||||
|
const attention = controls.length - healthy - unavailable;
|
||||||
|
const evidenceAvailable = unavailable !== controls.length;
|
||||||
|
const label = !evidenceAvailable
|
||||||
|
? "Evidence unavailable"
|
||||||
|
: unavailable
|
||||||
|
? `${healthy} / ${controls.length - unavailable} controls healthy · evidence incomplete`
|
||||||
|
: attention
|
||||||
|
? `${attention} control${attention === 1 ? " needs" : "s need"} attention`
|
||||||
|
: `${healthy} / ${controls.length} controls healthy`;
|
||||||
|
return { evidenceAvailable, healthy, attention, unavailable, total: controls.length, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unavailableHeaderPosture() {
|
||||||
|
const controls: HeaderControlPosture[] = ["hsts", "csp", "nosniff", "referrer-policy", "frame-protection"].map((id) => ({
|
||||||
|
id: id as HeaderControlId,
|
||||||
|
status: "unavailable",
|
||||||
|
reason: "No terminal HTTPS response was available for evaluation.",
|
||||||
|
}));
|
||||||
|
return { controls, aggregate: summarizeHeaderControls(controls) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function analyzeHeaders(headers: HeaderMap) {
|
||||||
|
const hsts = parseHsts(headers["strict-transport-security"]);
|
||||||
|
const csp = parseCsp(headers["content-security-policy"]);
|
||||||
|
const nosniff = nosniffControl(headers["x-content-type-options"]);
|
||||||
|
const referrerPolicy = parseReferrerPolicy(headers["referrer-policy"]);
|
||||||
|
const frameProtection = parseFrameProtection(headers);
|
||||||
|
const controls = [hstsControl(hsts), cspControl(csp), nosniff, referrerPolicy, frameProtection];
|
||||||
|
return { hsts, csp, nosniff, referrerPolicy, frameProtection, controls, aggregate: summarizeHeaderControls(controls) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { statusForHttp } from "./http-status";
|
||||||
|
|
||||||
|
describe("HTTP status semantics", () => {
|
||||||
|
it.each([
|
||||||
|
[100, "Informational response", "muted"],
|
||||||
|
[200, "Healthy response", "green"],
|
||||||
|
[302, "Redirect response", "muted"],
|
||||||
|
[404, "Client error", "amber"],
|
||||||
|
[503, "Server error", "red"],
|
||||||
|
] as const)("classifies %s without misleading status colors", (status, label, tone) => {
|
||||||
|
expect(statusForHttp(status)).toEqual({ label, tone });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([null, undefined, 99, 600, 200.5, "200"])("treats %s as incomplete", (status) => {
|
||||||
|
expect(statusForHttp(status).label).toBe("Incomplete");
|
||||||
|
expect(statusForHttp(status).tone).toBe("muted");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type HttpStatusTone = "green" | "amber" | "red" | "muted";
|
||||||
|
|
||||||
|
export type HttpStatusSemantics = {
|
||||||
|
label: "Incomplete" | "Informational response" | "Healthy response" | "Redirect response" | "Client error" | "Server error";
|
||||||
|
tone: HttpStatusTone;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusForHttp(status: unknown): HttpStatusSemantics {
|
||||||
|
if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) {
|
||||||
|
return { label: "Incomplete", tone: "muted" };
|
||||||
|
}
|
||||||
|
if (status < 200) return { label: "Informational response", tone: "muted" };
|
||||||
|
if (status < 300) return { label: "Healthy response", tone: "green" };
|
||||||
|
if (status < 400) return { label: "Redirect response", tone: "muted" };
|
||||||
|
if (status < 500) return { label: "Client error", tone: "amber" };
|
||||||
|
return { label: "Server error", tone: "red" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { followRedirectChain, requestOnce, validateProbeUrl } from "./http";
|
||||||
|
|
||||||
|
const response = (url: string, status: number, location: string | null = null) => ({ url, status, location, headers: {} });
|
||||||
|
const publicResolution = (addresses: string[]) => ({ addresses, families: { ipv4: { addresses, status: "ok" as const, error: null }, ipv6: { addresses: [], status: "absent" as const, error: null } }, _meta: { status: "ok" as const, partial: false, errors: [] } });
|
||||||
|
|
||||||
|
describe("HTTP target policy", () => {
|
||||||
|
it.each(["ftp://example.com/", "https://user:pass@example.com/", "https://example.com:8443/", "https://127.0.0.1/"])('rejects unsafe target %s', (value) => {
|
||||||
|
expect(() => validateProbeUrl(new URL(value))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts standard public-host HTTP(S) targets", () => {
|
||||||
|
expect(() => validateProbeUrl(new URL("https://example.com/path?q=1"))).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("redirect chain", () => {
|
||||||
|
it("follows bounded valid redirects with an injected transport", async () => {
|
||||||
|
const requester = async (url: URL) => url.pathname === "/"
|
||||||
|
? response(url.toString(), 302, "/final")
|
||||||
|
: response(url.toString(), 200);
|
||||||
|
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
|
||||||
|
expect(chain).toHaveLength(2);
|
||||||
|
expect(chain[1].status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records relative and protocol-relative redirects", async () => {
|
||||||
|
const requester = async (url: URL) => url.pathname === "/" ? response(url.toString(), 301, "//example.com/final") : response(url.toString(), 204);
|
||||||
|
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
|
||||||
|
expect(chain.at(-1)?.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks HTTPS to HTTP downgrades before requesting the target", async () => {
|
||||||
|
const requested: string[] = [];
|
||||||
|
const requester = async (url: URL) => { requested.push(url.toString()); return response(url.toString(), 302, "http://example.com/"); };
|
||||||
|
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
|
||||||
|
expect(chain.at(-1)).toMatchObject({ code: "HTTPS_DOWNGRADE" });
|
||||||
|
expect(requested).toEqual(["https://example.com/"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects self-loops and multi-hop normalized loops", async () => {
|
||||||
|
const self = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 301, url.toString()));
|
||||||
|
expect(self.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
|
||||||
|
const cycle = await followRedirectChain(new URL("https://example.com/a"), async (url) => url.pathname === "/a" ? response(url.toString(), 302, "/b") : response(url.toString(), 302, "/a"));
|
||||||
|
expect(cycle.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
|
||||||
|
const equivalent = await followRedirectChain(new URL("https://EXAMPLE.com./"), async (url) => response(url.toString(), 302, "https://example.com/"));
|
||||||
|
expect(equivalent.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records unsafe redirects and redirect budget exhaustion", async () => {
|
||||||
|
const unsafe = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "http://127.0.0.1/admin"));
|
||||||
|
expect(unsafe.at(-1)).toMatchObject({ error: expect.any(String), code: "REDIRECT_TARGET_BLOCKED" });
|
||||||
|
const limited = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "/again"), 1);
|
||||||
|
expect(limited.at(-1)).toMatchObject({ code: "REDIRECT_LIMIT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts redirect userinfo from error evidence", async () => {
|
||||||
|
const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "https://user:secret@example.com/private"));
|
||||||
|
expect(chain.at(-1)).toMatchObject({ code: "REDIRECT_TARGET_BLOCKED" });
|
||||||
|
expect(JSON.stringify(chain)).not.toContain("secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the total deadline across slow hops", async () => {
|
||||||
|
const started = Date.now();
|
||||||
|
const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
return response(url.toString(), 200);
|
||||||
|
}, 3, Date.now() + 10);
|
||||||
|
expect(chain.at(-1)?.error).toBe("HTTP request timed out.");
|
||||||
|
expect(Date.now() - started).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("multi-address HTTP strategy", () => {
|
||||||
|
it("tries the next validated public address after a transport failure", async () => {
|
||||||
|
const attempts: string[] = [];
|
||||||
|
const result = await requestOnce(new URL("https://example.com/"), Date.now() + 1_000, {
|
||||||
|
resolve: async () => publicResolution(["1.1.1.1", "2.2.2.2"]),
|
||||||
|
request: async (url, address) => {
|
||||||
|
attempts.push(address);
|
||||||
|
if (address === "1.1.1.1") throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" });
|
||||||
|
return response(url.toString(), 200);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toMatchObject({ status: 200, address: "2.2.2.2" });
|
||||||
|
expect(attempts).toEqual(["1.1.1.1", "2.2.2.2"]);
|
||||||
|
expect(result.attempts).toEqual([{ address: "1.1.1.1", error: "HTTP request failed (ECONNREFUSED)." }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unsafe address returned by a resolver before any request", async () => {
|
||||||
|
const attempted: string[] = [];
|
||||||
|
await expect(requestOnce(new URL("https://example.com/"), Date.now() + 1_000, {
|
||||||
|
resolve: async () => publicResolution(["1.1.1.1", "127.0.0.1"]),
|
||||||
|
request: async (_url, address) => { attempted.push(address); return response("https://example.com/", 200); },
|
||||||
|
})).rejects.toThrow(/non-public address space/);
|
||||||
|
expect(attempted).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import { analyzeHeaders, type HeaderMap } from "./headers";
|
||||||
|
import { createDeadline, remainingMs, timeoutError, withDeadline } from "./deadline";
|
||||||
|
import { InspectionInputError, InspectionPolicyError, InspectionResolutionError, normalizeHost, resolvePublicHostDetailed, validateResolvedAddresses } from "./policy";
|
||||||
|
import { CERTSCOPE_VERSION } from "../version";
|
||||||
|
|
||||||
|
const HEADER_NAMES = [
|
||||||
|
"strict-transport-security",
|
||||||
|
"content-security-policy",
|
||||||
|
"x-content-type-options",
|
||||||
|
"referrer-policy",
|
||||||
|
"x-frame-options",
|
||||||
|
"server",
|
||||||
|
"x-powered-by",
|
||||||
|
];
|
||||||
|
export const HTTP_TIMEOUT_MS = 5_000;
|
||||||
|
export const HTTP_TOTAL_TIMEOUT_MS = 15_000;
|
||||||
|
export const DEFAULT_MAX_REDIRECTS = 3;
|
||||||
|
export const MAX_ADDRESS_ATTEMPTS = 6;
|
||||||
|
export const HTTPS_DOWNGRADE_ERROR = "HTTPS redirect downgrade is not allowed.";
|
||||||
|
export const REDIRECT_LOOP_ERROR = "Redirect loop detected.";
|
||||||
|
|
||||||
|
export type HttpHop = Record<string, unknown>;
|
||||||
|
type Requester = (url: URL, deadline: number) => Promise<HttpHop>;
|
||||||
|
export type CandidateRequester = (url: URL, address: string, deadline: number) => Promise<HttpHop>;
|
||||||
|
export interface HttpRequestOptions {
|
||||||
|
resolve?: typeof resolvePublicHostDetailed;
|
||||||
|
request?: CandidateRequester;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeUrl(value: string | URL) {
|
||||||
|
const text = typeof value === "string" ? value : value.toString();
|
||||||
|
try {
|
||||||
|
const parsed = new URL(text);
|
||||||
|
parsed.username = "";
|
||||||
|
parsed.password = "";
|
||||||
|
return parsed.toString();
|
||||||
|
} catch {
|
||||||
|
return text.replace(/(\/\/)[^/\s@]+@/g, "$1[redacted]@");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedHop(hop: HttpHop): HttpHop {
|
||||||
|
return typeof hop.location === "string" ? { ...hop, location: safeUrl(hop.location) } : hop;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProbeUrl(url: URL) {
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) throw new InspectionPolicyError("Redirect used an unsupported protocol.");
|
||||||
|
if (url.username || url.password) throw new InspectionPolicyError("Redirect URLs with credentials are not allowed.");
|
||||||
|
if (url.port && url.port !== (url.protocol === "https:" ? "443" : "80")) {
|
||||||
|
throw new InspectionPolicyError("Redirect to a non-default port is not allowed.");
|
||||||
|
}
|
||||||
|
normalizeHost(url.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorCode(error: unknown) {
|
||||||
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
|
? String((error as { code?: unknown }).code ?? "")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function transportError(error: unknown) {
|
||||||
|
if (error instanceof InspectionInputError || error instanceof InspectionPolicyError || error instanceof InspectionResolutionError) return error.message;
|
||||||
|
if (errorCode(error) === "ETIMEOUT") return "HTTP request timed out.";
|
||||||
|
const code = errorCode(error);
|
||||||
|
return code ? "HTTP request failed (" + code + ")." : "HTTP request failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCandidate(url: URL, address: string, deadline: number): Promise<HttpHop> {
|
||||||
|
const budget = Math.min(HTTP_TIMEOUT_MS, remainingMs(deadline));
|
||||||
|
if (!budget) return Promise.reject(timeoutError("HTTP request timed out."));
|
||||||
|
const host = normalizeHost(url.hostname);
|
||||||
|
const client = url.protocol === "https:" ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
// The request constructor can throw before a timer exists; the callback
|
||||||
|
// therefore needs an intentionally late-bound timer reference.
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const finish = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
let request: ReturnType<typeof client.request>;
|
||||||
|
try {
|
||||||
|
request = client.request({
|
||||||
|
hostname: address,
|
||||||
|
port: url.protocol === "https:" ? 443 : 80,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: "GET",
|
||||||
|
servername: url.protocol === "https:" ? host : undefined,
|
||||||
|
rejectUnauthorized: url.protocol === "https:" ? true : undefined,
|
||||||
|
headers: { host, "user-agent": "CertScope/" + CERTSCOPE_VERSION },
|
||||||
|
timeout: budget,
|
||||||
|
}, (response) => {
|
||||||
|
if (settled) {
|
||||||
|
response.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headers = Object.fromEntries(
|
||||||
|
HEADER_NAMES.map((name) => [name, response.headers[name] ?? null]),
|
||||||
|
) as HeaderMap;
|
||||||
|
finish(() => {
|
||||||
|
response.destroy();
|
||||||
|
resolve({
|
||||||
|
url: url.toString(),
|
||||||
|
address,
|
||||||
|
status: response.statusCode ?? null,
|
||||||
|
location: response.headers.location ?? null,
|
||||||
|
headers,
|
||||||
|
headerPosture: analyzeHeaders(headers),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
finish(() => reject(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const abortOnTimeout = () => finish(() => {
|
||||||
|
request.destroy();
|
||||||
|
reject(timeoutError("HTTP request timed out."));
|
||||||
|
});
|
||||||
|
timer = setTimeout(abortOnTimeout, budget);
|
||||||
|
request.once("timeout", abortOnTimeout);
|
||||||
|
request.once("error", (error) => finish(() => reject(error)));
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestOnce(url: URL, deadline = createDeadline(HTTP_TIMEOUT_MS), options: HttpRequestOptions = {}): Promise<HttpHop> {
|
||||||
|
validateProbeUrl(url);
|
||||||
|
const resolution = await (options.resolve ?? resolvePublicHostDetailed)(url.hostname, deadline);
|
||||||
|
const addresses = validateResolvedAddresses(resolution.addresses);
|
||||||
|
const request = options.request ?? requestCandidate;
|
||||||
|
const attempts: Array<Record<string, unknown>> = [];
|
||||||
|
for (const address of addresses.slice(0, MAX_ADDRESS_ATTEMPTS)) {
|
||||||
|
if (!remainingMs(deadline)) break;
|
||||||
|
try {
|
||||||
|
const result = await request(url, address, deadline);
|
||||||
|
return { ...result, address: result.address ?? address, addresses, resolution: resolution._meta, attempts };
|
||||||
|
} catch (error) {
|
||||||
|
attempts.push({ address, error: transportError(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const message = remainingMs(deadline) ? "HTTP request failed for all resolved addresses." : "HTTP probe deadline exhausted.";
|
||||||
|
return { url: safeUrl(url), addresses, attempts, error: message, resolution: resolution._meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeRequest(url: URL, requester: Requester, deadline: number, markPolicyBlock = false): Promise<HttpHop> {
|
||||||
|
try {
|
||||||
|
validateProbeUrl(url);
|
||||||
|
if (!remainingMs(deadline)) return { url: safeUrl(url), error: "HTTP probe deadline exhausted." };
|
||||||
|
return await withDeadline(requester(url, deadline), deadline, () => undefined, "HTTP request timed out.");
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
url: safeUrl(url),
|
||||||
|
error: transportError(error),
|
||||||
|
...(markPolicyBlock && (error instanceof InspectionInputError || error instanceof InspectionPolicyError)
|
||||||
|
? { code: "REDIRECT_TARGET_BLOCKED" }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedUrlKey(url: URL) {
|
||||||
|
const host = normalizeHost(url.hostname);
|
||||||
|
const port = url.port && url.port !== (url.protocol === "https:" ? "443" : "80") ? ":" + url.port : "";
|
||||||
|
return url.protocol.toLowerCase() + "//" + host + port + (url.pathname || "/") + url.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function followRedirectChain(
|
||||||
|
start: URL,
|
||||||
|
requester: Requester = requestOnce,
|
||||||
|
maxRedirects = DEFAULT_MAX_REDIRECTS,
|
||||||
|
deadline = createDeadline(HTTP_TOTAL_TIMEOUT_MS),
|
||||||
|
): Promise<HttpHop[]> {
|
||||||
|
const chain: HttpHop[] = [];
|
||||||
|
const redirectLimit = Math.max(0, Math.floor(maxRedirects));
|
||||||
|
let current = start;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
for (let index = 0; index <= redirectLimit; index++) {
|
||||||
|
let key: string;
|
||||||
|
try {
|
||||||
|
key = normalizedUrlKey(current);
|
||||||
|
} catch (error) {
|
||||||
|
chain.push({ url: safeUrl(current), error: error instanceof Error ? error.message : "Invalid redirect target." });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (visited.has(key)) {
|
||||||
|
chain.push({ url: current.toString(), error: REDIRECT_LOOP_ERROR, code: "REDIRECT_LOOP" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visited.add(key);
|
||||||
|
|
||||||
|
const hop = await safeRequest(current, requester, deadline, index > 0);
|
||||||
|
chain.push(sanitizedHop(hop));
|
||||||
|
if ("error" in hop) break;
|
||||||
|
|
||||||
|
const status = Number(hop.status ?? 0);
|
||||||
|
const location = typeof hop.location === "string" ? hop.location : null;
|
||||||
|
if (status < 300 || status >= 400 || !location) break;
|
||||||
|
if (index === redirectLimit) {
|
||||||
|
chain.push({ url: current.toString(), error: "Redirect limit reached", code: "REDIRECT_LIMIT" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const next = new URL(location, current);
|
||||||
|
validateProbeUrl(next);
|
||||||
|
if (current.protocol === "https:" && next.protocol === "http:") {
|
||||||
|
chain.push({ url: next.toString(), error: HTTPS_DOWNGRADE_ERROR, code: "HTTPS_DOWNGRADE", from: current.toString() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (visited.has(normalizedUrlKey(next))) {
|
||||||
|
chain.push({ url: next.toString(), error: REDIRECT_LOOP_ERROR, code: "REDIRECT_LOOP", from: current.toString() });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
} catch (error) {
|
||||||
|
chain.push({
|
||||||
|
url: safeUrl(location),
|
||||||
|
error: error instanceof Error ? error.message : "Invalid redirect target.",
|
||||||
|
code: error instanceof InspectionInputError || error instanceof InspectionPolicyError ? "REDIRECT_TARGET_BLOCKED" : "INVALID_REDIRECT",
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorEvidence(hop: HttpHop, probe: string) {
|
||||||
|
return "error" in hop ? { probe, message: String(hop.error) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectHttp(host: string): Promise<Record<string, unknown>> {
|
||||||
|
const normalizedHost = normalizeHost(host);
|
||||||
|
const deadline = createDeadline(HTTP_TOTAL_TIMEOUT_MS);
|
||||||
|
const rawHttpResult = await safeRequest(new URL("http://" + normalizedHost + "/"), requestOnce, deadline);
|
||||||
|
const httpResult = sanitizedHop(rawHttpResult);
|
||||||
|
let httpRedirectsToHttps = false;
|
||||||
|
let httpRedirectTarget: HttpHop | null = null;
|
||||||
|
|
||||||
|
if (!("error" in rawHttpResult)) {
|
||||||
|
const status = Number(rawHttpResult.status ?? 0);
|
||||||
|
const location = typeof rawHttpResult.location === "string" ? rawHttpResult.location : null;
|
||||||
|
if (status >= 300 && status < 400 && location) {
|
||||||
|
try {
|
||||||
|
const target = new URL(location, new URL("http://" + normalizedHost + "/"));
|
||||||
|
validateProbeUrl(target);
|
||||||
|
const resolution = await resolvePublicHostDetailed(target.hostname, deadline);
|
||||||
|
httpRedirectsToHttps = target.protocol === "https:";
|
||||||
|
httpRedirectTarget = { url: target.toString(), addresses: resolution.addresses, resolution: resolution._meta, validated: true };
|
||||||
|
} catch (error) {
|
||||||
|
httpRedirectTarget = {
|
||||||
|
url: safeUrl(location),
|
||||||
|
error: error instanceof Error ? error.message : "Invalid redirect target.",
|
||||||
|
code: error instanceof InspectionInputError || error instanceof InspectionPolicyError ? "REDIRECT_TARGET_BLOCKED" : "INVALID_REDIRECT",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpsChain = await followRedirectChain(new URL("https://" + normalizedHost + "/"), requestOnce, DEFAULT_MAX_REDIRECTS, deadline);
|
||||||
|
const finalHop = httpsChain.at(-1) && !("error" in httpsChain.at(-1)!) ? httpsChain.at(-1)! : null;
|
||||||
|
const downgrade = httpsChain.some((hop) => hop.code === "HTTPS_DOWNGRADE");
|
||||||
|
const evidenceErrors = [
|
||||||
|
errorEvidence(httpResult, "HTTP"),
|
||||||
|
...(httpRedirectTarget && "error" in httpRedirectTarget
|
||||||
|
? [{ probe: "HTTP redirect", message: String(httpRedirectTarget.error) }]
|
||||||
|
: []),
|
||||||
|
...httpsChain.map((hop) => errorEvidence(hop, "HTTPS")),
|
||||||
|
].filter((item): item is { probe: string; message: string } => Boolean(item));
|
||||||
|
const hasSuccessfulObservation = ![httpResult, ...httpsChain].every((hop) => "error" in hop);
|
||||||
|
const status = evidenceErrors.length ? (hasSuccessfulObservation ? "partial" : "failed") : "ok";
|
||||||
|
|
||||||
|
return {
|
||||||
|
http: httpResult,
|
||||||
|
httpRedirectsToHttps,
|
||||||
|
httpRedirectTarget,
|
||||||
|
httpsChain,
|
||||||
|
httpsDowngrade: downgrade,
|
||||||
|
finalUrl: finalHop && typeof finalHop.url === "string" ? finalHop.url : null,
|
||||||
|
_meta: { status, errors: evidenceErrors },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildCompleteness } from "./inspect";
|
||||||
|
|
||||||
|
describe("inspection completeness", () => {
|
||||||
|
it("counts complete collectors and keeps unique issue messages", () => {
|
||||||
|
const result = buildCompleteness(
|
||||||
|
{ _meta: { status: "ok", errors: [] } },
|
||||||
|
{ _meta: { status: "partial", errors: [{ probe: "TLS", message: "timeout" }, { probe: "TLS", message: "timeout" }] } },
|
||||||
|
{ _meta: { status: "failed", errors: [{ probe: "HTTPS", message: "failed" }] } },
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: "partial",
|
||||||
|
complete: 1,
|
||||||
|
total: 3,
|
||||||
|
collectors: { dns: "ok", tls: "partial", http: "failed" },
|
||||||
|
issues: ["timeout", "failed"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a report failed when no collector completed", () => {
|
||||||
|
expect(buildCompleteness({}, {}, {}).status).toBe("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { collectDns } from "./dns";
|
||||||
|
import { evaluateDiagnostics } from "./diagnostics";
|
||||||
|
import { collectTls } from "./tls";
|
||||||
|
import { collectHttp } from "./http";
|
||||||
|
import { normalizeHost, validateResolvedAddresses } from "./policy";
|
||||||
|
import { score } from "./score";
|
||||||
|
import type { Category, CollectorStatus, InspectionResult } from "./types";
|
||||||
|
|
||||||
|
function collectorStatus(value: Record<string, unknown>): CollectorStatus {
|
||||||
|
const meta = value._meta;
|
||||||
|
if (!meta || typeof meta !== "object" || typeof (meta as Record<string, unknown>).status !== "string") return "failed";
|
||||||
|
const status = String((meta as Record<string, unknown>).status);
|
||||||
|
return status === "ok" || status === "partial" || status === "failed" ? status : "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCompleteness(dns: Record<string, unknown>, tls: Record<string, unknown>, http: Record<string, unknown>) {
|
||||||
|
const collectors: Record<Category, CollectorStatus> = {
|
||||||
|
dns: collectorStatus(dns),
|
||||||
|
tls: collectorStatus(tls),
|
||||||
|
http: collectorStatus(http),
|
||||||
|
};
|
||||||
|
const statuses = Object.values(collectors);
|
||||||
|
const complete = statuses.filter((status) => status === "ok").length;
|
||||||
|
const issues = [dns, tls, http].flatMap((collector) => {
|
||||||
|
const errors = collector._meta && typeof collector._meta === "object" ? (collector._meta as Record<string, unknown>).errors : [];
|
||||||
|
return Array.isArray(errors) ? errors.map((error) => typeof error === "object" && error !== null && "message" in error ? String((error as { message?: unknown }).message) : String(error)) : [];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: complete === statuses.length ? "complete" as const : complete === 0 ? "failed" as const : "partial" as const,
|
||||||
|
complete,
|
||||||
|
total: statuses.length,
|
||||||
|
collectors,
|
||||||
|
issues: [...new Set(issues)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectHost(rawHost: string): Promise<InspectionResult> {
|
||||||
|
const host = normalizeHost(rawHost);
|
||||||
|
const dns = await collectDns(host);
|
||||||
|
const addresses = validateResolvedAddresses([
|
||||||
|
...(Array.isArray(dns.a) ? dns.a.filter((value): value is string => typeof value === "string") : []),
|
||||||
|
...(Array.isArray(dns.aaaa) ? dns.aaaa.filter((value): value is string => typeof value === "string") : []),
|
||||||
|
]);
|
||||||
|
const [tls, http] = await Promise.all([collectTls(host, addresses), collectHttp(host)]);
|
||||||
|
const findings = evaluateDiagnostics(dns, tls, http);
|
||||||
|
return { host, inspectedAt: new Date().toISOString(), addresses, completeness: buildCompleteness(dns, tls, http), dns, tls, http, findings, score: score(findings) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { assertPublicAddress, normalizeHost, validateResolvedAddresses } from "./policy";
|
||||||
|
|
||||||
|
describe("hostname policy",()=>{
|
||||||
|
it("normalizes public hostnames",()=>expect(normalizeHost("Example.COM.")).toBe("example.com"));
|
||||||
|
it("normalizes IDN input to its ASCII form",()=>expect(normalizeHost("münich.example")).toBe("xn--mnich-kva.example"));
|
||||||
|
it("rejects oversized input before IDN conversion",()=>expect(()=>normalizeHost("a".repeat(254)+".example")).toThrow());
|
||||||
|
it.each(["https://example.com","localhost","127.0.0.1","::1"])("rejects non-hostname input %s",(value)=>expect(()=>normalizeHost(value)).toThrow());
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("address policy",()=>{
|
||||||
|
it.each(["127.0.0.1","10.0.0.1","192.168.1.5","169.254.1.1","100.64.0.1","224.0.0.1","::1","fc00::1","fe80::1","::ffff:127.0.0.1"])("rejects non-public address %s",(value)=>expect(()=>assertPublicAddress(value)).toThrow());
|
||||||
|
it.each(["1.1.1.1","8.8.8.8","2606:4700:4700::1111"])("accepts public unicast address %s",(value)=>expect(()=>assertPublicAddress(value)).not.toThrow());
|
||||||
|
it("validates every resolved address before returning any candidate", () => {
|
||||||
|
expect(() => validateResolvedAddresses(["1.1.1.1", "192.168.1.10"])).toThrow(/non-public address space/);
|
||||||
|
expect(validateResolvedAddresses(["1.1.1.1", "1.1.1.1"])).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ipaddr from "ipaddr.js";
|
||||||
|
import { domainToASCII } from "node:url";
|
||||||
|
import { DNS_TIMEOUT_MS, resolveDnsFamily, type DnsFamilyResult } from "./dns";
|
||||||
|
import { createDeadline } from "./deadline";
|
||||||
|
|
||||||
|
const HOST_RE = /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||||
|
export { DNS_TIMEOUT_MS } from "./dns";
|
||||||
|
|
||||||
|
export class InspectionInputError extends Error {
|
||||||
|
readonly kind = "input" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InspectionPolicyError extends Error {
|
||||||
|
readonly kind = "policy" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InspectionResolutionError extends Error {
|
||||||
|
readonly kind = "resolution" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHost(input: string) {
|
||||||
|
if (typeof input !== "string") throw new InspectionInputError("Enter a public DNS hostname, not a URL or IP address.");
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed || trimmed.length > 253) throw new InspectionInputError("Enter a public DNS hostname, not a URL or IP address.");
|
||||||
|
const host = domainToASCII(trimmed.toLowerCase().replace(/\.$/, ""));
|
||||||
|
if (!HOST_RE.test(host) || ipaddr.isValid(host)) {
|
||||||
|
throw new InspectionInputError("Enter a public DNS hostname, not a URL or IP address.");
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPublicAddress(address: string) {
|
||||||
|
try {
|
||||||
|
const range = ipaddr.parse(address).range();
|
||||||
|
if (range !== "unicast") throw new InspectionPolicyError("Target resolved to non-public address space (" + range + ").");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InspectionPolicyError) throw error;
|
||||||
|
throw new InspectionPolicyError("Target resolved to an invalid address.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateResolvedAddresses(addresses: string[]) {
|
||||||
|
if (!addresses.length) throw new InspectionResolutionError("Hostname did not resolve.");
|
||||||
|
const normalized = [...new Set(addresses)];
|
||||||
|
normalized.forEach(assertPublicAddress);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicResolution {
|
||||||
|
addresses: string[];
|
||||||
|
families: { ipv4: DnsFamilyResult; ipv6: DnsFamilyResult };
|
||||||
|
_meta: { status: "ok" | "partial"; partial: boolean; errors: Array<{ probe: string; message: string }> };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePublicHostDetailed(host: string, deadline = createDeadline(DNS_TIMEOUT_MS)): Promise<PublicResolution> {
|
||||||
|
const normalized = normalizeHost(host);
|
||||||
|
const [ipv4, ipv6] = await Promise.all([
|
||||||
|
resolveDnsFamily(normalized, 4, deadline),
|
||||||
|
resolveDnsFamily(normalized, 6, deadline),
|
||||||
|
]);
|
||||||
|
const addresses = validateResolvedAddresses([...ipv4.addresses, ...ipv6.addresses]);
|
||||||
|
const errors = [ipv4.error, ipv6.error].filter((error): error is { probe: string; message: string } => Boolean(error));
|
||||||
|
return {
|
||||||
|
addresses,
|
||||||
|
families: { ipv4, ipv6 },
|
||||||
|
_meta: { status: errors.length ? "partial" : "ok", partial: errors.length > 0, errors },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolvePublicHost(host: string) {
|
||||||
|
return (await resolvePublicHostDetailed(host)).addresses;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { demoInspection } from "./demo";
|
||||||
|
import { inspectionToMarkdown } from "./report";
|
||||||
|
|
||||||
|
describe("report export", () => {
|
||||||
|
it("exports the health score, findings and normalized evidence", () => {
|
||||||
|
const report = inspectionToMarkdown(demoInspection);
|
||||||
|
expect(report).toContain("# CertScope report — portfolio-demo.invalid");
|
||||||
|
expect(report).toContain("Overall health: 98/100");
|
||||||
|
expect(report).toContain("Score formula: equal-weighted average");
|
||||||
|
expect(report).toContain("Completeness: complete (3/3 collectors complete)");
|
||||||
|
expect(report).toContain("- DNS: ok");
|
||||||
|
expect(report).toContain("Content-Security-Policy is missing");
|
||||||
|
expect(report).toContain("## DNS evidence");
|
||||||
|
expect(report).toContain("## TLS evidence");
|
||||||
|
expect(report).toContain("## HTTP evidence");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes finding text and keeps JSON evidence fences safe", () => {
|
||||||
|
const report = inspectionToMarkdown({
|
||||||
|
...demoInspection,
|
||||||
|
findings: [{
|
||||||
|
...demoInspection.findings[0],
|
||||||
|
title: "Injected *title* [value]",
|
||||||
|
summary: "line one\nline two",
|
||||||
|
}],
|
||||||
|
http: { ...demoInspection.http, injected: "```\nnot a markdown fence" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report).toContain("### Injected \\*title\\* \\[value\\]");
|
||||||
|
expect(report).toContain("- Summary: line one line two");
|
||||||
|
expect(report).toContain("injected");
|
||||||
|
expect(report.match(/^`{3,}(json)?$/gm)?.length).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { InspectionResult } from "./types";
|
||||||
|
|
||||||
|
function escapeMarkdown(value: unknown) {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/[|*_\x60[\]<>]/g, "\\$&")
|
||||||
|
.replace(/\r?\n/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonFence(value: unknown) {
|
||||||
|
const json = JSON.stringify(value, null, 2);
|
||||||
|
const backticks = Math.max(3, Math.max(...(json.match(/\x60+/g) ?? [""]).map((match) => match.length)) + 1);
|
||||||
|
const fence = "\x60".repeat(backticks);
|
||||||
|
return [fence + "json", json, fence];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectionToMarkdown(result: InspectionResult): string {
|
||||||
|
const lines = [
|
||||||
|
"# CertScope report — " + escapeMarkdown(result.host),
|
||||||
|
"",
|
||||||
|
"- Inspected: " + escapeMarkdown(result.inspectedAt),
|
||||||
|
"- Addresses: " + escapeMarkdown(result.addresses.join(", ") || "none"),
|
||||||
|
"- Overall health: " + result.score.overall + "/100",
|
||||||
|
"- DNS: " + result.score.categories.dns + "/100",
|
||||||
|
"- TLS: " + result.score.categories.tls + "/100",
|
||||||
|
"- HTTP: " + result.score.categories.http + "/100",
|
||||||
|
"- Score formula: equal-weighted average of the DNS, TLS and HTTP category scores",
|
||||||
|
"- Completeness: " + result.completeness.status + " (" + result.completeness.complete + "/" + result.completeness.total + " collectors complete)",
|
||||||
|
"",
|
||||||
|
"## Collector status",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [category, status] of Object.entries(result.completeness.collectors)) {
|
||||||
|
lines.push("- " + category.toUpperCase() + ": " + escapeMarkdown(status));
|
||||||
|
}
|
||||||
|
if (result.completeness.issues.length) lines.push("- Collector issues: " + escapeMarkdown(result.completeness.issues.join("; ")));
|
||||||
|
lines.push("", "## Findings", "");
|
||||||
|
|
||||||
|
if (!result.findings.length) lines.push("No findings.", "");
|
||||||
|
for (const finding of result.findings) {
|
||||||
|
lines.push("### " + escapeMarkdown(finding.title));
|
||||||
|
lines.push("- Severity: " + escapeMarkdown(finding.severity));
|
||||||
|
lines.push("- Category: " + escapeMarkdown(finding.category));
|
||||||
|
lines.push("- Score impact: " + finding.scoreImpact);
|
||||||
|
lines.push("- Summary: " + escapeMarkdown(finding.summary));
|
||||||
|
lines.push("- Evidence: " + escapeMarkdown(finding.evidence.join(", ") || "none"));
|
||||||
|
if (finding.remediation) lines.push("- Remediation: " + escapeMarkdown(finding.remediation));
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [title, evidence] of [["DNS", result.dns], ["TLS", result.tls], ["HTTP", result.http]] as const) {
|
||||||
|
lines.push("## " + title + " evidence", "", ...jsonFence(evidence), "");
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { score } from "./score";
|
||||||
|
import type { Finding } from "./types";
|
||||||
|
|
||||||
|
const finding = (impact: number, category: Finding["category"] = "http"): Finding => ({
|
||||||
|
id: `${category}-${impact}`,
|
||||||
|
category,
|
||||||
|
severity: "warning",
|
||||||
|
title: "test",
|
||||||
|
summary: "test",
|
||||||
|
evidence: [],
|
||||||
|
scoreImpact: impact,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("score", () => {
|
||||||
|
it("uses an equal-weighted aggregate of category scores", () => {
|
||||||
|
expect(score([finding(-18, "dns"), finding(-20, "tls"), finding(-22, "http")])).toEqual({
|
||||||
|
overall: 80,
|
||||||
|
deductions: 20,
|
||||||
|
categories: { dns: 82, tls: 80, http: 78 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not charge category deductions a second time globally", () => {
|
||||||
|
expect(score([finding(-8), finding(-6, "tls")])).toEqual({
|
||||||
|
overall: 95,
|
||||||
|
deductions: 5,
|
||||||
|
categories: { dns: 100, tls: 94, http: 92 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds and clamps category and overall scores", () => {
|
||||||
|
expect(score([finding(-200, "dns"), finding(-200, "tls"), finding(-200, "http")])).toEqual({
|
||||||
|
overall: 0,
|
||||||
|
deductions: 100,
|
||||||
|
categories: { dns: 0, tls: 0, http: 0 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores positive or non-finite score impacts", () => {
|
||||||
|
expect(score([finding(20, "dns"), finding(Number.NaN, "tls")]).overall).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Category, Finding, InspectionScore } from "./types";
|
||||||
|
|
||||||
|
const CATEGORY_ORDER: Category[] = ["dns", "tls", "http"];
|
||||||
|
|
||||||
|
function clamp(value: number) {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scores are calculated per category first, then averaged equally. This keeps
|
||||||
|
* one category's findings from being charged again against the global score.
|
||||||
|
*/
|
||||||
|
export function score(findings: Finding[]): InspectionScore {
|
||||||
|
const categories: Record<Category, number> = { dns: 100, tls: 100, http: 100 };
|
||||||
|
for (const finding of findings) {
|
||||||
|
const impact = Number.isFinite(finding.scoreImpact) ? Math.min(0, finding.scoreImpact) : 0;
|
||||||
|
categories[finding.category] = clamp(categories[finding.category] + impact);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overall = clamp(CATEGORY_ORDER.reduce((sum, category) => sum + categories[category], 0) / CATEGORY_ORDER.length);
|
||||||
|
return { overall, deductions: 100 - overall, categories };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import tls from "node:tls";
|
||||||
|
import { certificateDaysRemaining } from "./tls";
|
||||||
|
import { collectTls, type TlsConnector } from "./tls";
|
||||||
|
|
||||||
|
describe("TLS certificate date handling", () => {
|
||||||
|
it("returns whole days remaining from a supplied clock", () => {
|
||||||
|
const now = Date.parse("2026-01-01T12:00:00.000Z");
|
||||||
|
expect(certificateDaysRemaining("Jan 11 12:00:00 2026 GMT", now)).toBe(10);
|
||||||
|
expect(certificateDaysRemaining("Dec 31 12:00:00 2025 GMT", now)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not turn an invalid certificate date into a score deduction", () => {
|
||||||
|
expect(certificateDaysRemaining("not-a-certificate-date", Date.now())).toBeNull();
|
||||||
|
expect(certificateDaysRemaining(null, Date.now())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries the next validated address after a failed TLS connection", async () => {
|
||||||
|
const attempted: string[] = [];
|
||||||
|
const connect: TlsConnector = ((options) => {
|
||||||
|
const address = String(options.host);
|
||||||
|
attempted.push(address);
|
||||||
|
const socket = new EventEmitter() as EventEmitter & {
|
||||||
|
authorized: boolean;
|
||||||
|
authorizationError: null;
|
||||||
|
getPeerCertificate: () => Record<string, unknown>;
|
||||||
|
getProtocol: () => string;
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
socket.destroy = () => undefined;
|
||||||
|
if (address === "1.1.1.1") {
|
||||||
|
queueMicrotask(() => socket.emit("error", Object.assign(new Error("refused"), { code: "ECONNREFUSED" })));
|
||||||
|
} else {
|
||||||
|
socket.authorized = true;
|
||||||
|
socket.authorizationError = null;
|
||||||
|
socket.getProtocol = () => "TLSv1.3";
|
||||||
|
socket.getPeerCertificate = () => ({
|
||||||
|
subject: { CN: "example.com" },
|
||||||
|
issuer: { CN: "Example CA" },
|
||||||
|
subjectaltname: "DNS:example.com",
|
||||||
|
valid_from: "Jan 01 00:00:00 2026 GMT",
|
||||||
|
valid_to: "Jan 01 00:00:00 2027 GMT",
|
||||||
|
fingerprint256: "AA:BB",
|
||||||
|
});
|
||||||
|
queueMicrotask(() => socket.emit("secureConnect"));
|
||||||
|
}
|
||||||
|
return socket as unknown as ReturnType<typeof tls.connect>;
|
||||||
|
}) as TlsConnector;
|
||||||
|
|
||||||
|
const result = await collectTls("example.com", ["1.1.1.1", "2.2.2.2"], { connect, now: Date.parse("2026-06-01T00:00:00Z") });
|
||||||
|
expect(result).toMatchObject({ address: "2.2.2.2", protocol: "TLSv1.3", hostnameValid: true, authorized: true });
|
||||||
|
expect(attempted).toEqual(["1.1.1.1", "2.2.2.2"]);
|
||||||
|
expect(result.attempts).toEqual([{ address: "1.1.1.1", error: "TLS probe failed (ECONNREFUSED)." }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports certificates that are not yet valid", async () => {
|
||||||
|
const connect: TlsConnector = (() => {
|
||||||
|
const socket = new EventEmitter() as EventEmitter & {
|
||||||
|
authorized: boolean;
|
||||||
|
authorizationError: null;
|
||||||
|
getPeerCertificate: () => Record<string, unknown>;
|
||||||
|
getProtocol: () => string;
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
socket.authorized = true;
|
||||||
|
socket.authorizationError = null;
|
||||||
|
socket.getProtocol = () => "TLSv1.3";
|
||||||
|
socket.getPeerCertificate = () => ({
|
||||||
|
subjectaltname: "DNS:example.com",
|
||||||
|
valid_from: "Jul 01 00:00:00 2026 GMT",
|
||||||
|
valid_to: "Jan 01 00:00:00 2027 GMT",
|
||||||
|
});
|
||||||
|
socket.destroy = () => undefined;
|
||||||
|
queueMicrotask(() => socket.emit("secureConnect"));
|
||||||
|
return socket as unknown as ReturnType<typeof tls.connect>;
|
||||||
|
}) as TlsConnector;
|
||||||
|
const result = await collectTls("example.com", ["2.2.2.2"], { connect, now: Date.parse("2026-06-01T00:00:00Z") });
|
||||||
|
expect(result.notYetValid).toBe(true);
|
||||||
|
expect(result.daysRemaining).toBe(214);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks malformed or inverted certificate validity dates", async () => {
|
||||||
|
const connect: TlsConnector = (() => {
|
||||||
|
const socket = new EventEmitter() as EventEmitter & {
|
||||||
|
authorized: boolean;
|
||||||
|
authorizationError: null;
|
||||||
|
getPeerCertificate: () => Record<string, unknown>;
|
||||||
|
getProtocol: () => string;
|
||||||
|
destroy: () => void;
|
||||||
|
};
|
||||||
|
socket.authorized = true;
|
||||||
|
socket.authorizationError = null;
|
||||||
|
socket.getProtocol = () => "TLSv1.3";
|
||||||
|
socket.getPeerCertificate = () => ({
|
||||||
|
subjectaltname: "DNS:example.com",
|
||||||
|
valid_from: "Jan 02 00:00:00 2026 GMT",
|
||||||
|
valid_to: "Jan 01 00:00:00 2026 GMT",
|
||||||
|
});
|
||||||
|
socket.destroy = () => undefined;
|
||||||
|
queueMicrotask(() => socket.emit("secureConnect"));
|
||||||
|
return socket as unknown as ReturnType<typeof tls.connect>;
|
||||||
|
}) as TlsConnector;
|
||||||
|
const result = await collectTls("example.com", ["2.2.2.2"], { connect, now: Date.parse("2026-01-01T00:00:00Z") });
|
||||||
|
expect(result).toMatchObject({ validityDatesValid: false, daysRemaining: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks an exhausted address set as a failed collector", async () => {
|
||||||
|
const connect: TlsConnector = (() => {
|
||||||
|
const socket = new EventEmitter() as EventEmitter & { destroy: () => void };
|
||||||
|
socket.destroy = () => undefined;
|
||||||
|
queueMicrotask(() => socket.emit("error", Object.assign(new Error("unreachable"), { code: "ETIMEDOUT" })));
|
||||||
|
return socket as unknown as ReturnType<typeof tls.connect>;
|
||||||
|
}) as TlsConnector;
|
||||||
|
const result = await collectTls("example.com", ["2.2.2.2"], { connect, deadline: Date.now() + 1_000 });
|
||||||
|
expect(result._meta).toMatchObject({ status: "failed" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import tls from "node:tls";
|
||||||
|
import { createDeadline, remainingMs, timeoutError } from "./deadline";
|
||||||
|
import { validateResolvedAddresses } from "./policy";
|
||||||
|
|
||||||
|
export const TLS_TIMEOUT_MS = 5_000;
|
||||||
|
export const TLS_TOTAL_TIMEOUT_MS = 8_000;
|
||||||
|
export const TLS_MAX_ADDRESS_ATTEMPTS = 6;
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
type TlsError = { probe: "TLS"; message: string };
|
||||||
|
type TlsSocket = ReturnType<typeof tls.connect>;
|
||||||
|
export type TlsConnectOptions = tls.ConnectionOptions;
|
||||||
|
export type TlsConnector = (options: TlsConnectOptions) => TlsSocket;
|
||||||
|
|
||||||
|
export interface TlsCollectionOptions {
|
||||||
|
connect?: TlsConnector;
|
||||||
|
now?: number;
|
||||||
|
deadline?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(message: string, attempts: Array<Record<string, unknown>> = []): Record<string, unknown> {
|
||||||
|
const errors: TlsError[] = [{ probe: "TLS", message }];
|
||||||
|
return { error: message, attempts, _meta: { status: "failed", errors } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
const code = typeof error === "object" && error !== null && "code" in error
|
||||||
|
? String((error as { code?: unknown }).code ?? "")
|
||||||
|
: "";
|
||||||
|
return code ? "TLS probe failed (" + code + ")." : "TLS probe failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function certificateDaysRemaining(validTo: string | null | undefined, now = Date.now()) {
|
||||||
|
if (!validTo) return null;
|
||||||
|
const timestamp = Date.parse(validTo);
|
||||||
|
if (!Number.isFinite(timestamp)) return null;
|
||||||
|
return Math.floor((timestamp - now) / DAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function certificateNotYetValid(validFrom: string | null | undefined, now: number) {
|
||||||
|
if (!validFrom) return null;
|
||||||
|
const timestamp = Date.parse(validFrom);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp > now : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certificateValidityDatesValid(validFrom: string | null | undefined, validTo: string | null | undefined) {
|
||||||
|
if (!validFrom || !validTo) return false;
|
||||||
|
const from = Date.parse(validFrom);
|
||||||
|
const to = Date.parse(validTo);
|
||||||
|
return Number.isFinite(from) && Number.isFinite(to) && from <= to;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTlsCandidate(host: string, address: string, deadline: number, connect: TlsConnector, now: number): Promise<Record<string, unknown>> {
|
||||||
|
const budget = Math.min(TLS_TIMEOUT_MS, remainingMs(deadline));
|
||||||
|
if (!budget) return Promise.reject(timeoutError("TLS handshake timed out."));
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let socket: TlsSocket | undefined;
|
||||||
|
let settled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const done = (callback: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
socket?.destroy();
|
||||||
|
callback();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = (error: unknown) => done(() => reject(error));
|
||||||
|
try {
|
||||||
|
socket = connect({
|
||||||
|
host: address,
|
||||||
|
port: 443,
|
||||||
|
servername: host,
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
timeout: budget,
|
||||||
|
});
|
||||||
|
timer = setTimeout(() => fail(timeoutError("TLS handshake timed out.")), budget);
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.once("secureConnect", () => {
|
||||||
|
try {
|
||||||
|
const cert = socket!.getPeerCertificate(true);
|
||||||
|
if (!cert || !Object.keys(cert).length) {
|
||||||
|
fail(new Error("TLS peer certificate was unavailable."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hostnameError = tls.checkServerIdentity(host, cert);
|
||||||
|
const validTo = cert.valid_to ?? null;
|
||||||
|
const validFrom = cert.valid_from ?? null;
|
||||||
|
const result = {
|
||||||
|
address,
|
||||||
|
protocol: socket!.getProtocol(),
|
||||||
|
authorized: socket!.authorized,
|
||||||
|
authorizationError: socket!.authorizationError ? String(socket!.authorizationError) : null,
|
||||||
|
hostnameValid: !hostnameError,
|
||||||
|
hostnameError: hostnameError?.message ?? null,
|
||||||
|
subject: cert.subject ?? null,
|
||||||
|
issuer: cert.issuer ?? null,
|
||||||
|
subjectaltname: cert.subjectaltname ?? null,
|
||||||
|
validFrom,
|
||||||
|
validTo,
|
||||||
|
validityDatesValid: certificateValidityDatesValid(validFrom, validTo),
|
||||||
|
daysRemaining: certificateDaysRemaining(validTo, now),
|
||||||
|
notYetValid: certificateNotYetValid(validFrom, now),
|
||||||
|
fingerprint256: cert.fingerprint256 ?? null,
|
||||||
|
_meta: { status: "ok", errors: [] },
|
||||||
|
};
|
||||||
|
done(() => resolve(result));
|
||||||
|
} catch (error) {
|
||||||
|
fail(new Error(error instanceof Error ? "TLS certificate could not be parsed." : "TLS probe failed."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.once("timeout", () => fail(timeoutError("TLS handshake timed out.")));
|
||||||
|
socket.once("error", (error) => fail(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function collectTls(host: string, rawAddresses: string[] | string, options: TlsCollectionOptions = {}): Promise<Record<string, unknown>> {
|
||||||
|
const addresses = validateResolvedAddresses(Array.isArray(rawAddresses) ? rawAddresses : [rawAddresses]);
|
||||||
|
const deadline = options.deadline ?? createDeadline(TLS_TOTAL_TIMEOUT_MS);
|
||||||
|
const connect = options.connect ?? ((connectionOptions) => tls.connect(connectionOptions));
|
||||||
|
const now = options.now ?? Date.now();
|
||||||
|
const attempts: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
|
for (const address of addresses.slice(0, TLS_MAX_ADDRESS_ATTEMPTS)) {
|
||||||
|
if (!remainingMs(deadline)) break;
|
||||||
|
try {
|
||||||
|
return { ...(await collectTlsCandidate(host, address, deadline, connect, now)), attempts };
|
||||||
|
} catch (error) {
|
||||||
|
attempts.push({ address, error: errorMessage(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = remainingMs(deadline) ? "TLS probe failed for all resolved addresses." : "TLS probe deadline exhausted.";
|
||||||
|
return failure(message, attempts);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type Category = "dns" | "tls" | "http";
|
||||||
|
export type Severity = "critical" | "warning" | "info" | "good";
|
||||||
|
export type CollectorStatus = "ok" | "partial" | "failed";
|
||||||
|
export type CompletenessStatus = "complete" | "partial" | "failed";
|
||||||
|
export interface ProbeError { probe: string; message: string; }
|
||||||
|
export interface CollectorMeta { status: CollectorStatus; errors: ProbeError[]; }
|
||||||
|
export interface Completeness {
|
||||||
|
status: CompletenessStatus;
|
||||||
|
complete: number;
|
||||||
|
total: number;
|
||||||
|
collectors: Record<Category, CollectorStatus>;
|
||||||
|
issues: string[];
|
||||||
|
}
|
||||||
|
export interface Finding { id:string; category:Category; severity:Severity; title:string; summary:string; evidence:string[]; remediation?:string; scoreImpact:number; }
|
||||||
|
export interface InspectionScore {
|
||||||
|
overall: number;
|
||||||
|
/** The aggregate loss from a perfect overall score, not the sum of category penalties. */
|
||||||
|
deductions: number;
|
||||||
|
categories: Record<Category, number>;
|
||||||
|
}
|
||||||
|
export interface InspectionResult { host:string; inspectedAt:string; addresses:string[]; completeness: Completeness; dns:Record<string,unknown>; tls:Record<string,unknown>; http:Record<string,unknown>; findings:Finding[]; score: InspectionScore; }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { demoInspection } from "./demo";
|
||||||
|
import { isInspectionResult } from "./validation";
|
||||||
|
|
||||||
|
describe("inspection result runtime validation", () => {
|
||||||
|
it("accepts the normalized result contract", () => {
|
||||||
|
expect(isInspectionResult(demoInspection)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
{},
|
||||||
|
{ ...demoInspection, host: "" },
|
||||||
|
{ ...demoInspection, inspectedAt: "not-a-date" },
|
||||||
|
{ ...demoInspection, score: { overall: 100, categories: { dns: 100 } } },
|
||||||
|
{ ...demoInspection, completeness: { ...demoInspection.completeness, collectors: { dns: "unknown", tls: "ok", http: "ok" } } },
|
||||||
|
{ ...demoInspection, completeness: { ...demoInspection.completeness, complete: 4, total: 3 } },
|
||||||
|
{ ...demoInspection, findings: [{ id: "missing-fields" }] },
|
||||||
|
{ ...demoInspection, findings: [{ ...demoInspection.findings[0], category: "unknown" }] },
|
||||||
|
{ ...demoInspection, findings: [{ ...demoInspection.findings[0], evidence: [42] }] },
|
||||||
|
{ ...demoInspection, findings: [{ ...demoInspection.findings[0], remediation: { unexpected: true } }] },
|
||||||
|
])("rejects malformed payload %#", (payload) => {
|
||||||
|
expect(isInspectionResult(payload)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { InspectionResult } from "./types";
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteScore(value: unknown) {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectorStatus(value: unknown) {
|
||||||
|
return value === "ok" || value === "partial" || value === "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFinding(value: unknown) {
|
||||||
|
if (!isRecord(value)) return false;
|
||||||
|
const category = value.category;
|
||||||
|
const severity = value.severity;
|
||||||
|
return typeof value.id === "string" && typeof value.title === "string" && typeof value.summary === "string"
|
||||||
|
&& (category === "dns" || category === "tls" || category === "http")
|
||||||
|
&& (severity === "critical" || severity === "warning" || severity === "info" || severity === "good")
|
||||||
|
&& typeof value.scoreImpact === "number" && Number.isFinite(value.scoreImpact)
|
||||||
|
&& Array.isArray(value.evidence) && value.evidence.every((pointer) => typeof pointer === "string")
|
||||||
|
&& (value.remediation === undefined || typeof value.remediation === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately small client boundary: it validates the shape ReportView
|
||||||
|
* depends on without duplicating server-side schema logic or adding a runtime
|
||||||
|
* validation dependency to this compact project.
|
||||||
|
*/
|
||||||
|
export function isInspectionResult(value: unknown): value is InspectionResult {
|
||||||
|
if (!isRecord(value) || typeof value.host !== "string" || !value.host || typeof value.inspectedAt !== "string" || !Number.isFinite(Date.parse(value.inspectedAt))) return false;
|
||||||
|
if (!Array.isArray(value.addresses) || !value.addresses.every((address) => typeof address === "string")) return false;
|
||||||
|
const score = value.score;
|
||||||
|
if (!isRecord(score) || !finiteScore(score.overall) || !isRecord(score.categories)) return false;
|
||||||
|
const categories = score.categories;
|
||||||
|
if (!["dns", "tls", "http"].every((category) => finiteScore(categories[category]))) return false;
|
||||||
|
const completeness = value.completeness;
|
||||||
|
if (!isRecord(completeness) || !["complete", "partial", "failed"].includes(String(completeness.status))) return false;
|
||||||
|
const complete = completeness.complete;
|
||||||
|
const total = completeness.total;
|
||||||
|
if (!nonNegativeInteger(complete) || !nonNegativeInteger(total) || complete > total || !isRecord(completeness.collectors) || !Array.isArray(completeness.issues)) return false;
|
||||||
|
const collectors = completeness.collectors;
|
||||||
|
if (!["dns", "tls", "http"].every((category) => collectorStatus(collectors[category])) || !completeness.issues.every((issue) => typeof issue === "string")) return false;
|
||||||
|
if (!Array.isArray(value.findings) || !value.findings.every(isFinding)) return false;
|
||||||
|
return isRecord(value.dns) && isRecord(value.tls) && isRecord(value.http);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import packageJson from "../package.json";
|
||||||
|
|
||||||
|
export const CERTSCOPE_VERSION = packageJson.version;
|
||||||
|
export const CERTSCOPE_REVISION = process.env.CERTSCOPE_BUILD_REVISION || "unknown";
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const securityHeaders = [
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "X-DNS-Prefetch-Control", value: "off" },
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
|
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
|
||||||
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; worker-src 'self' blob:",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
poweredByHeader: false,
|
||||||
|
async headers() {
|
||||||
|
return [{ source: "/(.*)", headers: securityHeaders }];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+7505
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "certscope",
|
||||||
|
"version": "1.0.4",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "node scripts/start-standalone.mjs",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"sbom": "node scripts/generate-sbom.mjs",
|
||||||
|
"capture:portfolio": "node scripts/capture-portfolio.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ipaddr.js": "^2.2.0",
|
||||||
|
"next": "16.3.5",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "^4.13.0",
|
||||||
|
"@playwright/test": "1.63.0",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"eslint": "^9.39.5",
|
||||||
|
"eslint-config-next": "^16.3.5",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vitest": "4.1.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
const baseURL = process.env.E2E_BASE_URL || "http://127.0.0.1:3100";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: Boolean(process.env.CI),
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
reporter: "line",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
viewport: { width: 1440, height: 1000 },
|
||||||
|
},
|
||||||
|
webServer: process.env.E2E_BASE_URL
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
command: "npm run start",
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
timeout: 120_000,
|
||||||
|
env: {
|
||||||
|
HOSTNAME: "127.0.0.1",
|
||||||
|
PORT: "3100",
|
||||||
|
NODE_ENV: "production",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const npmArgs = [
|
||||||
|
"sbom",
|
||||||
|
"--package-lock-only",
|
||||||
|
"--omit=dev",
|
||||||
|
"--sbom-format=cyclonedx",
|
||||||
|
"--sbom-type=application",
|
||||||
|
];
|
||||||
|
const command = process.platform === "win32" ? (process.env.ComSpec || "cmd.exe") : "npm";
|
||||||
|
const commandArgs = process.platform === "win32" ? ["/d", "/s", "/c", "npm.cmd " + npmArgs.join(" ")] : npmArgs;
|
||||||
|
const output = execFileSync(command, commandArgs, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
||||||
|
|
||||||
|
await writeFile("certscope-sbom.cdx.json", output, "utf8");
|
||||||
|
console.log("Wrote certscope-sbom.cdx.json");
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { cp, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(fileURLToPath(new URL("../", import.meta.url)));
|
||||||
|
const standaloneRoot = path.join(projectRoot, ".next", "standalone");
|
||||||
|
|
||||||
|
await mkdir(path.join(standaloneRoot, ".next", "static"), { recursive: true });
|
||||||
|
await cp(path.join(projectRoot, ".next", "static"), path.join(standaloneRoot, ".next", "static"), { recursive: true, force: true });
|
||||||
|
|
||||||
|
await mkdir(path.join(standaloneRoot, "public"), { recursive: true });
|
||||||
|
try {
|
||||||
|
await cp(path.join(projectRoot, "public"), path.join(standaloneRoot, "public"), { recursive: true, force: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code !== "ENOENT") throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.chdir(standaloneRoot);
|
||||||
|
await import(pathToFileURL(path.join(standaloneRoot, "server.js")).href);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es2022"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/** @type {import("vitest/config").UserConfig} */
|
||||||
|
const config = {
|
||||||
|
resolve: { alias: { "@": fileURLToPath(new URL("./", import.meta.url)) } },
|
||||||
|
test: {
|
||||||
|
include: ["lib/**/*.test.ts", "app/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
Reference in new issue
Block a user