From 4302e5ecc2e7598b2351d18b0e0020187fc8231e Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:35:59 +0000 Subject: [PATCH 01/32] feat(web): static Next.js report page and touchstone serve (#9) --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index c2658d7..ec484cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ node_modules/ +# next build output and its generated type shim; web/out is embedded at build time +web/.next/ +web/next-env.d.ts +web/out/* +!web/out/.gitkeep From 83c4b41caeccaafbd0557fa4644b53b89b8264ed Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:00 +0000 Subject: [PATCH 02/32] feat(web): static Next.js report page and touchstone serve (#9) --- Makefile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c132d4f..f5aea9b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: generate lint breaking test web-test +.PHONY: generate lint breaking test web-test web-build # Requires buf (https://buf.build): regenerates gen/, web/src/gen/, # openapi/ and schema/ from proto/touchstone/v1/scan.proto, using the @@ -22,3 +22,8 @@ test: web-test: cd web && npm install --no-package-lock && npx tsc --noEmit && npx vitest run + +# Exports the page to web/out, which web/embed.go embeds into touchstone serve. +# Build it before `go build ./cmd/touchstone`. +web-build: + cd web && npm install --no-package-lock && npx next build From 9356839fbdb91be1ba270db6b02a52dd51927ee7 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:01 +0000 Subject: [PATCH 03/32] feat(web): static Next.js report page and touchstone serve (#9) --- cmd/touchstone/main.go | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index 19cf4e7..ee3c2c7 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -1,7 +1,8 @@ // Command touchstone scans a vendor URL and prints an evidence-graded // ScanReport as protojson (--format json, the default) or as Markdown -// (--format md). The scan fetches the page and its scripts passively and -// looks components up in OSV (slice S2). +// (--format md). In slice S0 the scan is served by the stub ScanService, +// so the report is canned. `touchstone serve` runs the embedded web page +// and POST /v1/scans on 127.0.0.1:8080 (serve.go). package main import ( @@ -16,7 +17,7 @@ import ( "github.com/FootprintAI/touchstone/internal/fetch" "github.com/FootprintAI/touchstone/internal/report" "github.com/FootprintAI/touchstone/internal/service" - "github.com/FootprintAI/touchstone/internal/vuln" + "github.com/FootprintAI/touchstone/web" "google.golang.org/protobuf/encoding/protojson" ) @@ -28,15 +29,29 @@ const usage = "usage: touchstone scan [--allow-private] [--format json|md] type newService func(fetch.Options) touchstonev1.ScanServiceServer func main() { - newSvc := func(o fetch.Options) touchstonev1.ScanServiceServer { - return &service.Scanner{Fetch: o, Vulns: vuln.NewOSV()} + ctx := context.Background() + var err error + if len(os.Args) > 1 && os.Args[1] == "serve" { + err = serveMain(ctx, os.Args[2:]) + } else { + newSvc := func(fetch.Options) touchstonev1.ScanServiceServer { return &service.Stub{} } + err = run(ctx, os.Args[1:], os.Stdout, newSvc) } - if err := run(context.Background(), os.Args[1:], os.Stdout, newSvc); err != nil { + if err != nil { fmt.Fprintln(os.Stderr, "touchstone:", err) os.Exit(2) } } +// serveMain loads the embedded web page and runs serve with it. +func serveMain(ctx context.Context, args []string) error { + static, err := web.Static() + if err != nil { + return err + } + return serve(ctx, args, static) +} + func run(ctx context.Context, args []string, out io.Writer, newSvc newService) error { if len(args) == 0 || args[0] != "scan" { return errors.New(usage) From 4a75138a972a6e59b34e0677346687af5f65627b Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:03 +0000 Subject: [PATCH 04/32] feat(web): static Next.js report page and touchstone serve (#9) --- cmd/touchstone/serve.go | 73 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 cmd/touchstone/serve.go diff --git a/cmd/touchstone/serve.go b/cmd/touchstone/serve.go new file mode 100644 index 0000000..754f25d --- /dev/null +++ b/cmd/touchstone/serve.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/FootprintAI/touchstone/internal/server" + "github.com/FootprintAI/touchstone/internal/service" +) + +const ( + serveUsage = "usage: touchstone serve [--addr host:port]" + // defaultAddr is local-only on purpose: serve has no auth (design D3). + defaultAddr = "127.0.0.1:8080" +) + +// serve runs the web page and the ScanService HTTP API until interrupted. +// It has no --allow-private: the server never fetches private addresses. +func serve(ctx context.Context, args []string, static fs.FS) error { + addr, err := parseServeArgs(args) + if err != nil { + return err + } + svc := &service.Stub{} + handler, err := server.New(ctx, svc, static) + if err != nil { + return err + } + srv := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second} + + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + errc := make(chan error, 1) + go func() { errc <- srv.ListenAndServe() }() + fmt.Fprintf(os.Stderr, "touchstone: serving on http://%s\n", addr) + + select { + case err := <-errc: + return err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdown) + } +} + +// parseServeArgs returns the listen address: --addr, else TOUCHSTONE_ADDR, +// else 127.0.0.1:8080. +func parseServeArgs(args []string) (string, error) { + fs := flag.NewFlagSet("serve", flag.ContinueOnError) + fs.SetOutput(io.Discard) + addr := fs.String("addr", envOr("TOUCHSTONE_ADDR", defaultAddr), "listen address (env TOUCHSTONE_ADDR)") + if err := fs.Parse(args); err != nil || fs.NArg() != 0 { + return "", errors.New(serveUsage) + } + return *addr, nil +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} From a9b608c90aa7ae4cdfb149271d9e934646d24141 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:04 +0000 Subject: [PATCH 05/32] feat(web): static Next.js report page and touchstone serve (#9) --- cmd/touchstone/serve_test.go | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 cmd/touchstone/serve_test.go diff --git a/cmd/touchstone/serve_test.go b/cmd/touchstone/serve_test.go new file mode 100644 index 0000000..3007765 --- /dev/null +++ b/cmd/touchstone/serve_test.go @@ -0,0 +1,37 @@ +package main + +import "testing" + +func TestParseServeArgsAddress(t *testing.T) { + t.Setenv("TOUCHSTONE_ADDR", "") + for _, tt := range []struct { + name string + env string + args []string + want string + }{ + {"default is loopback", "", nil, "127.0.0.1:8080"}, + {"env overrides default", "0.0.0.0:9000", nil, "0.0.0.0:9000"}, + {"flag overrides env", "0.0.0.0:9000", []string{"--addr", "127.0.0.1:7000"}, "127.0.0.1:7000"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TOUCHSTONE_ADDR", tt.env) + got, err := parseServeArgs(tt.args) + if err != nil || got != tt.want { + t.Fatalf("parseServeArgs(%q) = %q, %v; want %q", tt.args, got, err, tt.want) + } + }) + } +} + +func TestParseServeArgsRejectsPositionalAndUnknownFlags(t *testing.T) { + for _, args := range [][]string{ + {"https://example.com"}, + {"--allow-private"}, + {"--addr"}, + } { + if _, err := parseServeArgs(args); err == nil { + t.Errorf("parseServeArgs(%q) = nil error, want error", args) + } + } +} From f677afcd9b193f078b3a78022f6a244e267eae2f Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:06 +0000 Subject: [PATCH 06/32] feat(web): static Next.js report page and touchstone serve (#9) --- internal/server/server.go | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 internal/server/server.go diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..35c5317 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,30 @@ +// Package server is the HTTP face of touchstone serve. The ScanService is +// exposed under /v1/ through grpc-gateway, which calls the service in +// process; every other path serves the embedded static web page. +package server + +import ( + "context" + "io/fs" + "net/http" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" +) + +// maxRequestBytes caps a request body. A ScanRequest carrying only a URL is +// a few hundred bytes; sbom_document uploads (D1) will need a larger cap. +const maxRequestBytes = 1 << 20 + +// New returns the handler for touchstone serve: svc under /v1/ and static +// at every other path. +func New(ctx context.Context, svc touchstonev1.ScanServiceServer, static fs.FS) (http.Handler, error) { + gw := runtime.NewServeMux() + if err := touchstonev1.RegisterScanServiceHandlerServer(ctx, gw, svc); err != nil { + return nil, err + } + mux := http.NewServeMux() + mux.Handle("/v1/", http.MaxBytesHandler(gw, maxRequestBytes)) + mux.Handle("/", http.FileServerFS(static)) + return mux, nil +} From 70e1ef530269fa1c2e36779ef8aef9f8a4d08ac1 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:07 +0000 Subject: [PATCH 07/32] feat(web): static Next.js report page and touchstone serve (#9) --- internal/server/server_test.go | 79 ++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 internal/server/server_test.go diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..2afab5a --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,79 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/FootprintAI/touchstone/internal/service" +) + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + static := fstest.MapFS{"index.html": {Data: []byte("touchstone page")}} + svc := &service.Stub{Now: func() time.Time { return time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC) }} + h, err := New(context.Background(), svc, static) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv +} + +func post(t *testing.T, srv *httptest.Server, body string) (int, string) { + t.Helper() + resp, err := http.Post(srv.URL+"/v1/scans", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +func TestServesPageAtRoot(t *testing.T) { + srv := newTestServer(t) + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "touchstone page") { + t.Fatalf("GET /: status %d, body %q", resp.StatusCode, body) + } +} + +func TestPostScanReturnsReport(t *testing.T) { + srv := newTestServer(t) + status, body := post(t, srv, `{"url":"https://example.com"}`) + if status != http.StatusOK { + t.Fatalf("POST /v1/scans: status %d, body %s", status, body) + } + for _, want := range []string{`"targetUrl":"https://example.com"`, `"scanStatus":"SCAN_STATUS_OK"`} { + if !strings.Contains(body, want) { + t.Errorf("response lacks %s:\n%s", want, body) + } + } +} + +func TestPostScanRejectsBadURL(t *testing.T) { + srv := newTestServer(t) + if status, body := post(t, srv, `{"url":"ftp://example.com"}`); status != http.StatusBadRequest { + t.Fatalf("POST bad URL: status %d, want 400, body %s", status, body) + } +} + +func TestPostScanRejectsOversizedBody(t *testing.T) { + srv := newTestServer(t) + big := `{"url":"https://example.com","sbom_document":"` + strings.Repeat("A", maxRequestBytes) + `"}` + if status, _ := post(t, srv, big); status == http.StatusOK { + t.Fatal("oversized body was accepted") + } +} From ca23afc96326e87646ea546918757bbc9a822ede Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:09 +0000 Subject: [PATCH 08/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/embed.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 web/embed.go diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..3a5002c --- /dev/null +++ b/web/embed.go @@ -0,0 +1,18 @@ +// Package web embeds the Next.js static export that `touchstone serve` +// returns. `make web-build` writes the export to web/out; it is not +// committed. out/.gitkeep keeps the embed pattern valid on a fresh checkout. +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:out +var out embed.FS + +// Static returns the exported page with out/ stripped, so index.html and +// _next/ sit at the root. +func Static() (fs.FS, error) { + return fs.Sub(out, "out") +} From c99be44e380be70a41cec5498e0762f652816ae6 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:10 +0000 Subject: [PATCH 09/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/next.config.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 web/next.config.mjs diff --git a/web/next.config.mjs b/web/next.config.mjs new file mode 100644 index 0000000..c927a45 --- /dev/null +++ b/web/next.config.mjs @@ -0,0 +1,8 @@ +/** @type {import("next").NextConfig} */ +// The page is exported to web/out and embedded in the Go binary (web/embed.go), +// so there is no Next.js server at runtime. +const nextConfig = { + output: "export", +}; + +export default nextConfig; From 38c303255970356ecee227ac54672aa050c9bce3 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:11 +0000 Subject: [PATCH 10/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/package.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 65aba8c..ff24ac9 100644 --- a/web/package.json +++ b/web/package.json @@ -7,10 +7,21 @@ "test": "vitest run" }, "dependencies": { - "@bufbuild/protobuf": "2.16.0" + "@bufbuild/protobuf": "2.16.0", + "@tailwindcss/postcss": "4.3.3", + "next": "16.4.3", + "postcss": "8.5.31", + "react": "19.3.1", + "react-dom": "19.3.1", + "tailwindcss": "4.3.3" }, "devDependencies": { + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", "@types/node": "24.19.2", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "jsdom": "26.1.0", "typescript": "5.9.3", "vitest": "3.2.7" } From 946010fea36c9724b4250c21192ff56898253166 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:13 +0000 Subject: [PATCH 11/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/postcss.config.mjs | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 web/postcss.config.mjs diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; From b66a02c5b3e9bba7b29c47a25ae2d384c033ddeb Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:14 +0000 Subject: [PATCH 12/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/out/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 web/out/.gitkeep diff --git a/web/out/.gitkeep b/web/out/.gitkeep new file mode 100644 index 0000000..e69de29 From 58b9f15edaa5dbb702f97d3fa2a397c6457f9791 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:16 +0000 Subject: [PATCH 13/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/app/globals.css | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/src/app/globals.css diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; From 29b6487e5a0727410747d54ca2d61d9be88bad59 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:18 +0000 Subject: [PATCH 14/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/app/layout.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 web/src/app/layout.tsx diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..60e8738 --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Touchstone", + description: "Paste a vendor's URL, get an evidence-graded CVE report.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +
+
+

Touchstone

+

Paste a vendor's URL, get an evidence-graded CVE report.

+
+ {children} +
+ + + ); +} From 58c6e0832205636304d39195ab2f71718b7ba491 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:19 +0000 Subject: [PATCH 15/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/app/page.tsx | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 web/src/app/page.tsx diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx new file mode 100644 index 0000000..6c8a88e --- /dev/null +++ b/web/src/app/page.tsx @@ -0,0 +1,5 @@ +import { ScanPage } from "../components/scan-page"; + +export default function Home() { + return ; +} From 159ecb5fd0f662cff63de598c1d4211e2dd58ae5 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:21 +0000 Subject: [PATCH 16/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/components/component-card.tsx | 87 +++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 web/src/components/component-card.tsx diff --git a/web/src/components/component-card.tsx b/web/src/components/component-card.tsx new file mode 100644 index 0000000..28c72f2 --- /dev/null +++ b/web/src/components/component-card.tsx @@ -0,0 +1,87 @@ +import { + EvidenceKind, + VexStatus, + type Component, + type Vulnerability, +} from "../gen/touchstone/v1/scan_pb"; + +/** Lowercase words for an enum name, e.g. FINGERPRINT_ASSET → "fingerprint asset". */ +function words(enumName: string): string { + return enumName.toLowerCase().replaceAll("_", " "); +} + +/** + * One component: its name and version, the evidence behind it, and its + * vulnerabilities in the order the server ranked them. The count is withheld + * when the lookup is incomplete, to match the banner. + */ +export function ComponentCard({ component, showCount }: { component: Component; showCount: boolean }) { + const count = component.vulnerabilities.length; + return ( +
+

+ {component.name} {component.version} +

+ {component.purl &&

{component.purl}

} + {component.caveats.length > 0 && ( +
    + {component.caveats.map((caveat) => ( +
  • + {caveat} +
  • + ))} +
+ )} + +
+ Evidence ({component.evidence.length}) +
    + {component.evidence.map((ev, i) => ( +
  • + {words(EvidenceKind[ev.kind] ?? "UNSPECIFIED")} + {": "} + {ev.location} + {ev.detail && ({ev.detail})} +
  • + ))} +
+
+ +

+ Vulnerabilities{showCount ? ` (${count})` : ""} +

+ {count === 0 ? ( +

None listed for this component.

+ ) : ( +
    + {component.vulnerabilities.map((vuln) => ( + + ))} +
+ )} +
+ ); +} + +function VulnerabilityItem({ vuln }: { vuln: Vulnerability }) { + return ( +
  • +
    + {vuln.id} + {vuln.kev && Known exploited (KEV)} + {vuln.cvssBase !== undefined && CVSS {vuln.cvssBase}} + {vuln.epss !== undefined && EPSS {(vuln.epss * 100).toFixed(1)}%} + {vuln.suppressed && ( + + Suppressed by VEX: {words(VexStatus[vuln.vexStatus] ?? "UNSPECIFIED")} + {vuln.vexJustification && ` (${vuln.vexJustification})`} + + )} +
    + {vuln.summary &&

    {vuln.summary}

    } + {vuln.aliases.length > 0 && ( +

    Also known as {vuln.aliases.join(", ")}

    + )} +
  • + ); +} From 3dd8401602f6e147fa5998673bb5d18a3e064e90 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:22 +0000 Subject: [PATCH 17/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/components/report-view.test.tsx | 128 ++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 web/src/components/report-view.test.tsx diff --git a/web/src/components/report-view.test.tsx b/web/src/components/report-view.test.tsx new file mode 100644 index 0000000..afa3078 --- /dev/null +++ b/web/src/components/report-view.test.tsx @@ -0,0 +1,128 @@ +import { cleanup, render, screen, within } from "@testing-library/react"; +import { create } from "@bufbuild/protobuf"; +import { afterEach, describe, expect, it } from "vitest"; +import { + CheckedLocation_Result, + CheckedLocationSchema, + Confidence, + ComponentSchema, + LookupStatus, +} from "../gen/touchstone/v1/scan_pb"; +import { goldenReport } from "../test/golden"; +import { ReportView } from "./report-view"; + +afterEach(cleanup); + +function lowTier(): HTMLElement { + return screen.getByRole("region", { name: "Low confidence" }); +} + +describe("ReportView", () => { + it("shows the jQuery component under Low confidence with its CVE list", () => { + render(); + + const low = lowTier(); + expect(within(low).getByRole("heading", { name: /jquery/ })).toBeTruthy(); + expect(within(low).getByText("1.12.4")).toBeTruthy(); + expect(within(low).getByText("CVE-2020-11022")).toBeTruthy(); + expect(within(low).getByText("Also known as GHSA-gxr4-xjj5-5px2")).toBeTruthy(); + expect(within(low).getByText("Potential XSS vulnerability in jQuery.htmlPrefilter")).toBeTruthy(); + }); + + it("groups components under their confidence tier and says when a tier is empty", () => { + const report = goldenReport(); + report.components.push( + create(ComponentSchema, { name: "lodash", version: "4.17.21", confidence: Confidence.HIGH }), + create(ComponentSchema, { name: "react", version: "18.2.0", confidence: Confidence.MEDIUM }), + ); + render(); + + const high = screen.getByRole("region", { name: "High confidence" }); + const medium = screen.getByRole("region", { name: "Medium confidence" }); + expect(within(high).getByText("lodash")).toBeTruthy(); + expect(within(high).queryByText("react")).toBeNull(); + expect(within(medium).getByText("react")).toBeTruthy(); + expect(within(medium).queryByText("lodash")).toBeNull(); + expect(within(lowTier()).getByText("jquery")).toBeTruthy(); + }); + + it("keeps a component with no confidence in an Unclassified section", () => { + const report = goldenReport(); + report.components.push(create(ComponentSchema, { name: "mystery", version: "1.0.0" })); + render(); + + expect(within(screen.getByRole("region", { name: "Unclassified" })).getByText(/mystery/)).toBeTruthy(); + }); + + it("shows the lookup-incomplete banner and no vulnerability count when the lookup is PARTIAL", () => { + const report = goldenReport(); + report.lookupStatus = LookupStatus.PARTIAL; + render(); + + const banner = screen.getByRole("alert"); + expect(within(banner).getByText("Lookup incomplete")).toBeTruthy(); + expect(within(lowTier()).getByRole("heading", { name: "Vulnerabilities" })).toBeTruthy(); + expect(screen.queryByText(/Vulnerabilities \(1\)/)).toBeNull(); + expect(within(lowTier()).getByText("CVE-2020-11022")).toBeTruthy(); + }); + + it("treats a FAILED or unspecified lookup as incomplete too", () => { + for (const lookupStatus of [LookupStatus.FAILED, LookupStatus.UNSPECIFIED]) { + const report = goldenReport(); + report.lookupStatus = lookupStatus; + const { unmount } = render(); + expect(screen.getByRole("alert")).toBeTruthy(); + unmount(); + } + }); + + it("shows no banner and the count when the lookup is COMPLETE", () => { + render(); + + expect(screen.queryByRole("alert")).toBeNull(); + expect(within(lowTier()).getByRole("heading", { name: "Vulnerabilities (1)" })).toBeTruthy(); + }); + + it("lists the evidence behind each finding", () => { + render(); + + const evidence = within(lowTier()).getByText("Evidence (1)"); + expect(evidence.closest("details")).toBeTruthy(); + expect(within(lowTier()).getByText("fingerprint asset")).toBeTruthy(); + expect(within(lowTier()).getByText("https://example.com")).toBeTruthy(); + expect(within(lowTier()).getByText("(jquery-1.12.4.min.js)")).toBeTruthy(); + }); + + it("lists every checked location with its result", () => { + const report = goldenReport(); + report.checked.push( + create(CheckedLocationSchema, { + url: "https://example.com/.well-known/security.txt", + reason: "DISCOVERY_PATH", + result: CheckedLocation_Result.NOT_FOUND, + }), + create(CheckedLocationSchema, { + url: "https://example.com/admin", + reason: "TARGET", + result: CheckedLocation_Result.REFUSED, + error: "not an allowed reason", + }), + ); + report.requestsUsed = 3; + render(); + + const checked = screen.getByRole("region", { name: "Checked locations" }); + expect(within(checked).getByText("3 of 50 requests used.")).toBeTruthy(); + expect(within(checked).getByText("https://example.com/.well-known/security.txt")).toBeTruthy(); + expect(within(checked).getByText("not found")).toBeTruthy(); + expect(within(checked).getByText("refused")).toBeTruthy(); + expect(within(checked).getByText("not an allowed reason")).toBeTruthy(); + }); + + it("shows the disclaimer from the report", () => { + const report = goldenReport(); + render(); + + expect(screen.getByText(report.disclaimer)).toBeTruthy(); + }); +}); From cb44511bd3578a79b6be4c4aa5bc899f32fefe54 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:24 +0000 Subject: [PATCH 18/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/components/report-view.tsx | 146 +++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 web/src/components/report-view.tsx diff --git a/web/src/components/report-view.tsx b/web/src/components/report-view.tsx new file mode 100644 index 0000000..7f00114 --- /dev/null +++ b/web/src/components/report-view.tsx @@ -0,0 +1,146 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { + CheckedLocation_Result, + type CheckedLocation, + type Component, + type ScanReport, +} from "../gen/touchstone/v1/scan_pb"; +import { groupByTier, lookupIncomplete } from "../lib/report"; +import { ComponentCard } from "./component-card"; + +/** + * The report for one scan: a banner when the lookup is incomplete, the + * components grouped by confidence tier, the checked locations, and the + * disclaimer. + */ +export function ReportView({ report }: { report: ScanReport }) { + const incomplete = lookupIncomplete(report); + const { tiers, unclassified } = groupByTier(report); + return ( +
    +
    +

    + Target {report.targetUrl} +

    + {report.vulnDataAsOf &&

    Vulnerability data as of {timestampDate(report.vulnDataAsOf).toISOString()}

    } +
    + + {incomplete && ( +
    +

    Lookup incomplete

    +

    + Not every vulnerability source answered, so the results below may be missing CVEs. No vulnerability count + is shown. +

    +
    + )} + + {tiers.map(({ tier, components }) => ( + + ))} + {unclassified.length > 0 && ( + + )} + + + +

    {report.disclaimer}

    +
    + ); +} + +function TierSection({ + label, + description, + components, + showCount, +}: { + label: string; + description: string; + components: Component[]; + showCount: boolean; +}) { + const headingId = `tier-${label.toLowerCase().replaceAll(" ", "-")}`; + return ( +
    +
    +

    + {label} +

    +

    {description}

    +
    + {components.length === 0 ? ( +

    No components at this tier.

    + ) : ( + components.map((component, i) => ( + + )) + )} +
    + ); +} + +function CheckedLocations({ + locations, + requestsUsed, + budget, +}: { + locations: CheckedLocation[]; + requestsUsed: number; + budget: number; +}) { + return ( +
    +

    + Checked locations +

    +

    + {requestsUsed} of {budget} requests used. +

    + {locations.length === 0 ? ( +

    No locations were requested.

    + ) : ( +
      + {locations.map((loc, i) => ( +
    • + {loc.url} + {loc.reason} + {resultLabel(loc.result)} + {loc.error && {loc.error}} +
    • + ))} +
    + )} +
    + ); +} + +function resultLabel(result: CheckedLocation_Result): string { + switch (result) { + case CheckedLocation_Result.FOUND: + return "found"; + case CheckedLocation_Result.NOT_FOUND: + return "not found"; + case CheckedLocation_Result.ERROR: + return "error"; + case CheckedLocation_Result.REFUSED: + return "refused"; + default: + return "unknown"; + } +} From 474c56c466530de208ec20c7db36d7e783ef899c Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:25 +0000 Subject: [PATCH 19/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/components/scan-form.tsx | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 web/src/components/scan-form.tsx diff --git a/web/src/components/scan-form.tsx b/web/src/components/scan-form.tsx new file mode 100644 index 0000000..d86e4ac --- /dev/null +++ b/web/src/components/scan-form.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { isScannableUrl } from "../lib/url"; + +/** One URL input. A value that is not an absolute http(s) URL is refused here, before any request. */ +export function ScanForm({ onScan, busy }: { onScan: (url: string) => void; busy: boolean }) { + const [value, setValue] = useState(""); + const [invalid, setInvalid] = useState(false); + + function submit(event: FormEvent) { + event.preventDefault(); + const url = value.trim(); + const ok = isScannableUrl(url); + setInvalid(!ok); + if (ok) { + onScan(url); + } + } + + return ( +
    +
    + + setValue(e.target.value)} + aria-invalid={invalid} + aria-describedby={invalid ? "target-url-error" : undefined} + className="flex-1 rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-sm focus:border-slate-500 focus:outline-none" + /> + +
    + {invalid && ( +

    + Enter an absolute http:// or https:// URL. +

    + )} +
    + ); +} From e5a2a923b87542e02c5bb78bc6ee4a783bd0e598 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:27 +0000 Subject: [PATCH 20/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/components/scan-page.test.tsx | 96 +++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 web/src/components/scan-page.test.tsx diff --git a/web/src/components/scan-page.test.tsx b/web/src/components/scan-page.test.tsx new file mode 100644 index 0000000..1f7ba89 --- /dev/null +++ b/web/src/components/scan-page.test.tsx @@ -0,0 +1,96 @@ +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { goldenText } from "../test/golden"; +import { ScanPage } from "./scan-page"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function stubFetch(impl: () => Promise) { + const fn = vi.fn(impl); + vi.stubGlobal("fetch", fn); + return fn; +} + +function scanFor(url: string) { + fireEvent.change(screen.getByLabelText("Vendor URL"), { target: { value: url } }); + fireEvent.click(screen.getByRole("button", { name: "Scan" })); +} + +describe("ScanPage", () => { + it("refuses a value that is not an http(s) URL without sending a request", () => { + const fetchMock = stubFetch(async () => new Response(goldenText)); + render(); + + scanFor("vendor.example"); + + expect(screen.getByText("Enter an absolute http:// or https:// URL.")).toBeTruthy(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("posts the URL and renders the report, with the jQuery component under Low confidence", async () => { + const fetchMock = stubFetch(async () => new Response(goldenText, { status: 200 })); + render(); + + scanFor(" https://example.com "); + + const low = await screen.findByRole("region", { name: "Low confidence" }); + expect(within(low).getByText("CVE-2020-11022")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledWith("/v1/scans", expect.objectContaining({ method: "POST" })); + expect(fetchMock.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ url: "https://example.com" })); + }); + + it("shows the gateway's message as an error, not an empty report", async () => { + stubFetch( + async () => new Response('{"code":13,"message":"internal error","details":[]}', { status: 500 }), + ); + render(); + + scanFor("https://example.com"); + + expect((await screen.findByRole("alert")).textContent).toContain("internal error"); + expect(screen.queryByRole("region", { name: "Low confidence" })).toBeNull(); + }); + + it("shows an error when touchstone serve cannot be reached", async () => { + stubFetch(async () => { + throw new TypeError("Failed to fetch"); + }); + render(); + + scanFor("https://example.com"); + + expect((await screen.findByRole("alert")).textContent).toContain("could not be reached"); + }); + + it("shows an error, not an empty report, when the target was unreachable", async () => { + const unreachable = JSON.stringify({ + schemaVersion: "1", + targetUrl: "https://example.com", + scanStatus: "SCAN_STATUS_TARGET_UNREACHABLE", + lookupStatus: "LOOKUP_STATUS_COMPLETE", + }); + stubFetch(async () => new Response(unreachable)); + render(); + + scanFor("https://example.com"); + + expect((await screen.findByRole("alert")).textContent).toContain("could not be reached"); + expect(screen.queryByRole("region", { name: "Low confidence" })).toBeNull(); + expect(screen.queryByRole("region", { name: "Checked locations" })).toBeNull(); + }); + + it("disables the button while the scan runs", async () => { + let finish: (r: Response) => void = () => {}; + stubFetch(() => new Promise((resolve) => (finish = resolve))); + render(); + + scanFor("https://example.com"); + + expect(screen.getByRole("button", { name: "Scanning…" }).hasAttribute("disabled")).toBe(true); + finish(new Response(goldenText)); + expect(await screen.findByRole("region", { name: "Low confidence" })).toBeTruthy(); + }); +}); From 7628ec5e8104be5ab9781c55c2158a5f98399fa1 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:28 +0000 Subject: [PATCH 21/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/components/scan-page.tsx | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 web/src/components/scan-page.tsx diff --git a/web/src/components/scan-page.tsx b/web/src/components/scan-page.tsx new file mode 100644 index 0000000..c4234a1 --- /dev/null +++ b/web/src/components/scan-page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { ScanStatus, type ScanReport } from "../gen/touchstone/v1/scan_pb"; +import { requestScan, ScanError } from "../lib/api"; +import { ReportView } from "./report-view"; +import { ScanForm } from "./scan-form"; + +type View = + | { kind: "idle" } + | { kind: "scanning" } + | { kind: "error"; message: string } + | { kind: "report"; report: ScanReport }; + +/** The whole page: the URL form, then either the report or an error. */ +export function ScanPage() { + const [view, setView] = useState({ kind: "idle" }); + + async function scan(url: string) { + setView({ kind: "scanning" }); + try { + const report = await requestScan(url); + // An unreachable target returns a report with no components, which + // would look like a clean result. Show it as an error instead. + if (report.scanStatus !== ScanStatus.OK) { + setView({ kind: "error", message: scanStatusMessage(report.scanStatus) }); + return; + } + setView({ kind: "report", report }); + } catch (err) { + setView({ kind: "error", message: err instanceof ScanError ? err.message : "The scan failed unexpectedly." }); + } + } + + return ( +
    + + {view.kind === "scanning" &&

    Scanning…

    } + {view.kind === "error" && ( +
    + {view.message} +
    + )} + {view.kind === "report" && } +
    + ); +} + +function scanStatusMessage(status: ScanStatus): string { + if (status === ScanStatus.TARGET_UNREACHABLE) { + return "The target could not be reached, so there is no report. Check the URL and try again."; + } + return "The scan did not complete, so there is no report."; +} From 9e7fcf68d651212614eb0cc18f3ccc103a6fe589 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:30 +0000 Subject: [PATCH 22/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/lib/api.test.ts | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 web/src/lib/api.test.ts diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 0000000..5150271 --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; +import { LookupStatus, ScanStatus } from "../gen/touchstone/v1/scan_pb"; +import { goldenText } from "../test/golden"; +import { isJsonValue, requestScan, ScanError } from "./api"; + +function jsonResponse(body: string, status = 200): Response { + return new Response(body, { status, headers: { "Content-Type": "application/json" } }); +} + +describe("requestScan", () => { + it("POSTs the URL to /v1/scans and decodes the reply with the generated parser", async () => { + const fetchFn = vi.fn(async () => jsonResponse(goldenText)); + + const report = await requestScan("https://example.com", fetchFn); + + expect(fetchFn).toHaveBeenCalledWith("/v1/scans", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com" }), + }); + expect(report.scanStatus).toBe(ScanStatus.OK); + expect(report.lookupStatus).toBe(LookupStatus.COMPLETE); + expect(report.components[0]?.name).toBe("jquery"); + }); + + it("uses the gateway's message for an error reply", async () => { + const fetchFn = vi.fn(async () => + jsonResponse('{"code":3,"message":"url must be an absolute http(s) URL","details":[]}', 400), + ); + + await expect(requestScan("ftp://x", fetchFn)).rejects.toThrow("url must be an absolute http(s) URL"); + }); + + it("reports the HTTP status when an error reply has no JSON message", async () => { + const fetchFn = vi.fn(async () => new Response("bad gateway", { status: 502 })); + + await expect(requestScan("https://example.com", fetchFn)).rejects.toThrow("HTTP 502"); + }); + + it("rejects a reply with a field the contract does not define", async () => { + const fetchFn = vi.fn(async () => jsonResponse('{"schemaVersion":"1","bogusField":true}')); + + await expect(requestScan("https://example.com", fetchFn)).rejects.toBeInstanceOf(ScanError); + }); + + it("turns a network failure into a ScanError, not a raw TypeError", async () => { + const fetchFn = vi.fn(async () => { + throw new TypeError("Failed to fetch"); + }); + + await expect(requestScan("https://example.com", fetchFn)).rejects.toThrow("could not be reached"); + }); +}); + +describe("isJsonValue", () => { + it.each([null, true, 1, "x", [], [1, "a", null], { a: { b: [1] } }])("accepts %j", (value) => { + expect(isJsonValue(value)).toBe(true); + }); + + it.each([undefined, () => 1, new Date(0), { a: undefined }, [undefined]])("rejects %s", (value) => { + expect(isJsonValue(value)).toBe(false); + }); +}); From 5f035dee29623dd60851b1f2b058cf8cf84ade6f Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:31 +0000 Subject: [PATCH 23/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/lib/api.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 web/src/lib/api.ts diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..b661938 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,66 @@ +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { ScanReportSchema, type ScanReport } from "../gen/touchstone/v1/scan_pb"; + +/** The ScanService route, served by `touchstone serve` through grpc-gateway. */ +export const SCAN_PATH = "/v1/scans"; + +/** A scan that produced no report. The message is shown to the reviewer. */ +export class ScanError extends Error {} + +/** + * POSTs the URL and decodes the reply with the generated parser. Any failure, + * including a body that is not a ScanReport, becomes a ScanError, so the page + * never shows a partial or empty report. + */ +export async function requestScan(url: string, fetchFn: typeof fetch = fetch): Promise { + let res: Response; + try { + res = await fetchFn(SCAN_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + } catch { + throw new ScanError("The scan service could not be reached. Is `touchstone serve` running?"); + } + const body: unknown = await res.json().catch(() => undefined); + if (!res.ok) { + throw new ScanError(gatewayMessage(body) ?? `The scan failed with HTTP ${res.status}.`); + } + if (!isJsonValue(body)) { + throw new ScanError("The scan service replied with something other than JSON."); + } + try { + return fromJson(ScanReportSchema, body); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new ScanError(`The scan service replied with an unexpected report: ${reason}`); + } +} + +/** True for any value protobuf's JSON parser accepts, checked without a cast. */ +export function isJsonValue(value: unknown): value is JsonValue { + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return true; + } + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + if (typeof value === "object") { + // Only plain objects: a Date or class instance is not JSON. + const proto: unknown = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + return false; + } + return Object.values(value).every(isJsonValue); + } + return false; +} + +/** The message from a grpc-gateway error body, {"code":…,"message":…}. */ +function gatewayMessage(body: unknown): string | undefined { + if (typeof body === "object" && body !== null && "message" in body && typeof body.message === "string") { + return body.message; + } + return undefined; +} From 89059e76e1e5904980f8bcb02c29c64ee6cf6989 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:33 +0000 Subject: [PATCH 24/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/lib/report.ts | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 web/src/lib/report.ts diff --git a/web/src/lib/report.ts b/web/src/lib/report.ts new file mode 100644 index 0000000..dd877f2 --- /dev/null +++ b/web/src/lib/report.ts @@ -0,0 +1,52 @@ +import { + Confidence, + LookupStatus, + type Component, + type ScanReport, +} from "../gen/touchstone/v1/scan_pb"; + +export interface Tier { + confidence: Confidence; + label: string; + description: string; +} + +/** Tiers in display order, highest confidence first. */ +export const TIERS: readonly Tier[] = [ + { confidence: Confidence.HIGH, label: "High confidence", description: "Published by the vendor as an SBOM or VEX." }, + { + confidence: Confidence.MEDIUM, + label: "Medium confidence", + description: "From the vendor's public source repository, at its latest release.", + }, + { + confidence: Confidence.LOW, + label: "Low confidence", + description: "Inferred from a passive fingerprint of the page and the assets it loads.", + }, +]; + +export interface TierGroup { + tier: Tier; + components: Component[]; +} + +/** + * Splits components into the tiers above. A component with no known tier goes + * to unclassified rather than being dropped. + */ +export function groupByTier(report: ScanReport): { tiers: TierGroup[]; unclassified: Component[] } { + const known = new Set(TIERS.map((tier) => tier.confidence)); + return { + tiers: TIERS.map((tier) => ({ + tier, + components: report.components.filter((c) => c.confidence === tier.confidence), + })), + unclassified: report.components.filter((c) => !known.has(c.confidence)), + }; +} + +/** True unless the lookup completed, so a partial lookup never looks clean. */ +export function lookupIncomplete(report: ScanReport): boolean { + return report.lookupStatus !== LookupStatus.COMPLETE; +} From 87a81f4e46e88cd2134817d9a3ecc06402b2f630 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:34 +0000 Subject: [PATCH 25/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/lib/url.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 web/src/lib/url.test.ts diff --git a/web/src/lib/url.test.ts b/web/src/lib/url.test.ts new file mode 100644 index 0000000..ce3af27 --- /dev/null +++ b/web/src/lib/url.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { isScannableUrl } from "./url"; + +describe("isScannableUrl", () => { + it.each(["https://vendor.example", "http://127.0.0.1:8080/app", "https://vendor.example/a?b=c"])( + "accepts %s", + (url) => { + expect(isScannableUrl(url)).toBe(true); + }, + ); + + it.each(["", "vendor.example", "ftp://vendor.example", "https://", "javascript:alert(1)", "not a url"])( + "refuses %j", + (url) => { + expect(isScannableUrl(url)).toBe(false); + }, + ); +}); From e594ddd7cad56abad69bc91b05be0f237254a112 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:36 +0000 Subject: [PATCH 26/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/lib/url.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 web/src/lib/url.ts diff --git a/web/src/lib/url.ts b/web/src/lib/url.ts new file mode 100644 index 0000000..47aa368 --- /dev/null +++ b/web/src/lib/url.ts @@ -0,0 +1,9 @@ +/** True for an absolute http(s) URL, the only kind ScanService accepts. */ +export function isScannableUrl(text: string): boolean { + try { + const url = new URL(text); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} From 2af19ccc15d398ab3eb1774da836319cd53ef606 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:38 +0000 Subject: [PATCH 27/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/test/golden.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 web/src/test/golden.ts diff --git a/web/src/test/golden.ts b/web/src/test/golden.ts new file mode 100644 index 0000000..1297ba2 --- /dev/null +++ b/web/src/test/golden.ts @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { fromJson } from "@bufbuild/protobuf"; +import { isJsonValue } from "../lib/api"; +import { ScanReportSchema, type ScanReport } from "../gen/touchstone/v1/scan_pb"; + +/** testdata/report.golden.json, written by the Go CLI test; see vitest.config.ts for the path. */ +export const goldenPath = process.env.GOLDEN_REPORT_PATH; +if (!goldenPath) { + throw new Error("GOLDEN_REPORT_PATH is not set; run the tests with vitest (see vitest.config.ts)"); +} + +/** The JSON text of the golden file. */ +export const goldenText = readFileSync(goldenPath, "utf8"); + +/** The golden report, decoded with the generated parser. */ +export function goldenReport(): ScanReport { + const parsed: unknown = JSON.parse(goldenText); + if (!isJsonValue(parsed)) { + throw new Error("golden file is not JSON"); + } + return fromJson(ScanReportSchema, parsed); +} From 401b36e5e1fe283051c4def3b9469bc40a39890f Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:39 +0000 Subject: [PATCH 28/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/src/report.test.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/src/report.test.ts b/web/src/report.test.ts index 606de94..155cafc 100644 --- a/web/src/report.test.ts +++ b/web/src/report.test.ts @@ -1,4 +1,3 @@ -import { readFileSync } from "node:fs"; import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { describe, expect, it } from "vitest"; import { @@ -7,12 +6,11 @@ import { ScanReportSchema, ScanStatus, } from "./gen/touchstone/v1/scan_pb"; +import { goldenText } from "./test/golden"; // Written by the Go CLI test (go test ./cmd/touchstone -update), so drift // between the Go and TS sides of scan.proto fails one of the two suites. -const golden = JSON.parse( - readFileSync(new URL("../../testdata/report.golden.json", import.meta.url), "utf8"), -) as JsonValue; +const golden = JSON.parse(goldenText) as JsonValue; describe("ScanReport golden", () => { it("decodes with the generated parser", () => { From c9d69f550001b8c18524fa1552ae59a3474067ca Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:40 +0000 Subject: [PATCH 29/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/web/tsconfig.json b/web/tsconfig.json index ef89603..20dfcba 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], "types": ["node"], + "jsx": "react-jsx", "strict": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, From e41ba4eda6706c6c1c11f96501fd6c3680815772 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:36:42 +0000 Subject: [PATCH 30/32] feat(web): static Next.js report page and touchstone serve (#9) --- web/vitest.config.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 web/vitest.config.ts diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..0bf16e8 --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,13 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + // The tests render TSX with the automatic runtime, as Next.js does. + esbuild: { jsx: "automatic" }, + test: { + environment: "jsdom", + // jsdom rewrites import.meta.url to http://, so the golden file's path is + // resolved here, in Node, and handed to the tests. + env: { GOLDEN_REPORT_PATH: fileURLToPath(new URL("../testdata/report.golden.json", import.meta.url)) }, + }, +}); From b602a86a40dc5edd5549517c810f193342cce351 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:56:18 +0000 Subject: [PATCH 31/32] feat(web): Next.js static page and touchstone serve (#9) Co-authored-by: hsinatfootprintai <149764663+hsinatfootprintai@users.noreply.github.com> --- .gitignore | 1 - Makefile | 10 +- cmd/touchstone/main.go | 33 ++-- cmd/touchstone/serve.go | 81 ++++++---- cmd/touchstone/serve_test.go | 160 +++++++++++++++---- web/embed.go | 19 ++- web/next.config.ts | 9 ++ web/package.json | 25 +-- web/postcss.config.mjs | 4 +- web/src/app/layout.tsx | 12 +- web/src/app/page.tsx | 4 +- web/src/components/ReportView.test.tsx | 121 +++++++++++++++ web/src/components/ReportView.tsx | 203 +++++++++++++++++++++++++ web/src/components/ScanPage.test.tsx | 73 +++++++++ web/src/components/ScanPage.tsx | 75 +++++++++ web/src/lib/group.ts | 21 +++ web/src/lib/scan.ts | 54 +++++++ web/src/test-setup.ts | 5 + web/tsconfig.json | 10 +- web/vitest.config.ts | 9 +- 20 files changed, 803 insertions(+), 126 deletions(-) create mode 100644 web/next.config.ts create mode 100644 web/src/components/ReportView.test.tsx create mode 100644 web/src/components/ReportView.tsx create mode 100644 web/src/components/ScanPage.test.tsx create mode 100644 web/src/components/ScanPage.tsx create mode 100644 web/src/lib/group.ts create mode 100644 web/src/lib/scan.ts create mode 100644 web/src/test-setup.ts diff --git a/.gitignore b/.gitignore index ec484cd..4d5e273 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -# next build output and its generated type shim; web/out is embedded at build time web/.next/ web/next-env.d.ts web/out/* diff --git a/Makefile b/Makefile index f5aea9b..9d98e2a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,10 @@ .PHONY: generate lint breaking test web-test web-build +# Builds the static page into web/out, which `go build` embeds into +# `touchstone serve`; run it before building the binary you serve with. +web-build: + cd web && npm install --no-package-lock && npm run build + # Requires buf (https://buf.build): regenerates gen/, web/src/gen/, # openapi/ and schema/ from proto/touchstone/v1/scan.proto, using the # googleapis version pinned in buf.lock (`buf dep update` to bump it). @@ -22,8 +27,3 @@ test: web-test: cd web && npm install --no-package-lock && npx tsc --noEmit && npx vitest run - -# Exports the page to web/out, which web/embed.go embeds into touchstone serve. -# Build it before `go build ./cmd/touchstone`. -web-build: - cd web && npm install --no-package-lock && npx next build diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index ee3c2c7..5cc1c6b 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -1,8 +1,8 @@ // Command touchstone scans a vendor URL and prints an evidence-graded // ScanReport as protojson (--format json, the default) or as Markdown -// (--format md). In slice S0 the scan is served by the stub ScanService, -// so the report is canned. `touchstone serve` runs the embedded web page -// and POST /v1/scans on 127.0.0.1:8080 (serve.go). +// (--format md), or serves the web page and POST /v1/scans (serve, slice +// S9). The scan fetches the page and its scripts passively and +// looks components up in OSV (slice S2). package main import ( @@ -17,11 +17,11 @@ import ( "github.com/FootprintAI/touchstone/internal/fetch" "github.com/FootprintAI/touchstone/internal/report" "github.com/FootprintAI/touchstone/internal/service" - "github.com/FootprintAI/touchstone/web" + "github.com/FootprintAI/touchstone/internal/vuln" "google.golang.org/protobuf/encoding/protojson" ) -const usage = "usage: touchstone scan [--allow-private] [--format json|md] " +const usage = "usage: touchstone scan [--allow-private] [--format json|md] \n touchstone serve [--addr host:port]" // newService builds the ScanService for one CLI invocation. The fetch // options carry CLI-only settings such as --allow-private, which the @@ -29,30 +29,19 @@ const usage = "usage: touchstone scan [--allow-private] [--format json|md] type newService func(fetch.Options) touchstonev1.ScanServiceServer func main() { - ctx := context.Background() - var err error - if len(os.Args) > 1 && os.Args[1] == "serve" { - err = serveMain(ctx, os.Args[2:]) - } else { - newSvc := func(fetch.Options) touchstonev1.ScanServiceServer { return &service.Stub{} } - err = run(ctx, os.Args[1:], os.Stdout, newSvc) + newSvc := func(o fetch.Options) touchstonev1.ScanServiceServer { + return &service.Scanner{Fetch: o, Vulns: vuln.NewOSV()} } - if err != nil { + if err := run(context.Background(), os.Args[1:], os.Stdout, newSvc); err != nil { fmt.Fprintln(os.Stderr, "touchstone:", err) os.Exit(2) } } -// serveMain loads the embedded web page and runs serve with it. -func serveMain(ctx context.Context, args []string) error { - static, err := web.Static() - if err != nil { - return err - } - return serve(ctx, args, static) -} - func run(ctx context.Context, args []string, out io.Writer, newSvc newService) error { + if len(args) > 0 && args[0] == "serve" { + return serve(ctx, args[1:], out, newSvc) + } if len(args) == 0 || args[0] != "scan" { return errors.New(usage) } diff --git a/cmd/touchstone/serve.go b/cmd/touchstone/serve.go index 754f25d..0b22d44 100644 --- a/cmd/touchstone/serve.go +++ b/cmd/touchstone/serve.go @@ -7,42 +7,53 @@ import ( "fmt" "io" "io/fs" + "net" "net/http" "os" - "os/signal" - "syscall" "time" - "github.com/FootprintAI/touchstone/internal/server" + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/fetch" "github.com/FootprintAI/touchstone/internal/service" + "github.com/FootprintAI/touchstone/web" + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/protobuf/encoding/protojson" ) const ( - serveUsage = "usage: touchstone serve [--addr host:port]" - // defaultAddr is local-only on purpose: serve has no auth (design D3). defaultAddr = "127.0.0.1:8080" + + // scanTimeout bounds one scan request end to end. + scanTimeout = 180 * time.Second ) -// serve runs the web page and the ScanService HTTP API until interrupted. -// It has no --allow-private: the server never fetches private addresses. -func serve(ctx context.Context, args []string, static fs.FS) error { - addr, err := parseServeArgs(args) +// serve runs the embedded page and POST /v1/scans on one port. It has no +// --allow-private: the fetch options carry only the tool version, so the +// server always refuses loopback and private targets. +func serve(ctx context.Context, args []string, out io.Writer, newSvc newService) error { + flags := flag.NewFlagSet("serve", flag.ContinueOnError) + flags.SetOutput(io.Discard) + addr := flags.String("addr", envOr("TOUCHSTONE_ADDR", defaultAddr), "listen address (env TOUCHSTONE_ADDR)") + if err := flags.Parse(args); err != nil || flags.NArg() != 0 { + return errors.New(usage) + } + handler, err := newHandler(ctx, newSvc(fetch.Options{Version: service.ToolVersion}), web.Page()) if err != nil { return err } - svc := &service.Stub{} - handler, err := server.New(ctx, svc, static) + ln, err := net.Listen("tcp", *addr) if err != nil { return err } - srv := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 10 * time.Second} + srv := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: scanTimeout + 10*time.Second, + } + fmt.Fprintf(out, "touchstone: listening on http://%s\n", ln.Addr()) - ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) - defer stop() errc := make(chan error, 1) - go func() { errc <- srv.ListenAndServe() }() - fmt.Fprintf(os.Stderr, "touchstone: serving on http://%s\n", addr) - + go func() { errc <- srv.Serve(ln) }() select { case err := <-errc: return err @@ -53,21 +64,35 @@ func serve(ctx context.Context, args []string, static fs.FS) error { } } -// parseServeArgs returns the listen address: --addr, else TOUCHSTONE_ADDR, -// else 127.0.0.1:8080. -func parseServeArgs(args []string) (string, error) { - fs := flag.NewFlagSet("serve", flag.ContinueOnError) - fs.SetOutput(io.Discard) - addr := fs.String("addr", envOr("TOUCHSTONE_ADDR", defaultAddr), "listen address (env TOUCHSTONE_ADDR)") - if err := fs.Parse(args); err != nil || fs.NArg() != 0 { - return "", errors.New(serveUsage) +// newHandler routes /v1/ to the ScanService through grpc-gateway and +// everything else to the static page. +func newHandler(ctx context.Context, svc touchstonev1.ScanServiceServer, page fs.FS) (http.Handler, error) { + // Default protojson options, so the REST body has the same shape as the + // CLI's JSON that the page decodes with the generated fromJson. + gw := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{ + MarshalOptions: protojson.MarshalOptions{}, + UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true}, + })) + if err := touchstonev1.RegisterScanServiceHandlerServer(ctx, gw, svc); err != nil { + return nil, err } - return *addr, nil + mux := http.NewServeMux() + mux.Handle("/v1/", withTimeout(gw, scanTimeout)) + mux.Handle("/", http.FileServerFS(page)) + return mux, nil +} + +func withTimeout(next http.Handler, d time.Duration) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), d) + defer cancel() + next.ServeHTTP(w, r.WithContext(ctx)) + }) } -func envOr(key, fallback string) string { +func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } - return fallback + return def } diff --git a/cmd/touchstone/serve_test.go b/cmd/touchstone/serve_test.go index 3007765..f09a4e8 100644 --- a/cmd/touchstone/serve_test.go +++ b/cmd/touchstone/serve_test.go @@ -1,37 +1,145 @@ package main -import "testing" +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "testing/fstest" + "time" -func TestParseServeArgsAddress(t *testing.T) { - t.Setenv("TOUCHSTONE_ADDR", "") - for _, tt := range []struct { - name string - env string - args []string - want string - }{ - {"default is loopback", "", nil, "127.0.0.1:8080"}, - {"env overrides default", "0.0.0.0:9000", nil, "0.0.0.0:9000"}, - {"flag overrides env", "0.0.0.0:9000", []string{"--addr", "127.0.0.1:7000"}, "127.0.0.1:7000"}, - } { - t.Run(tt.name, func(t *testing.T) { - t.Setenv("TOUCHSTONE_ADDR", tt.env) - got, err := parseServeArgs(tt.args) - if err != nil || got != tt.want { - t.Fatalf("parseServeArgs(%q) = %q, %v; want %q", tt.args, got, err, tt.want) - } - }) + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/fetch" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func testServer(t *testing.T) *httptest.Server { + t.Helper() + page := fstest.MapFS{"index.html": {Data: []byte("

    Touchstone

    ")}} + h, err := newHandler(context.Background(), fixedStub(fetch.Options{}), page) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return srv +} + +func TestServeServesPage(t *testing.T) { + srv := testServer(t) + resp, err := http.Get(srv.URL + "/") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var body bytes.Buffer + body.ReadFrom(resp.Body) + if resp.StatusCode != 200 || !strings.Contains(body.String(), "

    Touchstone

    ") { + t.Errorf("GET / = %d %q, want the embedded page", resp.StatusCode, body.String()) + } +} + +// TestServeScanMatchesGolden posts through grpc-gateway and compares the +// body with the same golden the CLI test and the Vitest suite use. +func TestServeScanMatchesGolden(t *testing.T) { + srv := testServer(t) + resp, err := http.Post(srv.URL+"/v1/scans", "application/json", strings.NewReader(`{"url":"https://example.com"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var body bytes.Buffer + body.ReadFrom(resp.Body) + if resp.StatusCode != 200 { + t.Fatalf("POST /v1/scans = %d: %s", resp.StatusCode, body.String()) + } + var got, want touchstonev1.ScanReport + if err := protojson.Unmarshal(body.Bytes(), &got); err != nil { + t.Fatalf("response is not a ScanReport: %v\n%s", err, body.String()) + } + golden, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatal(err) + } + if err := protojson.Unmarshal(golden, &want); err != nil { + t.Fatal(err) + } + if !proto.Equal(&got, &want) { + t.Errorf("gateway report differs from %s:\n%s", goldenPath, body.String()) } } -func TestParseServeArgsRejectsPositionalAndUnknownFlags(t *testing.T) { +func TestServeScanRejectsBadURL(t *testing.T) { + srv := testServer(t) + resp, err := http.Post(srv.URL+"/v1/scans", "application/json", strings.NewReader(`{"url":"example.com"}`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("POST bad url = %d, want 400", resp.StatusCode) + } +} + +func TestServeRejectsBadArgs(t *testing.T) { for _, args := range [][]string{ - {"https://example.com"}, - {"--allow-private"}, - {"--addr"}, + {"serve", "https://example.com"}, {"serve", "--allow-private"}, {"serve", "--no-such-flag"}, } { - if _, err := parseServeArgs(args); err == nil { - t.Errorf("parseServeArgs(%q) = nil error, want error", args) + if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub); err == nil { + t.Errorf("run(%q) = nil error, want error", args) + } + } +} + +func TestServeListensAndStops(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + out := &syncBuffer{} + done := make(chan error, 1) + go func() { done <- run(ctx, []string{"serve", "--addr", "127.0.0.1:0"}, out, fixedStub) }() + for !strings.Contains(out.String(), "listening on http://127.0.0.1:") { + select { + case err := <-done: + t.Fatalf("serve exited early: %v", err) + default: + time.Sleep(5 * time.Millisecond) } } + cancel() + if err := <-done; err != nil { + t.Errorf("serve after cancel: %v", err) + } +} + +// syncBuffer is a bytes.Buffer safe to read while serve writes to it. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestServeDefaultAddr(t *testing.T) { + t.Setenv("TOUCHSTONE_ADDR", "") + if got := envOr("TOUCHSTONE_ADDR", defaultAddr); got != "127.0.0.1:8080" { + t.Errorf("default addr = %q, want 127.0.0.1:8080", got) + } + t.Setenv("TOUCHSTONE_ADDR", "127.0.0.1:9") + if got := envOr("TOUCHSTONE_ADDR", defaultAddr); got != "127.0.0.1:9" { + t.Errorf("env addr = %q", got) + } } diff --git a/web/embed.go b/web/embed.go index 3a5002c..e0c3ae5 100644 --- a/web/embed.go +++ b/web/embed.go @@ -1,6 +1,6 @@ -// Package web embeds the Next.js static export that `touchstone serve` -// returns. `make web-build` writes the export to web/out; it is not -// committed. out/.gitkeep keeps the embed pattern valid on a fresh checkout. +// Package web embeds the static export of the Next.js page (web/out, built +// with `npm run build`). Only the placeholder .gitkeep is committed, so the +// Go build works before the page has been built; the page is then a 404. package web import ( @@ -9,10 +9,13 @@ import ( ) //go:embed all:out -var out embed.FS +var embedded embed.FS -// Static returns the exported page with out/ stripped, so index.html and -// _next/ sit at the root. -func Static() (fs.FS, error) { - return fs.Sub(out, "out") +// Page returns the exported page rooted at its index.html. +func Page() fs.FS { + sub, err := fs.Sub(embedded, "out") + if err != nil { + panic(err) // "out" is always embedded + } + return sub } diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..cc2c471 --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from "next"; + +// A static export (web/out), embedded into the Go binary by web/embed.go and +// served by `touchstone serve`. The page calls POST /v1/scans on its own origin. +const config: NextConfig = { + output: "export", +}; + +export default config; diff --git a/web/package.json b/web/package.json index ff24ac9..d67583a 100644 --- a/web/package.json +++ b/web/package.json @@ -3,25 +3,28 @@ "private": true, "type": "module", "scripts": { + "dev": "next dev", + "build": "next build", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { "@bufbuild/protobuf": "2.16.0", - "@tailwindcss/postcss": "4.3.3", - "next": "16.4.3", - "postcss": "8.5.31", - "react": "19.3.1", - "react-dom": "19.3.1", - "tailwindcss": "4.3.3" + "next": "16.0.10", + "react": "19.2.0", + "react-dom": "19.2.0" }, "devDependencies": { - "@testing-library/dom": "10.4.2", - "@testing-library/react": "16.3.3", + "@tailwindcss/postcss": "4.1.17", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.0", + "@testing-library/user-event": "14.6.1", "@types/node": "24.19.2", - "@types/react": "19.3.0", - "@types/react-dom": "19.3.0", - "jsdom": "26.1.0", + "@types/react": "19.2.2", + "@types/react-dom": "19.2.2", + "@vitejs/plugin-react": "5.1.0", + "jsdom": "27.0.0", + "tailwindcss": "4.1.17", "typescript": "5.9.3", "vitest": "3.2.7" } diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index 61e3684..c2ddf74 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,7 +1,5 @@ -const config = { +export default { plugins: { "@tailwindcss/postcss": {}, }, }; - -export default config; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 60e8738..df7fae1 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -4,21 +4,13 @@ import "./globals.css"; export const metadata: Metadata = { title: "Touchstone", - description: "Paste a vendor's URL, get an evidence-graded CVE report.", + description: "Evidence-graded CVE report for a vendor URL, from passive observation only.", }; export default function RootLayout({ children }: { children: ReactNode }) { return ( - -
    -
    -

    Touchstone

    -

    Paste a vendor's URL, get an evidence-graded CVE report.

    -
    - {children} -
    - + {children} ); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 6c8a88e..32545d0 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,5 +1,5 @@ -import { ScanPage } from "../components/scan-page"; +import { ScanPage } from "../components/ScanPage"; -export default function Home() { +export default function Page() { return ; } diff --git a/web/src/components/ReportView.test.tsx b/web/src/components/ReportView.test.tsx new file mode 100644 index 0000000..1c627a3 --- /dev/null +++ b/web/src/components/ReportView.test.tsx @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs"; +import { fromJson, type JsonObject, type JsonValue } from "@bufbuild/protobuf"; +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ScanReportSchema } from "../gen/touchstone/v1/scan_pb"; +import { ReportView } from "./ReportView"; + +const golden = JSON.parse( + readFileSync(new URL("../../../testdata/report.golden.json", import.meta.url), "utf8"), +) as JsonObject; + +function renderReport(overrides: Record = {}) { + const report = fromJson(ScanReportSchema, { ...golden, ...overrides }); + return render(); +} + +const component = (name: string, confidence: string): JsonObject => ({ + name, + version: "1.0.0", + purl: `pkg:npm/${name}@1.0.0`, + confidence, + evidence: [{ kind: "EVIDENCE_KIND_FINGERPRINT_ASSET", location: `https://example.com/${name}.js`, detail: name }], +}); + +describe("ReportView", () => { + it("shows the jQuery component under Low confidence with its CVE and evidence", () => { + renderReport(); + + const low = screen.getByRole("region", { name: "Low confidence" }); + expect(within(low).getByRole("heading", { name: "jquery 1.12.4" })).toBeInTheDocument(); + expect(within(low).getByText("CVE-2020-11022")).toBeInTheDocument(); + expect(within(low).getByText(/Potential XSS vulnerability/)).toBeInTheDocument(); + expect(within(low).getByText("CVSS 6.1")).toBeInTheDocument(); + expect(within(low).getByText("Evidence (1)")).toBeInTheDocument(); + expect(within(low).getByText("jquery-1.12.4.min.js")).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "High confidence" })).not.toBeInTheDocument(); + }); + + it("groups components by tier, best evidence first", () => { + renderReport({ + components: [ + component("low-lib", "CONFIDENCE_LOW"), + component("high-lib", "CONFIDENCE_HIGH"), + component("mid-lib", "CONFIDENCE_MEDIUM"), + ], + }); + + const tiers = screen + .getAllByRole("region") + .map((r) => r.getAttribute("aria-label")) + .filter((label) => label?.endsWith("confidence")); + expect(tiers).toEqual(["High confidence", "Medium confidence", "Low confidence"]); + expect( + within(screen.getByRole("region", { name: "High confidence" })).getByText("high-lib 1.0.0"), + ).toBeInTheDocument(); + }); + + it("lists every checked location with its reason and result", () => { + renderReport({ + checked: [ + { url: "https://example.com", reason: "TARGET", result: "RESULT_FOUND" }, + { url: "https://example.com/.well-known/security.txt", reason: "DISCOVERY_PATH", result: "RESULT_NOT_FOUND" }, + { url: "https://example.com/x.js", reason: "SUBRESOURCE", result: "RESULT_ERROR", error: "timeout" }, + ], + }); + + const section = screen.getByRole("heading", { name: "Checked locations (3)" }).closest("section"); + expect(section).not.toBeNull(); + const items = within(section as HTMLElement).getAllByRole("listitem"); + expect(items).toHaveLength(3); + expect(items[1]).toHaveTextContent("security.txt — discovery path: not found"); + expect(items[2]).toHaveTextContent("subresource: error (timeout)"); + }); + + it("shows no banner when the lookup is complete", () => { + renderReport(); + expect(screen.queryByText(/Lookup incomplete/)).not.toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it.each(["LOOKUP_STATUS_PARTIAL", "LOOKUP_STATUS_FAILED"])( + "shows the lookup incomplete banner for %s and never claims zero CVEs", + (lookupStatus) => { + renderReport({ + lookupStatus, + components: [component("jquery", "CONFIDENCE_LOW")], + }); + + expect(screen.getByRole("alert")).toHaveTextContent("Lookup incomplete"); + expect(screen.queryByText("No known vulnerabilities.")).not.toBeInTheDocument(); + expect(screen.getByText(/the vulnerability lookup was incomplete/)).toBeInTheDocument(); + }, + ); + + it("shows an error, not an empty report, when the target was unreachable", () => { + renderReport({ + scanStatus: "SCAN_STATUS_TARGET_UNREACHABLE", + lookupStatus: "LOOKUP_STATUS_UNSPECIFIED", + components: [], + checked: [{ url: "https://example.com", reason: "TARGET", result: "RESULT_ERROR", error: "connection refused" }], + }); + + expect(screen.getByRole("alert")).toHaveTextContent("Target unreachable"); + expect(screen.queryByText(/No components were detected/)).not.toBeInTheDocument(); + expect(screen.getByText(/connection refused/)).toBeInTheDocument(); + }); + + it("renders vendor strings as text, not markup", () => { + renderReport({ + components: [ + { + ...component("evil", "CONFIDENCE_LOW"), + caveats: [""], + }, + ], + }); + + expect(screen.getByText(//)).toBeInTheDocument(); + expect(document.querySelector("img")).toBeNull(); + }); +}); diff --git a/web/src/components/ReportView.tsx b/web/src/components/ReportView.tsx new file mode 100644 index 0000000..83d3a1f --- /dev/null +++ b/web/src/components/ReportView.tsx @@ -0,0 +1,203 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { + type CheckedLocation, + CheckedLocation_Result, + type Component, + type Evidence, + EvidenceKind, + LookupStatus, + type ScanReport, + ScanStatus, + type Vulnerability, + VexStatus, +} from "../gen/touchstone/v1/scan_pb"; +import { groupByTier } from "../lib/group"; + +const evidenceLabel: Record = { + [EvidenceKind.UNSPECIFIED]: "Unknown evidence", + [EvidenceKind.SBOM_PUBLISHED]: "Published SBOM", + [EvidenceKind.SBOM_SUPPLIED]: "Supplied SBOM", + [EvidenceKind.VEX]: "VEX", + [EvidenceKind.REPO_LOCKFILE]: "Repo lockfile", + [EvidenceKind.FINGERPRINT_ASSET]: "Fingerprint: asset", + [EvidenceKind.FINGERPRINT_HEADER]: "Fingerprint: header", +}; + +const resultLabel: Record = { + [CheckedLocation_Result.UNSPECIFIED]: "unknown", + [CheckedLocation_Result.FOUND]: "found", + [CheckedLocation_Result.NOT_FOUND]: "not found", + [CheckedLocation_Result.ERROR]: "error", + [CheckedLocation_Result.REFUSED]: "refused", +}; + +const vexLabel: Partial> = { + [VexStatus.AFFECTED]: "VEX: affected", + [VexStatus.NOT_AFFECTED]: "VEX: not affected", + [VexStatus.FIXED]: "VEX: fixed", + [VexStatus.UNDER_INVESTIGATION]: "VEX: under investigation", +}; + +function time(ts: Parameters[0] | undefined): string { + return ts ? timestampDate(ts).toISOString() : "unknown"; +} + +function EvidenceItem({ e }: { e: Evidence }) { + return ( +
  • + {evidenceLabel[e.kind]} at {e.location} + {e.detail ? ( + <> + {" "} + matched {e.detail} + + ) : null} +
  • + ); +} + +function VulnItem({ v }: { v: Vulnerability }) { + const badge = "rounded px-1.5 py-0.5 text-xs font-medium"; + const vex = vexLabel[v.vexStatus]; + return ( +
  • +
    + {v.id} + {v.kev ? Known exploited (KEV) : null} + {v.cvssBase !== undefined ? ( + CVSS {v.cvssBase.toFixed(1)} + ) : null} + {v.epss !== undefined ? ( + EPSS {(v.epss * 100).toFixed(2)}% + ) : null} + {vex ? {vex} : null} + {v.suppressed ? Suppressed : null} +
    + {v.summary ?

    {v.summary}

    : null} + {v.aliases.length > 0 ?

    Also: {v.aliases.join(", ")}

    : null} + {v.vexJustification ?

    {v.vexJustification}

    : null} +
  • + ); +} + +function ComponentCard({ c, lookupComplete }: { c: Component; lookupComplete: boolean }) { + return ( +
  • +

    + {c.name} {c.version} +

    + {c.purl ?

    {c.purl}

    : null} + {c.caveats.length > 0 ? ( +

    Caveats: {c.caveats.join(", ")}

    + ) : null} + +
    Vulnerabilities (ranked)
    + {c.vulnerabilities.length > 0 ? ( +
      + {c.vulnerabilities.map((v) => ( + + ))} +
    + ) : ( + // An empty list is only "none known" when every lookup succeeded. +

    + {lookupComplete ? "No known vulnerabilities." : "Unknown: the vulnerability lookup was incomplete."} +

    + )} + +
    + Evidence ({c.evidence.length}) +
      + {c.evidence.map((e, i) => ( + + ))} +
    +
    +
  • + ); +} + +function CheckedList({ checked }: { checked: readonly CheckedLocation[] }) { + return ( +
    +

    + Checked locations ({checked.length}) +

    +

    Every request Touchstone made, and why.

    +
      + {checked.map((l, i) => ( +
    • + {l.url} — {l.reason.toLowerCase().replaceAll("_", " ")}: {resultLabel[l.result]} + {l.error ? ` (${l.error})` : ""} +
    • + ))} +
    +
    + ); +} + +export function ReportView({ report }: { report: ScanReport }) { + const unreachable = report.scanStatus !== ScanStatus.OK; + const lookupComplete = report.lookupStatus === LookupStatus.COMPLETE; + const tiers = groupByTier(report.components); + + return ( +
    +

    Report for {report.targetUrl}

    +

    + Scanned {time(report.startedAt)} · tool {report.toolVersion} · {report.requestsUsed} of{" "} + {report.requestBudget} requests used · vulnerability data as of {time(report.vulnDataAsOf)} +

    + + {unreachable ? ( +
    + Target unreachable. Touchstone could not fetch {report.targetUrl}, so nothing was + scanned. This is not an empty report: no components were checked. See the checked locations below. +
    + ) : ( + <> + {!lookupComplete ? ( +
    + Lookup incomplete. Some vulnerability lookups failed ( + {report.lookupStatus === LookupStatus.FAILED ? "all of them" : "some of them"}), so a missing + CVE below does not mean none exist. +
    + ) : null} + + {tiers.length === 0 ? ( +

    No components were detected from the page's public assets.

    + ) : ( + tiers.map((t) => ( +
    +

    + {t.label} ({t.components.length}) +

    +
      + {t.components.map((c) => ( + + ))} +
    +
    + )) + )} + + )} + + + + {report.sources.some((s) => !s.ok) ? ( +
      + {report.sources + .filter((s) => !s.ok) + .map((s) => ( +
    • + Source {s.source} failed: {s.error} +
    • + ))} +
    + ) : null} + +

    {report.disclaimer}

    +
    + ); +} diff --git a/web/src/components/ScanPage.test.tsx b/web/src/components/ScanPage.test.tsx new file mode 100644 index 0000000..95d21cc --- /dev/null +++ b/web/src/components/ScanPage.test.tsx @@ -0,0 +1,73 @@ +import { readFileSync } from "node:fs"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ScanPage } from "./ScanPage"; + +const golden = readFileSync(new URL("../../../testdata/report.golden.json", import.meta.url), "utf8"); + +function mockFetch(status: number, body: string) { + const fn = vi.fn(async () => new Response(body, { status, headers: { "Content-Type": "application/json" } })); + vi.stubGlobal("fetch", fn); + return fn; +} + +async function scanUrl(url: string) { + const user = userEvent.setup(); + render(); + await user.type(screen.getByLabelText("Vendor URL"), url); + await user.click(screen.getByRole("button", { name: "Scan" })); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("ScanPage", () => { + it("posts the URL to /v1/scans and renders the report", async () => { + const fetchMock = mockFetch(200, golden); + + await scanUrl("https://example.com"); + + expect(await screen.findByRole("region", { name: "Low confidence" })).toBeInTheDocument(); + expect(screen.getByText("CVE-2020-11022")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [path, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(path).toBe("/v1/scans"); + expect(init.method).toBe("POST"); + expect(JSON.parse(String(init.body))).toEqual({ url: "https://example.com" }); + }); + + it("rejects a non-http URL without calling the server", async () => { + const fetchMock = mockFetch(200, golden); + + await scanUrl("ftp://example.com"); + + expect(await screen.findByRole("alert")).toHaveTextContent("Only http and https"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("shows the server's message when the scan is rejected", async () => { + mockFetch(400, JSON.stringify({ code: 3, message: "url must be an absolute http(s) URL" })); + + await scanUrl("https://example.com"); + + expect(await screen.findByRole("alert")).toHaveTextContent("url must be an absolute http(s) URL"); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); + + it("shows an error state when the server is unreachable", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(new TypeError("Failed to fetch")))); + + await scanUrl("https://example.com"); + + expect(await screen.findByRole("alert")).toHaveTextContent("Could not reach the Touchstone server"); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + }); + + it("shows an error state when the response is not a scan report", async () => { + mockFetch(200, JSON.stringify({ scanStatus: "NOT_A_STATUS" })); + + await scanUrl("https://example.com"); + + expect(await screen.findByRole("alert")).toHaveTextContent("not a Touchstone scan report"); + }); +}); diff --git a/web/src/components/ScanPage.tsx b/web/src/components/ScanPage.tsx new file mode 100644 index 0000000..e8e0bc1 --- /dev/null +++ b/web/src/components/ScanPage.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import type { ScanReport } from "../gen/touchstone/v1/scan_pb"; +import { scan, ScanError, validateUrl } from "../lib/scan"; +import { ReportView } from "./ReportView"; + +type State = + | { kind: "idle" } + | { kind: "scanning" } + | { kind: "error"; message: string } + | { kind: "report"; report: ScanReport }; + +export function ScanPage() { + const [url, setUrl] = useState(""); + const [state, setState] = useState({ kind: "idle" }); + + async function onSubmit(ev: FormEvent) { + ev.preventDefault(); + const invalid = validateUrl(url); + if (invalid) { + setState({ kind: "error", message: invalid }); + return; + } + setState({ kind: "scanning" }); + try { + setState({ kind: "report", report: await scan(url) }); + } catch (err) { + setState({ kind: "error", message: err instanceof ScanError ? err.message : "The scan failed unexpectedly." }); + } + } + + return ( +
    +

    Touchstone

    +

    + Paste a vendor URL for a CVE report graded by evidence quality. Touchstone only makes requests a + browser loading the page would make. +

    +
    + + setUrl(e.target.value)} + placeholder="https://vendor.example" + className="flex-1 rounded border border-slate-300 bg-white px-3 py-2" + /> + +
    + + {state.kind === "scanning" ? ( +

    + Scanning… this can take up to a minute. +

    + ) : null} + {state.kind === "error" ? ( +
    + Scan failed. {state.message} +
    + ) : null} + {state.kind === "report" ? : null} +
    + ); +} diff --git a/web/src/lib/group.ts b/web/src/lib/group.ts new file mode 100644 index 0000000..f0fa35b --- /dev/null +++ b/web/src/lib/group.ts @@ -0,0 +1,21 @@ +import { type Component, Confidence } from "../gen/touchstone/v1/scan_pb"; + +export interface Tier { + confidence: Confidence; + label: string; + components: Component[]; +} + +const order: { confidence: Confidence; label: string }[] = [ + { confidence: Confidence.HIGH, label: "High confidence" }, + { confidence: Confidence.MEDIUM, label: "Medium confidence" }, + { confidence: Confidence.LOW, label: "Low confidence" }, + { confidence: Confidence.UNSPECIFIED, label: "Unrated" }, +]; + +/** Groups components by confidence tier, best evidence first; empty tiers are dropped. */ +export function groupByTier(components: readonly Component[]): Tier[] { + return order + .map((t) => ({ ...t, components: components.filter((c) => c.confidence === t.confidence) })) + .filter((t) => t.components.length > 0); +} diff --git a/web/src/lib/scan.ts b/web/src/lib/scan.ts new file mode 100644 index 0000000..d9b19f5 --- /dev/null +++ b/web/src/lib/scan.ts @@ -0,0 +1,54 @@ +import { fromJson, type JsonValue } from "@bufbuild/protobuf"; +import { type ScanReport, ScanReportSchema } from "../gen/touchstone/v1/scan_pb"; + +export class ScanError extends Error {} + +/** Returns the message to show for a URL the scan would reject, or null. */ +export function validateUrl(raw: string): string | null { + let u: URL; + try { + u = new URL(raw.trim()); + } catch { + return "Enter an absolute http(s) URL, for example https://vendor.example."; + } + if (u.protocol !== "http:" && u.protocol !== "https:") { + return "Only http and https URLs can be scanned."; + } + return null; +} + +/** POST /v1/scans (grpc-gateway). Failures throw ScanError with a message fit to show. */ +export async function scan(url: string, signal?: AbortSignal): Promise { + let res: Response; + try { + res = await fetch("/v1/scans", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: url.trim() }), + signal, + }); + } catch (err) { + if (err instanceof DOMException && err.name === "AbortError") throw err; + throw new ScanError("Could not reach the Touchstone server."); + } + + let body: JsonValue; + try { + body = await res.json(); + } catch { + throw new ScanError(`The server answered ${res.status} with a body that is not JSON.`); + } + if (!res.ok) { + // grpc-gateway errors are {"code": n, "message": "..."}. + const message = + typeof body === "object" && body !== null && !Array.isArray(body) && typeof body["message"] === "string" + ? body["message"] + : `The scan failed (HTTP ${res.status}).`; + throw new ScanError(message); + } + try { + return fromJson(ScanReportSchema, body); + } catch { + throw new ScanError("The server's response is not a Touchstone scan report."); + } +} diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts new file mode 100644 index 0000000..bb32bff --- /dev/null +++ b/web/src/test-setup.ts @@ -0,0 +1,5 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); diff --git a/web/tsconfig.json b/web/tsconfig.json index 20dfcba..bfb95a9 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -3,14 +3,16 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["node"], - "jsx": "react-jsx", + "jsx": "preserve", "strict": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "skipLibCheck": true, - "noEmit": true + "noEmit": true, + "incremental": true, + "plugins": [{ "name": "next" }] }, - "include": ["src"] + "include": ["next-env.d.ts", "src", ".next/types/**/*.ts"] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 0bf16e8..9bf0907 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,13 +1,10 @@ -import { fileURLToPath } from "node:url"; +import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ - // The tests render TSX with the automatic runtime, as Next.js does. - esbuild: { jsx: "automatic" }, + plugins: [react()], test: { environment: "jsdom", - // jsdom rewrites import.meta.url to http://, so the golden file's path is - // resolved here, in Node, and handed to the tests. - env: { GOLDEN_REPORT_PATH: fileURLToPath(new URL("../testdata/report.golden.json", import.meta.url)) }, + setupFiles: ["./src/test-setup.ts"], }, }); From d4f01b359c55ab59de85bef5c9f0fd2f76098840 Mon Sep 17 00:00:00 2001 From: hsinatfootprintai <149764663+hsinatfootprintai@users.noreply.github.com> Date: Mon, 26 Oct 2026 23:29:55 +0800 Subject: [PATCH 32/32] fix(web): restore the web files of the previous attempt, keep the real-scanner fix (#9) The revise run rewrote the web side from a clean main: it left two copies of the report and scan-page components, broke vitest.config.ts (the golden report path, so 6 of 7 test files failed), and changed the pinned dependency versions. Restore web/, Makefile and .gitignore to the previous head, and keep the Go side of the revision: main.go and serve.go wire the real service.Scanner (serve no longer uses the stub), with the serve tests and web/embed.go that serve depends on. go vet, go test ./..., tsc and vitest (5 files, 42 tests) pass on this tree. Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + Makefile | 10 +- web/next.config.ts | 9 -- web/package.json | 25 ++- web/postcss.config.mjs | 4 +- web/src/app/layout.tsx | 12 +- web/src/app/page.tsx | 4 +- web/src/components/ReportView.test.tsx | 121 --------------- web/src/components/ReportView.tsx | 203 ------------------------- web/src/components/ScanPage.test.tsx | 73 --------- web/src/components/ScanPage.tsx | 75 --------- web/src/lib/group.ts | 21 --- web/src/lib/scan.ts | 54 ------- web/src/test-setup.ts | 5 - web/tsconfig.json | 10 +- web/vitest.config.ts | 9 +- 16 files changed, 42 insertions(+), 594 deletions(-) delete mode 100644 web/next.config.ts delete mode 100644 web/src/components/ReportView.test.tsx delete mode 100644 web/src/components/ReportView.tsx delete mode 100644 web/src/components/ScanPage.test.tsx delete mode 100644 web/src/components/ScanPage.tsx delete mode 100644 web/src/lib/group.ts delete mode 100644 web/src/lib/scan.ts delete mode 100644 web/src/test-setup.ts diff --git a/.gitignore b/.gitignore index 4d5e273..ec484cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +# next build output and its generated type shim; web/out is embedded at build time web/.next/ web/next-env.d.ts web/out/* diff --git a/Makefile b/Makefile index 9d98e2a..f5aea9b 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,5 @@ .PHONY: generate lint breaking test web-test web-build -# Builds the static page into web/out, which `go build` embeds into -# `touchstone serve`; run it before building the binary you serve with. -web-build: - cd web && npm install --no-package-lock && npm run build - # Requires buf (https://buf.build): regenerates gen/, web/src/gen/, # openapi/ and schema/ from proto/touchstone/v1/scan.proto, using the # googleapis version pinned in buf.lock (`buf dep update` to bump it). @@ -27,3 +22,8 @@ test: web-test: cd web && npm install --no-package-lock && npx tsc --noEmit && npx vitest run + +# Exports the page to web/out, which web/embed.go embeds into touchstone serve. +# Build it before `go build ./cmd/touchstone`. +web-build: + cd web && npm install --no-package-lock && npx next build diff --git a/web/next.config.ts b/web/next.config.ts deleted file mode 100644 index cc2c471..0000000 --- a/web/next.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { NextConfig } from "next"; - -// A static export (web/out), embedded into the Go binary by web/embed.go and -// served by `touchstone serve`. The page calls POST /v1/scans on its own origin. -const config: NextConfig = { - output: "export", -}; - -export default config; diff --git a/web/package.json b/web/package.json index d67583a..ff24ac9 100644 --- a/web/package.json +++ b/web/package.json @@ -3,28 +3,25 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev", - "build": "next build", "typecheck": "tsc --noEmit", "test": "vitest run" }, "dependencies": { "@bufbuild/protobuf": "2.16.0", - "next": "16.0.10", - "react": "19.2.0", - "react-dom": "19.2.0" + "@tailwindcss/postcss": "4.3.3", + "next": "16.4.3", + "postcss": "8.5.31", + "react": "19.3.1", + "react-dom": "19.3.1", + "tailwindcss": "4.3.3" }, "devDependencies": { - "@tailwindcss/postcss": "4.1.17", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "16.3.0", - "@testing-library/user-event": "14.6.1", + "@testing-library/dom": "10.4.2", + "@testing-library/react": "16.3.3", "@types/node": "24.19.2", - "@types/react": "19.2.2", - "@types/react-dom": "19.2.2", - "@vitejs/plugin-react": "5.1.0", - "jsdom": "27.0.0", - "tailwindcss": "4.1.17", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "jsdom": "26.1.0", "typescript": "5.9.3", "vitest": "3.2.7" } diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index c2ddf74..61e3684 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,5 +1,7 @@ -export default { +const config = { plugins: { "@tailwindcss/postcss": {}, }, }; + +export default config; diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index df7fae1..60e8738 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -4,13 +4,21 @@ import "./globals.css"; export const metadata: Metadata = { title: "Touchstone", - description: "Evidence-graded CVE report for a vendor URL, from passive observation only.", + description: "Paste a vendor's URL, get an evidence-graded CVE report.", }; export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + +
    +
    +

    Touchstone

    +

    Paste a vendor's URL, get an evidence-graded CVE report.

    +
    + {children} +
    + ); } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 32545d0..6c8a88e 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,5 +1,5 @@ -import { ScanPage } from "../components/ScanPage"; +import { ScanPage } from "../components/scan-page"; -export default function Page() { +export default function Home() { return ; } diff --git a/web/src/components/ReportView.test.tsx b/web/src/components/ReportView.test.tsx deleted file mode 100644 index 1c627a3..0000000 --- a/web/src/components/ReportView.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { readFileSync } from "node:fs"; -import { fromJson, type JsonObject, type JsonValue } from "@bufbuild/protobuf"; -import { render, screen, within } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { ScanReportSchema } from "../gen/touchstone/v1/scan_pb"; -import { ReportView } from "./ReportView"; - -const golden = JSON.parse( - readFileSync(new URL("../../../testdata/report.golden.json", import.meta.url), "utf8"), -) as JsonObject; - -function renderReport(overrides: Record = {}) { - const report = fromJson(ScanReportSchema, { ...golden, ...overrides }); - return render(); -} - -const component = (name: string, confidence: string): JsonObject => ({ - name, - version: "1.0.0", - purl: `pkg:npm/${name}@1.0.0`, - confidence, - evidence: [{ kind: "EVIDENCE_KIND_FINGERPRINT_ASSET", location: `https://example.com/${name}.js`, detail: name }], -}); - -describe("ReportView", () => { - it("shows the jQuery component under Low confidence with its CVE and evidence", () => { - renderReport(); - - const low = screen.getByRole("region", { name: "Low confidence" }); - expect(within(low).getByRole("heading", { name: "jquery 1.12.4" })).toBeInTheDocument(); - expect(within(low).getByText("CVE-2020-11022")).toBeInTheDocument(); - expect(within(low).getByText(/Potential XSS vulnerability/)).toBeInTheDocument(); - expect(within(low).getByText("CVSS 6.1")).toBeInTheDocument(); - expect(within(low).getByText("Evidence (1)")).toBeInTheDocument(); - expect(within(low).getByText("jquery-1.12.4.min.js")).toBeInTheDocument(); - expect(screen.queryByRole("region", { name: "High confidence" })).not.toBeInTheDocument(); - }); - - it("groups components by tier, best evidence first", () => { - renderReport({ - components: [ - component("low-lib", "CONFIDENCE_LOW"), - component("high-lib", "CONFIDENCE_HIGH"), - component("mid-lib", "CONFIDENCE_MEDIUM"), - ], - }); - - const tiers = screen - .getAllByRole("region") - .map((r) => r.getAttribute("aria-label")) - .filter((label) => label?.endsWith("confidence")); - expect(tiers).toEqual(["High confidence", "Medium confidence", "Low confidence"]); - expect( - within(screen.getByRole("region", { name: "High confidence" })).getByText("high-lib 1.0.0"), - ).toBeInTheDocument(); - }); - - it("lists every checked location with its reason and result", () => { - renderReport({ - checked: [ - { url: "https://example.com", reason: "TARGET", result: "RESULT_FOUND" }, - { url: "https://example.com/.well-known/security.txt", reason: "DISCOVERY_PATH", result: "RESULT_NOT_FOUND" }, - { url: "https://example.com/x.js", reason: "SUBRESOURCE", result: "RESULT_ERROR", error: "timeout" }, - ], - }); - - const section = screen.getByRole("heading", { name: "Checked locations (3)" }).closest("section"); - expect(section).not.toBeNull(); - const items = within(section as HTMLElement).getAllByRole("listitem"); - expect(items).toHaveLength(3); - expect(items[1]).toHaveTextContent("security.txt — discovery path: not found"); - expect(items[2]).toHaveTextContent("subresource: error (timeout)"); - }); - - it("shows no banner when the lookup is complete", () => { - renderReport(); - expect(screen.queryByText(/Lookup incomplete/)).not.toBeInTheDocument(); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); - }); - - it.each(["LOOKUP_STATUS_PARTIAL", "LOOKUP_STATUS_FAILED"])( - "shows the lookup incomplete banner for %s and never claims zero CVEs", - (lookupStatus) => { - renderReport({ - lookupStatus, - components: [component("jquery", "CONFIDENCE_LOW")], - }); - - expect(screen.getByRole("alert")).toHaveTextContent("Lookup incomplete"); - expect(screen.queryByText("No known vulnerabilities.")).not.toBeInTheDocument(); - expect(screen.getByText(/the vulnerability lookup was incomplete/)).toBeInTheDocument(); - }, - ); - - it("shows an error, not an empty report, when the target was unreachable", () => { - renderReport({ - scanStatus: "SCAN_STATUS_TARGET_UNREACHABLE", - lookupStatus: "LOOKUP_STATUS_UNSPECIFIED", - components: [], - checked: [{ url: "https://example.com", reason: "TARGET", result: "RESULT_ERROR", error: "connection refused" }], - }); - - expect(screen.getByRole("alert")).toHaveTextContent("Target unreachable"); - expect(screen.queryByText(/No components were detected/)).not.toBeInTheDocument(); - expect(screen.getByText(/connection refused/)).toBeInTheDocument(); - }); - - it("renders vendor strings as text, not markup", () => { - renderReport({ - components: [ - { - ...component("evil", "CONFIDENCE_LOW"), - caveats: [""], - }, - ], - }); - - expect(screen.getByText(//)).toBeInTheDocument(); - expect(document.querySelector("img")).toBeNull(); - }); -}); diff --git a/web/src/components/ReportView.tsx b/web/src/components/ReportView.tsx deleted file mode 100644 index 83d3a1f..0000000 --- a/web/src/components/ReportView.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { - type CheckedLocation, - CheckedLocation_Result, - type Component, - type Evidence, - EvidenceKind, - LookupStatus, - type ScanReport, - ScanStatus, - type Vulnerability, - VexStatus, -} from "../gen/touchstone/v1/scan_pb"; -import { groupByTier } from "../lib/group"; - -const evidenceLabel: Record = { - [EvidenceKind.UNSPECIFIED]: "Unknown evidence", - [EvidenceKind.SBOM_PUBLISHED]: "Published SBOM", - [EvidenceKind.SBOM_SUPPLIED]: "Supplied SBOM", - [EvidenceKind.VEX]: "VEX", - [EvidenceKind.REPO_LOCKFILE]: "Repo lockfile", - [EvidenceKind.FINGERPRINT_ASSET]: "Fingerprint: asset", - [EvidenceKind.FINGERPRINT_HEADER]: "Fingerprint: header", -}; - -const resultLabel: Record = { - [CheckedLocation_Result.UNSPECIFIED]: "unknown", - [CheckedLocation_Result.FOUND]: "found", - [CheckedLocation_Result.NOT_FOUND]: "not found", - [CheckedLocation_Result.ERROR]: "error", - [CheckedLocation_Result.REFUSED]: "refused", -}; - -const vexLabel: Partial> = { - [VexStatus.AFFECTED]: "VEX: affected", - [VexStatus.NOT_AFFECTED]: "VEX: not affected", - [VexStatus.FIXED]: "VEX: fixed", - [VexStatus.UNDER_INVESTIGATION]: "VEX: under investigation", -}; - -function time(ts: Parameters[0] | undefined): string { - return ts ? timestampDate(ts).toISOString() : "unknown"; -} - -function EvidenceItem({ e }: { e: Evidence }) { - return ( -
  • - {evidenceLabel[e.kind]} at {e.location} - {e.detail ? ( - <> - {" "} - matched {e.detail} - - ) : null} -
  • - ); -} - -function VulnItem({ v }: { v: Vulnerability }) { - const badge = "rounded px-1.5 py-0.5 text-xs font-medium"; - const vex = vexLabel[v.vexStatus]; - return ( -
  • -
    - {v.id} - {v.kev ? Known exploited (KEV) : null} - {v.cvssBase !== undefined ? ( - CVSS {v.cvssBase.toFixed(1)} - ) : null} - {v.epss !== undefined ? ( - EPSS {(v.epss * 100).toFixed(2)}% - ) : null} - {vex ? {vex} : null} - {v.suppressed ? Suppressed : null} -
    - {v.summary ?

    {v.summary}

    : null} - {v.aliases.length > 0 ?

    Also: {v.aliases.join(", ")}

    : null} - {v.vexJustification ?

    {v.vexJustification}

    : null} -
  • - ); -} - -function ComponentCard({ c, lookupComplete }: { c: Component; lookupComplete: boolean }) { - return ( -
  • -

    - {c.name} {c.version} -

    - {c.purl ?

    {c.purl}

    : null} - {c.caveats.length > 0 ? ( -

    Caveats: {c.caveats.join(", ")}

    - ) : null} - -
    Vulnerabilities (ranked)
    - {c.vulnerabilities.length > 0 ? ( -
      - {c.vulnerabilities.map((v) => ( - - ))} -
    - ) : ( - // An empty list is only "none known" when every lookup succeeded. -

    - {lookupComplete ? "No known vulnerabilities." : "Unknown: the vulnerability lookup was incomplete."} -

    - )} - -
    - Evidence ({c.evidence.length}) -
      - {c.evidence.map((e, i) => ( - - ))} -
    -
    -
  • - ); -} - -function CheckedList({ checked }: { checked: readonly CheckedLocation[] }) { - return ( -
    -

    - Checked locations ({checked.length}) -

    -

    Every request Touchstone made, and why.

    -
      - {checked.map((l, i) => ( -
    • - {l.url} — {l.reason.toLowerCase().replaceAll("_", " ")}: {resultLabel[l.result]} - {l.error ? ` (${l.error})` : ""} -
    • - ))} -
    -
    - ); -} - -export function ReportView({ report }: { report: ScanReport }) { - const unreachable = report.scanStatus !== ScanStatus.OK; - const lookupComplete = report.lookupStatus === LookupStatus.COMPLETE; - const tiers = groupByTier(report.components); - - return ( -
    -

    Report for {report.targetUrl}

    -

    - Scanned {time(report.startedAt)} · tool {report.toolVersion} · {report.requestsUsed} of{" "} - {report.requestBudget} requests used · vulnerability data as of {time(report.vulnDataAsOf)} -

    - - {unreachable ? ( -
    - Target unreachable. Touchstone could not fetch {report.targetUrl}, so nothing was - scanned. This is not an empty report: no components were checked. See the checked locations below. -
    - ) : ( - <> - {!lookupComplete ? ( -
    - Lookup incomplete. Some vulnerability lookups failed ( - {report.lookupStatus === LookupStatus.FAILED ? "all of them" : "some of them"}), so a missing - CVE below does not mean none exist. -
    - ) : null} - - {tiers.length === 0 ? ( -

    No components were detected from the page's public assets.

    - ) : ( - tiers.map((t) => ( -
    -

    - {t.label} ({t.components.length}) -

    -
      - {t.components.map((c) => ( - - ))} -
    -
    - )) - )} - - )} - - - - {report.sources.some((s) => !s.ok) ? ( -
      - {report.sources - .filter((s) => !s.ok) - .map((s) => ( -
    • - Source {s.source} failed: {s.error} -
    • - ))} -
    - ) : null} - -

    {report.disclaimer}

    -
    - ); -} diff --git a/web/src/components/ScanPage.test.tsx b/web/src/components/ScanPage.test.tsx deleted file mode 100644 index 95d21cc..0000000 --- a/web/src/components/ScanPage.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { readFileSync } from "node:fs"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ScanPage } from "./ScanPage"; - -const golden = readFileSync(new URL("../../../testdata/report.golden.json", import.meta.url), "utf8"); - -function mockFetch(status: number, body: string) { - const fn = vi.fn(async () => new Response(body, { status, headers: { "Content-Type": "application/json" } })); - vi.stubGlobal("fetch", fn); - return fn; -} - -async function scanUrl(url: string) { - const user = userEvent.setup(); - render(); - await user.type(screen.getByLabelText("Vendor URL"), url); - await user.click(screen.getByRole("button", { name: "Scan" })); -} - -afterEach(() => vi.unstubAllGlobals()); - -describe("ScanPage", () => { - it("posts the URL to /v1/scans and renders the report", async () => { - const fetchMock = mockFetch(200, golden); - - await scanUrl("https://example.com"); - - expect(await screen.findByRole("region", { name: "Low confidence" })).toBeInTheDocument(); - expect(screen.getByText("CVE-2020-11022")).toBeInTheDocument(); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [path, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; - expect(path).toBe("/v1/scans"); - expect(init.method).toBe("POST"); - expect(JSON.parse(String(init.body))).toEqual({ url: "https://example.com" }); - }); - - it("rejects a non-http URL without calling the server", async () => { - const fetchMock = mockFetch(200, golden); - - await scanUrl("ftp://example.com"); - - expect(await screen.findByRole("alert")).toHaveTextContent("Only http and https"); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("shows the server's message when the scan is rejected", async () => { - mockFetch(400, JSON.stringify({ code: 3, message: "url must be an absolute http(s) URL" })); - - await scanUrl("https://example.com"); - - expect(await screen.findByRole("alert")).toHaveTextContent("url must be an absolute http(s) URL"); - expect(screen.queryByRole("article")).not.toBeInTheDocument(); - }); - - it("shows an error state when the server is unreachable", async () => { - vi.stubGlobal("fetch", vi.fn(async () => Promise.reject(new TypeError("Failed to fetch")))); - - await scanUrl("https://example.com"); - - expect(await screen.findByRole("alert")).toHaveTextContent("Could not reach the Touchstone server"); - expect(screen.queryByRole("article")).not.toBeInTheDocument(); - }); - - it("shows an error state when the response is not a scan report", async () => { - mockFetch(200, JSON.stringify({ scanStatus: "NOT_A_STATUS" })); - - await scanUrl("https://example.com"); - - expect(await screen.findByRole("alert")).toHaveTextContent("not a Touchstone scan report"); - }); -}); diff --git a/web/src/components/ScanPage.tsx b/web/src/components/ScanPage.tsx deleted file mode 100644 index e8e0bc1..0000000 --- a/web/src/components/ScanPage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -"use client"; - -import { type FormEvent, useState } from "react"; -import type { ScanReport } from "../gen/touchstone/v1/scan_pb"; -import { scan, ScanError, validateUrl } from "../lib/scan"; -import { ReportView } from "./ReportView"; - -type State = - | { kind: "idle" } - | { kind: "scanning" } - | { kind: "error"; message: string } - | { kind: "report"; report: ScanReport }; - -export function ScanPage() { - const [url, setUrl] = useState(""); - const [state, setState] = useState({ kind: "idle" }); - - async function onSubmit(ev: FormEvent) { - ev.preventDefault(); - const invalid = validateUrl(url); - if (invalid) { - setState({ kind: "error", message: invalid }); - return; - } - setState({ kind: "scanning" }); - try { - setState({ kind: "report", report: await scan(url) }); - } catch (err) { - setState({ kind: "error", message: err instanceof ScanError ? err.message : "The scan failed unexpectedly." }); - } - } - - return ( -
    -

    Touchstone

    -

    - Paste a vendor URL for a CVE report graded by evidence quality. Touchstone only makes requests a - browser loading the page would make. -

    -
    - - setUrl(e.target.value)} - placeholder="https://vendor.example" - className="flex-1 rounded border border-slate-300 bg-white px-3 py-2" - /> - -
    - - {state.kind === "scanning" ? ( -

    - Scanning… this can take up to a minute. -

    - ) : null} - {state.kind === "error" ? ( -
    - Scan failed. {state.message} -
    - ) : null} - {state.kind === "report" ? : null} -
    - ); -} diff --git a/web/src/lib/group.ts b/web/src/lib/group.ts deleted file mode 100644 index f0fa35b..0000000 --- a/web/src/lib/group.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { type Component, Confidence } from "../gen/touchstone/v1/scan_pb"; - -export interface Tier { - confidence: Confidence; - label: string; - components: Component[]; -} - -const order: { confidence: Confidence; label: string }[] = [ - { confidence: Confidence.HIGH, label: "High confidence" }, - { confidence: Confidence.MEDIUM, label: "Medium confidence" }, - { confidence: Confidence.LOW, label: "Low confidence" }, - { confidence: Confidence.UNSPECIFIED, label: "Unrated" }, -]; - -/** Groups components by confidence tier, best evidence first; empty tiers are dropped. */ -export function groupByTier(components: readonly Component[]): Tier[] { - return order - .map((t) => ({ ...t, components: components.filter((c) => c.confidence === t.confidence) })) - .filter((t) => t.components.length > 0); -} diff --git a/web/src/lib/scan.ts b/web/src/lib/scan.ts deleted file mode 100644 index d9b19f5..0000000 --- a/web/src/lib/scan.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { fromJson, type JsonValue } from "@bufbuild/protobuf"; -import { type ScanReport, ScanReportSchema } from "../gen/touchstone/v1/scan_pb"; - -export class ScanError extends Error {} - -/** Returns the message to show for a URL the scan would reject, or null. */ -export function validateUrl(raw: string): string | null { - let u: URL; - try { - u = new URL(raw.trim()); - } catch { - return "Enter an absolute http(s) URL, for example https://vendor.example."; - } - if (u.protocol !== "http:" && u.protocol !== "https:") { - return "Only http and https URLs can be scanned."; - } - return null; -} - -/** POST /v1/scans (grpc-gateway). Failures throw ScanError with a message fit to show. */ -export async function scan(url: string, signal?: AbortSignal): Promise { - let res: Response; - try { - res = await fetch("/v1/scans", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: url.trim() }), - signal, - }); - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") throw err; - throw new ScanError("Could not reach the Touchstone server."); - } - - let body: JsonValue; - try { - body = await res.json(); - } catch { - throw new ScanError(`The server answered ${res.status} with a body that is not JSON.`); - } - if (!res.ok) { - // grpc-gateway errors are {"code": n, "message": "..."}. - const message = - typeof body === "object" && body !== null && !Array.isArray(body) && typeof body["message"] === "string" - ? body["message"] - : `The scan failed (HTTP ${res.status}).`; - throw new ScanError(message); - } - try { - return fromJson(ScanReportSchema, body); - } catch { - throw new ScanError("The server's response is not a Touchstone scan report."); - } -} diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts deleted file mode 100644 index bb32bff..0000000 --- a/web/src/test-setup.ts +++ /dev/null @@ -1,5 +0,0 @@ -import "@testing-library/jest-dom/vitest"; -import { cleanup } from "@testing-library/react"; -import { afterEach } from "vitest"; - -afterEach(cleanup); diff --git a/web/tsconfig.json b/web/tsconfig.json index bfb95a9..20dfcba 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -3,16 +3,14 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM"], "types": ["node"], - "jsx": "preserve", + "jsx": "react-jsx", "strict": true, "noUncheckedIndexedAccess": true, "resolveJsonModule": true, "skipLibCheck": true, - "noEmit": true, - "incremental": true, - "plugins": [{ "name": "next" }] + "noEmit": true }, - "include": ["next-env.d.ts", "src", ".next/types/**/*.ts"] + "include": ["src"] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 9bf0907..0bf16e8 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,10 +1,13 @@ -import react from "@vitejs/plugin-react"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [react()], + // The tests render TSX with the automatic runtime, as Next.js does. + esbuild: { jsx: "automatic" }, test: { environment: "jsdom", - setupFiles: ["./src/test-setup.ts"], + // jsdom rewrites import.meta.url to http://, so the golden file's path is + // resolved here, in Node, and handed to the tests. + env: { GOLDEN_REPORT_PATH: fileURLToPath(new URL("../testdata/report.golden.json", import.meta.url)) }, }, });