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 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 diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index 19cf4e7..5cc1c6b 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -1,6 +1,7 @@ // 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 +// (--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 @@ -20,7 +21,7 @@ import ( "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 @@ -38,6 +39,9 @@ func main() { } 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 new file mode 100644 index 0000000..0b22d44 --- /dev/null +++ b/cmd/touchstone/serve.go @@ -0,0 +1,98 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "net" + "net/http" + "os" + "time" + + 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 ( + defaultAddr = "127.0.0.1:8080" + + // scanTimeout bounds one scan request end to end. + scanTimeout = 180 * time.Second +) + +// 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 + } + ln, err := net.Listen("tcp", *addr) + if err != nil { + return err + } + 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()) + + errc := make(chan error, 1) + go func() { errc <- srv.Serve(ln) }() + select { + case err := <-errc: + return err + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdown) + } +} + +// 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 + } + 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, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/cmd/touchstone/serve_test.go b/cmd/touchstone/serve_test.go new file mode 100644 index 0000000..f09a4e8 --- /dev/null +++ b/cmd/touchstone/serve_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "testing/fstest" + "time" + + 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 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{ + {"serve", "https://example.com"}, {"serve", "--allow-private"}, {"serve", "--no-such-flag"}, + } { + 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/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 +} 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") + } +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..e0c3ae5 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,21 @@ +// 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 ( + "embed" + "io/fs" +) + +//go:embed all:out +var embedded embed.FS + +// 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.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; diff --git a/web/out/.gitkeep b/web/out/.gitkeep new file mode 100644 index 0000000..e69de29 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" } 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; 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"; 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} +
+ + + ); +} 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 ; +} 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(", ")}

    + )} +
  • + ); +} 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(); + }); +}); 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"; + } +} 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. +

    + )} +
    + ); +} 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(); + }); +}); 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."; +} 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); + }); +}); 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; +} 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; +} 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); + }, + ); +}); 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; + } +} 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", () => { 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); +} 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, 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)) }, + }, +});