From 45eb07a24eff5626a8a4d8863e58b6a58a3bc4dd Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:34 +0000 Subject: [PATCH 01/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- cmd/touchstone/main.go | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index 19cf4e7..bd19863 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). +// looks components up in OSV (by purl) and NVD (by CPE), enriched with KEV +// and EPSS. package main import ( @@ -29,7 +30,16 @@ type newService func(fetch.Options) touchstonev1.ScanServiceServer func main() { newSvc := func(o fetch.Options) touchstonev1.ScanServiceServer { - return &service.Scanner{Fetch: o, Vulns: vuln.NewOSV()} + // NVD_API_KEY is optional; without it NVD lookups are capped. + nvdKey := os.Getenv("NVD_API_KEY") + return &service.Scanner{ + Fetch: o, + Vulns: vuln.NewOSV(), + CPE: vuln.NewNVD(nvdKey), + CPEMax: vuln.NVDMaxLookups(nvdKey), + KEV: vuln.NewKEV(), + EPSS: vuln.NewEPSS(), + } } if err := run(context.Background(), os.Args[1:], os.Stdout, newSvc); err != nil { fmt.Fprintln(os.Stderr, "touchstone:", err) From 18533f81d595a8d7280710448f8b81264e0f6bb7 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:36 +0000 Subject: [PATCH 02/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/service/scanner.go | 161 ++++++++++++++++++++++++++++-------- 1 file changed, 126 insertions(+), 35 deletions(-) diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 76c139e..18f8917 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "net/url" "strings" "time" @@ -17,11 +18,10 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -// Scanner is the walking-skeleton ScanService (slice S2). It fetches the -// target page and the scripts it loads through a PassiveFetcher, -// fingerprints them, looks each purl up through vuln.Source, and returns -// the report. Merging across tiers and the NVD, KEV and EPSS lookups come -// in later slices. +// Scanner is the ScanService. It fetches the target page and the scripts it +// loads through a PassiveFetcher, fingerprints them, matches each component +// against the vulnerability sources, and returns the report. Merging across +// tiers comes in a later slice. type Scanner struct { touchstonev1.UnimplementedScanServiceServer @@ -33,6 +33,16 @@ type Scanner struct { // Vulns looks components up by purl (OSV in the CLI). Vulns vuln.Source + // CPE looks components that have a CPE but no purl up (NVD in the CLI). + // CPEMax caps the lookups one scan makes (vuln.NVDMaxLookups); components + // past the cap are reported as not looked up. + CPE vuln.Source + CPEMax int + + // KEV and EPSS enrich every CVE the lookups found. + KEV vuln.Enricher + EPSS vuln.Enricher + // Now returns the scan's start and completion time; time.Now if nil. Now func() time.Time } @@ -118,41 +128,119 @@ func collect(ctx context.Context, f fetch.Fetcher, page *fetch.Response) ([]pipe return merge(comps), errors.Join(errs...) } -// lookup asks vuln.Source about every component that has a purl and -// converts the components to proto. It returns the lookup status, which is -// COMPLETE only if every lookup succeeded. +// lookup matches every component against the vulnerability sources and +// converts the components to proto. A component with a purl goes to Vulns; +// one with only a CPE goes to CPE, up to CPEMax lookups. Every CVE found is +// enriched with KEV and EPSS, and each component's findings are ranked. Each +// source that was asked anything gets a SourceStatus. The lookup status is +// COMPLETE only if every question was answered. func (s *Scanner) lookup(ctx context.Context, rep *touchstonev1.ScanReport, comps []pipeline.Component) (touchstonev1.LookupStatus, []*touchstonev1.Component) { - var out []*touchstonev1.Component - var ok, failed int - var errs []error - for _, c := range comps { - var findings []vuln.Finding - if c.Purl != "" { - var err error - findings, err = s.Vulns.Lookup(ctx, []vuln.Query{{Purl: c.Purl}}) - if err != nil { - failed++ - errs = append(errs, err) - } else { - ok++ + osv := &tally{name: s.Vulns.Name()} + nvd := &tally{name: s.CPE.Name()} + kev := &tally{name: s.KEV.Name()} + epss := &tally{name: s.EPSS.Name()} + + findings := make([][]vuln.Finding, len(comps)) + var cpeLookups, capped int + for i, c := range comps { + switch { + case c.Purl != "": + f, err := s.Vulns.Lookup(ctx, []vuln.Query{{Purl: c.Purl}}) + osv.answer(err) + findings[i] = f + case c.CPE != "": + if cpeLookups >= s.CPEMax { + capped++ + continue } + cpeLookups++ + f, err := s.CPE.Lookup(ctx, []vuln.Query{{CPE: c.CPE}}) + nvd.answer(err) + findings[i] = f } - out = append(out, toProto(c, findings)) } - if ok+failed > 0 { - rep.Sources = append(rep.Sources, &touchstonev1.SourceStatus{ - Source: s.Vulns.Name(), - Ok: failed == 0, - Error: errText(errors.Join(errs...)), - }) + if capped > 0 { + nvd.skip(capped, fmt.Errorf("capped at %d CPE lookups per scan, %d components not looked up (NVD_API_KEY raises the cap)", s.CPEMax, capped)) + } + + var ids []string + for _, fs := range findings { + for _, f := range fs { + ids = append(ids, f.ID) + } + } + var kevs, epsss map[string]vuln.Enrichment + if len(ids) > 0 { + var err error + kevs, err = s.KEV.Enrich(ctx, ids) + kev.answer(err) + epsss, err = s.EPSS.Enrich(ctx, ids) + epss.answer(err) + } + + var out []*touchstonev1.Component + for i, c := range comps { + vuln.Apply(findings[i], kevs, epsss) + vuln.Rank(findings[i]) + out = append(out, toProto(c, findings[i])) + } + for _, t := range []*tally{osv, nvd, kev, epss} { + if t.asked > 0 { + rep.Sources = append(rep.Sources, t.status()) + } + } + return lookupStatus(osv, nvd, kev, epss), out +} + +// tally counts the questions one source was asked and the ones it failed to +// answer. +type tally struct { + name string + asked int + failed int + errs []error +} + +// answer records one question, failed if err is not nil. +func (t *tally) answer(err error) { + t.asked++ + if err != nil { + t.failed++ + t.errs = append(t.errs, err) + } +} + +// skip records n questions the source was never asked, because a limit +// stopped them, as failed with the reason. +func (t *tally) skip(n int, err error) { + t.asked += n + t.failed += n + t.errs = append(t.errs, err) +} + +func (t *tally) status() *touchstonev1.SourceStatus { + return &touchstonev1.SourceStatus{ + Source: t.name, + Ok: t.failed == 0, + Error: errText(errors.Join(t.errs...)), + } +} + +// lookupStatus is COMPLETE when no question failed, FAILED when every question +// failed, and PARTIAL when some did. A scan that asked nothing is COMPLETE. +func lookupStatus(ts ...*tally) touchstonev1.LookupStatus { + var asked, failed int + for _, t := range ts { + asked += t.asked + failed += t.failed } switch { case failed == 0: - return touchstonev1.LookupStatus_LOOKUP_STATUS_COMPLETE, out - case ok > 0: - return touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL, out + return touchstonev1.LookupStatus_LOOKUP_STATUS_COMPLETE + case failed == asked: + return touchstonev1.LookupStatus_LOOKUP_STATUS_FAILED default: - return touchstonev1.LookupStatus_LOOKUP_STATUS_FAILED, out + return touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL } } @@ -209,9 +297,12 @@ func toProto(c pipeline.Component, findings []vuln.Finding) *touchstonev1.Compon } for _, f := range findings { out.Vulnerabilities = append(out.Vulnerabilities, &touchstonev1.Vulnerability{ - Id: f.ID, - Aliases: f.Aliases, - Summary: f.Summary, + Id: f.ID, + Aliases: f.Aliases, + Summary: f.Summary, + Kev: f.KEV, + Epss: f.EPSS, + CvssBase: f.CVSSBase, }) } return out From 6dbea49323e60481ed2507a70af885c89f553d13 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:37 +0000 Subject: [PATCH 03/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/service/scanner_test.go | 218 +++++++++++++++++++++++++++---- 1 file changed, 194 insertions(+), 24 deletions(-) diff --git a/internal/service/scanner_test.go b/internal/service/scanner_test.go index d73098b..f06dd24 100644 --- a/internal/service/scanner_test.go +++ b/internal/service/scanner_test.go @@ -11,6 +11,7 @@ import ( touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" "github.com/FootprintAI/touchstone/internal/fetch" + "github.com/FootprintAI/touchstone/internal/pipeline" "github.com/FootprintAI/touchstone/internal/vuln" ) @@ -68,16 +69,75 @@ func recordedOSVSite(t *testing.T) *httptest.Server { return srv } -func newTestScanner(osvURL string) *Scanner { +// recordedFeeds replays the NVD, KEV and EPSS responses recorded under +// internal/vuln/testdata. NVD answers only the nginx CPE; anything else is a +// 404, which the scanner must report as a failed lookup. +func recordedFeeds(t *testing.T) *httptest.Server { + t.Helper() + dir := filepath.Join("..", "vuln", "testdata") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var file string + switch { + case r.URL.Path == "/rest/json/cves/2.0" && strings.Contains(r.URL.Query().Get("cpeName"), ":f5:nginx:"): + file = "nvd/cpe-nginx-1.18.0.json" + case r.URL.Path == "/kev.json": + file = "kev/known_exploited_vulnerabilities.json" + case r.URL.Path == "/data/v1/epss": + file = "epss/epss.json" + default: + http.NotFound(w, r) + return + } + b, err := os.ReadFile(filepath.Join(dir, file)) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + })) + t.Cleanup(srv.Close) + return srv +} + +// newTestScanner wires every source to a recorded server: OSV at osvURL, and +// NVD, KEV and EPSS at the recorded feeds. +func newTestScanner(t *testing.T, osvURL string) *Scanner { + t.Helper() + feeds := recordedFeeds(t).URL return &Scanner{ - Fetch: fetch.Options{AllowPrivate: true, Version: ToolVersion}, - Vulns: &vuln.OSV{BaseURL: osvURL, Client: http.DefaultClient}, + Fetch: fetch.Options{AllowPrivate: true, Version: ToolVersion}, + Vulns: &vuln.OSV{BaseURL: osvURL, Client: http.DefaultClient}, + CPE: &vuln.NVD{BaseURL: feeds, Client: http.DefaultClient}, + CPEMax: vuln.NVDMaxLookups(""), + KEV: &vuln.KEV{URL: feeds + "/kev.json", Client: http.DefaultClient}, + EPSS: &vuln.EPSS{BaseURL: feeds + "/data/v1/epss", Client: http.DefaultClient}, } } +// sourceStatus returns the SourceStatus named source, or nil. +func sourceStatus(rep *touchstonev1.ScanReport, source string) *touchstonev1.SourceStatus { + for _, s := range rep.GetSources() { + if s.GetSource() == source { + return s + } + } + return nil +} + +// component returns the component named name, or nil. +func component(rep *touchstonev1.ScanReport, name string) *touchstonev1.Component { + for _, c := range rep.GetComponents() { + if c.GetName() == name { + return c + } + } + return nil +} + func TestScannerReportsJQueryWithOSVIDs(t *testing.T) { site := vendorSite(t) - rep, err := newTestScanner(recordedOSVSite(t).URL).Scan(context.Background(), + rep, err := newTestScanner(t, recordedOSVSite(t).URL).Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) if err != nil { t.Fatalf("Scan: %v", err) @@ -123,7 +183,7 @@ func TestScannerReportsJQueryWithOSVIDs(t *testing.T) { func TestScannerCheckedListMatchesRequests(t *testing.T) { site := vendorSite(t) - rep, err := newTestScanner(recordedOSVSite(t).URL).Scan(context.Background(), + rep, err := newTestScanner(t, recordedOSVSite(t).URL).Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) if err != nil { t.Fatalf("Scan: %v", err) @@ -152,7 +212,7 @@ func TestScannerCheckedListMatchesRequests(t *testing.T) { func TestScannerRecordsScriptRefusedByBudget(t *testing.T) { site := vendorSite(t) - rep, err := newTestScanner(recordedOSVSite(t).URL).Scan(context.Background(), + rep, err := newTestScanner(t, recordedOSVSite(t).URL).Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/", RequestBudget: 1}) if err != nil { t.Fatalf("Scan: %v", err) @@ -172,7 +232,7 @@ func TestScannerRecordsScriptRefusedByBudget(t *testing.T) { func TestScannerTargetUnreachable(t *testing.T) { site := httptest.NewServer(http.NotFoundHandler()) site.Close() - rep, err := newTestScanner(recordedOSVSite(t).URL).Scan(context.Background(), + rep, err := newTestScanner(t, recordedOSVSite(t).URL).Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) if err != nil { t.Fatalf("Scan: an unreachable target is a report, not an RPC error: %v", err) @@ -188,38 +248,148 @@ func TestScannerTargetUnreachable(t *testing.T) { } } -func TestScannerOSVDownIsFailedLookup(t *testing.T) { - osv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// downServer answers every request with 503, a vulnerability source that is +// unavailable. +func downServer(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "down", http.StatusServiceUnavailable) })) - t.Cleanup(osv.Close) + t.Cleanup(srv.Close) + return srv +} + +func TestScannerOSVDownIsPartialWhenNVDAnswers(t *testing.T) { site := vendorSite(t) - rep, err := newTestScanner(osv.URL).Scan(context.Background(), + rep, err := newTestScanner(t, downServer(t).URL).Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) if err != nil { t.Fatalf("Scan: %v", err) } + if rep.GetLookupStatus() != touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL { + t.Errorf("lookup_status = %v, want PARTIAL: NVD answered the nginx CPE", rep.GetLookupStatus()) + } + if jq := component(rep, "jquery"); len(jq.GetVulnerabilities()) != 0 { + t.Errorf("jquery has vulnerabilities despite the failed OSV lookup: %v", jq.GetVulnerabilities()) + } + if s := sourceStatus(rep, "OSV"); s == nil || s.GetOk() || s.GetError() == "" { + t.Errorf("OSV source status = %v, want ok=false with an error", s) + } + if s := sourceStatus(rep, "NVD"); s == nil || !s.GetOk() { + t.Errorf("NVD source status = %v, want ok=true", s) + } +} + +func TestScannerFailedWhenEveryQuestionFails(t *testing.T) { + site := vendorSite(t) + s := newTestScanner(t, downServer(t).URL) + s.CPE = &vuln.NVD{BaseURL: downServer(t).URL, Client: http.DefaultClient} + rep, err := s.Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) + if err != nil { + t.Fatalf("Scan: %v", err) + } if rep.GetLookupStatus() != touchstonev1.LookupStatus_LOOKUP_STATUS_FAILED { - t.Errorf("lookup_status = %v, want FAILED", rep.GetLookupStatus()) + t.Errorf("lookup_status = %v, want FAILED: OSV and NVD both failed", rep.GetLookupStatus()) } - for _, c := range rep.GetComponents() { - if len(c.GetVulnerabilities()) != 0 { - t.Errorf("component %s has vulnerabilities despite the failed lookup", c.GetName()) - } +} + +func TestScannerLooksUpCPEOnlyComponentsInNVD(t *testing.T) { + site := vendorSite(t) + rep, err := newTestScanner(t, recordedOSVSite(t).URL).Scan(context.Background(), + &touchstonev1.ScanRequest{Url: site.URL + "/"}) + if err != nil { + t.Fatalf("Scan: %v", err) } - var osvStatus *touchstonev1.SourceStatus - for _, s := range rep.GetSources() { - if s.GetSource() == "OSV" { - osvStatus = s - } + nginx := component(rep, "nginx") + if nginx == nil { + t.Fatalf("no nginx component in %v", rep.GetComponents()) + } + if nginx.GetPurl() != "" || nginx.GetCpe() != "cpe:2.3:a:f5:nginx:1.18.0:*:*:*:*:*:*:*" { + t.Fatalf("nginx = purl %q cpe %q, want a CPE-only component", nginx.GetPurl(), nginx.GetCpe()) + } + var ids []string + for _, v := range nginx.GetVulnerabilities() { + ids = append(ids, v.GetId()) + } + if len(ids) != 2 || ids[0] != "CVE-2021-23017" || ids[1] != "CVE-2019-20372" { + t.Errorf("nginx vulnerability ids = %v, want the two NVD CVEs", ids) + } + if s := sourceStatus(rep, "NVD"); s == nil || !s.GetOk() { + t.Errorf("NVD source status = %v, want ok=true", s) + } + if rep.GetLookupStatus() != touchstonev1.LookupStatus_LOOKUP_STATUS_COMPLETE { + t.Errorf("lookup_status = %v, want COMPLETE", rep.GetLookupStatus()) + } +} + +func TestScannerRanksKEVFirstThenEPSS(t *testing.T) { + site := vendorSite(t) + rep, err := newTestScanner(t, recordedOSVSite(t).URL).Scan(context.Background(), + &touchstonev1.ScanRequest{Url: site.URL + "/"}) + if err != nil { + t.Fatalf("Scan: %v", err) + } + // CVE-2020-11022 is KEV-listed in the fixture, so it ranks first even + // though EPSS alone would not put it there. CVE-2019-11358 is not + // KEV-listed and ranks second. + jq := component(rep, "jquery").GetVulnerabilities() + if len(jq) != 2 || jq[0].GetId() != "CVE-2020-11022" || !jq[0].GetKev() || jq[0].GetEpss() != 0.94321 { + t.Errorf("jquery vulnerabilities = %v, want CVE-2020-11022 first with KEV and EPSS 0.94321", jq) + } + if jq[1].GetId() != "CVE-2019-11358" || jq[1].GetKev() || jq[1].GetEpss() != 0.01234 { + t.Errorf("second jquery vulnerability = %v, want CVE-2019-11358 with EPSS 0.01234 and no KEV", jq[1]) + } + // The nginx KEV entry ranks first, and the CVE EPSS does not score ranks + // last, with its CVSS carried through. + ng := component(rep, "nginx").GetVulnerabilities() + if len(ng) != 2 || ng[0].GetId() != "CVE-2021-23017" || !ng[0].GetKev() || ng[0].GetCvssBase() != 7.7 { + t.Errorf("nginx vulnerabilities = %v, want the KEV CVE first with CVSS 7.7", ng) + } + if ng[1].Epss != nil || ng[1].GetCvssBase() != 5.0 { + t.Errorf("second nginx vulnerability = %v, want no EPSS and CVSS 5.0", ng[1]) + } +} + +func TestScannerKEVFailureIsPartial(t *testing.T) { + site := vendorSite(t) + s := newTestScanner(t, recordedOSVSite(t).URL) + s.KEV = &vuln.KEV{URL: downServer(t).URL + "/kev.json", Client: http.DefaultClient} + rep, err := s.Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) + if err != nil { + t.Fatalf("Scan: %v", err) + } + if rep.GetLookupStatus() != touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL { + t.Errorf("lookup_status = %v, want PARTIAL: KEV could not be read", rep.GetLookupStatus()) + } + if st := sourceStatus(rep, "CISA KEV"); st == nil || st.GetOk() || st.GetError() == "" { + t.Errorf("CISA KEV source status = %v, want ok=false with an error", st) + } +} + +func TestScannerCapsCPELookupsAndReportsTheCap(t *testing.T) { + s := newTestScanner(t, recordedOSVSite(t).URL) + s.CPEMax = 1 + comps := []pipeline.Component{ + {Name: "nginx", Version: "1.18.0", CPE: "cpe:2.3:a:f5:nginx:1.18.0:*:*:*:*:*:*:*"}, + {Name: "nginx", Version: "1.17.0", CPE: "cpe:2.3:a:f5:nginx:1.17.0:*:*:*:*:*:*:*"}, + } + rep := &touchstonev1.ScanReport{} + status, out := s.lookup(context.Background(), rep, comps) + + if status != touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL { + t.Errorf("lookup_status = %v, want PARTIAL: the second CPE was not looked up", status) + } + if len(out) != 2 { + t.Fatalf("components = %d, want both reported", len(out)) } - if osvStatus == nil || osvStatus.GetOk() || osvStatus.GetError() == "" { - t.Errorf("OSV source status = %v, want ok=false with an error", osvStatus) + st := sourceStatus(rep, "NVD") + if st == nil || st.GetOk() || !strings.Contains(st.GetError(), "capped at 1 CPE lookups per scan, 1 components not looked up") { + t.Errorf("NVD source status = %v, want the cap reported", st) } } func TestScannerRejectsNonHTTPTarget(t *testing.T) { - _, err := newTestScanner("http://unused").Scan(context.Background(), + _, err := newTestScanner(t, "http://unused").Scan(context.Background(), &touchstonev1.ScanRequest{Url: "ftp://vendor.example/"}) if err == nil { t.Fatal("Scan: want an InvalidArgument error for an ftp URL") From d9da4e80552fbd564c81b5ade6f0d08fcc3da4ae Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:38 +0000 Subject: [PATCH 04/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/epss.go | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 internal/vuln/epss.go diff --git a/internal/vuln/epss.go b/internal/vuln/epss.go new file mode 100644 index 0000000..2082871 --- /dev/null +++ b/internal/vuln/epss.go @@ -0,0 +1,68 @@ +package vuln + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +// DefaultEPSSURL is the FIRST EPSS API. +const DefaultEPSSURL = "https://api.first.org/data/v1/epss" + +// epssBatch is how many CVE ids one EPSS request asks for. +const epssBatch = 100 + +// EPSS is the Enricher that attaches the FIRST EPSS score (the probability of +// exploitation within 30 days) to each CVE it knows. A CVE EPSS does not +// score gets no entry, so its finding ranks after every scored one. +type EPSS struct { + BaseURL string + Client *http.Client +} + +// NewEPSS returns an EPSS enricher for the public API. +func NewEPSS() *EPSS { + return &EPSS{BaseURL: DefaultEPSSURL, Client: &http.Client{Timeout: defaultFeedTimeout}} +} + +var _ Enricher = (*EPSS)(nil) + +func (e *EPSS) Name() string { return "FIRST EPSS" } + +// epssResponse is the wire shape of GET /data/v1/epss. Scores come as strings. +type epssResponse struct { + Status string `json:"status"` + Data []struct { + CVE string `json:"cve"` + EPSS string `json:"epss"` + } `json:"data"` +} + +// Enrich asks EPSS for the CVE ids, one request per epssBatch ids. A failed +// request is an error for the whole call. +func (e *EPSS) Enrich(ctx context.Context, findingIDs []string) (map[string]Enrichment, error) { + cves := cveIDs(findingIDs) + out := map[string]Enrichment{} + for start := 0; start < len(cves); start += epssBatch { + batch := cves[start:min(start+epssBatch, len(cves))] + q := url.Values{"cve": {strings.Join(batch, ",")}} + var resp epssResponse + if err := getJSON(ctx, e.Client, e.BaseURL+"?"+q.Encode(), nil, &resp); err != nil { + return nil, fmt.Errorf("epss: %w", err) + } + if resp.Status != "OK" { + return nil, fmt.Errorf("epss: status %q", resp.Status) + } + for _, d := range resp.Data { + score, err := strconv.ParseFloat(d.EPSS, 64) + if err != nil { + return nil, fmt.Errorf("epss: %s: score %q: %w", d.CVE, d.EPSS, err) + } + out[d.CVE] = Enrichment{EPSS: &score} + } + } + return out, nil +} From faa0b899c83e5a796b2ce8c0f8d20a9e50767762 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:40 +0000 Subject: [PATCH 05/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/epss_test.go | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 internal/vuln/epss_test.go diff --git a/internal/vuln/epss_test.go b/internal/vuln/epss_test.go new file mode 100644 index 0000000..20db9ba --- /dev/null +++ b/internal/vuln/epss_test.go @@ -0,0 +1,73 @@ +package vuln + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func epssFor(srv *httptest.Server) *EPSS { + return &EPSS{BaseURL: srv.URL + "/data/v1/epss", Client: srv.Client()} +} + +func TestEPSSScoresKnownCVEs(t *testing.T) { + srv, _ := fixtureServer(t, "epss/epss.json") + got, err := epssFor(srv).Enrich(context.Background(), []string{"CVE-2020-11022", "CVE-2019-11358", "CVE-2099-0002", "GHSA-gxr4-xjj5-5px2"}) + if err != nil { + t.Fatalf("Enrich: %v", err) + } + if e := got["CVE-2020-11022"]; e.EPSS == nil || *e.EPSS != 0.94321 { + t.Errorf("CVE-2020-11022 EPSS = %v, want 0.94321", e.EPSS) + } + if e := got["CVE-2019-11358"]; e.EPSS == nil || *e.EPSS != 0.01234 { + t.Errorf("CVE-2019-11358 EPSS = %v, want 0.01234", e.EPSS) + } + if _, ok := got["CVE-2099-0002"]; ok { + t.Error("a CVE EPSS does not score got an entry; it must be left absent") + } + if _, ok := got["GHSA-gxr4-xjj5-5px2"]; ok { + t.Error("a non-CVE id got an entry") + } +} + +func TestEPSSSendsOnlyCVEIDs(t *testing.T) { + srv, log := fixtureServer(t, "epss/epss.json") + if _, err := epssFor(srv).Enrich(context.Background(), []string{"GHSA-gxr4-xjj5-5px2"}); err != nil { + t.Fatalf("Enrich: %v", err) + } + if log.count() != 0 { + t.Errorf("made %d requests for non-CVE ids", log.count()) + } +} + +func TestEPSSBatchesLargeLists(t *testing.T) { + srv, log := fixtureServer(t, "epss/epss.json") + var cves []string + for i := 0; i < epssBatch+1; i++ { + cves = append(cves, fmt.Sprintf("CVE-2099-%04d", i)) + } + if _, err := epssFor(srv).Enrich(context.Background(), cves); err != nil { + t.Fatalf("Enrich: %v", err) + } + if log.count() != 2 { + t.Fatalf("requests = %d for %d ids, want 2 batches", log.count(), len(cves)) + } + first := strings.Split(log.reqs[0].URL.Query().Get("cve"), ",") + if len(first) != epssBatch { + t.Errorf("first batch has %d ids, want %d", len(first), epssBatch) + } +} + +func TestEPSSNonOKStatusIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status": "ERROR", "data": []}`)) + })) + t.Cleanup(srv.Close) + got, err := epssFor(srv).Enrich(context.Background(), []string{"CVE-2020-11022"}) + if err == nil || got != nil { + t.Fatalf("Enrich = %v, %v; want an error and no enrichments", got, err) + } +} From 6bf62296aa6adb2672e32a0807bfb7a3270afcd5 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:41 +0000 Subject: [PATCH 06/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/feed.go | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 internal/vuln/feed.go diff --git a/internal/vuln/feed.go b/internal/vuln/feed.go new file mode 100644 index 0000000..3bbebe0 --- /dev/null +++ b/internal/vuln/feed.go @@ -0,0 +1,66 @@ +package vuln + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// maxFeedBytes caps one NVD, KEV or EPSS response. The KEV catalog, the +// largest of them, is a few megabytes. +const maxFeedBytes = 16 << 20 + +// defaultFeedTimeout bounds one request to a vulnerability feed. +const defaultFeedTimeout = 10 * time.Second + +// getJSON GETs rawURL with the given extra headers and decodes the reply into +// out. Any non-200 status is an error, so a failed feed is never read as +// "nothing listed". +func getJSON(ctx context.Context, client *http.Client, rawURL string, header http.Header, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + for k, v := range header { + req.Header[k] = v + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + b, err := io.ReadAll(io.LimitReader(resp.Body, maxFeedBytes+1)) + if err != nil { + return err + } + if len(b) > maxFeedBytes { + return fmt.Errorf("response exceeds %d bytes", maxFeedBytes) + } + if err := json.Unmarshal(b, out); err != nil { + return fmt.Errorf("decode: %w", err) + } + return nil +} + +// cveIDs returns the distinct CVE ids among ids, in first-seen order. KEV and +// EPSS only score CVE ids, so other ids (GHSA, PYSEC, ...) are dropped before +// any request is made. +func cveIDs(ids []string) []string { + seen := map[string]bool{} + var out []string + for _, id := range ids { + if strings.HasPrefix(id, "CVE-") && !seen[id] { + seen[id] = true + out = append(out, id) + } + } + return out +} From 38b72b535b9cb0e086f92f29bcce49befee46cb9 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:43 +0000 Subject: [PATCH 07/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/feed_test.go | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 internal/vuln/feed_test.go diff --git a/internal/vuln/feed_test.go b/internal/vuln/feed_test.go new file mode 100644 index 0000000..f901daa --- /dev/null +++ b/internal/vuln/feed_test.go @@ -0,0 +1,48 @@ +package vuln + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" +) + +// fixtureServer serves one file from testdata for every request and records +// each request's method, path and query. The fixtures are in the wire shape +// of the feeds, but their values are illustrative, not recorded responses. +func fixtureServer(t *testing.T, file string) (*httptest.Server, *requestLog) { + t.Helper() + log := &requestLog{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.add(r) + b, err := os.ReadFile(filepath.Join("testdata", file)) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + })) + t.Cleanup(srv.Close) + return srv, log +} + +// requestLog counts requests a test server received. +type requestLog struct { + mu sync.Mutex + reqs []*http.Request +} + +func (l *requestLog) add(r *http.Request) { + l.mu.Lock() + defer l.mu.Unlock() + l.reqs = append(l.reqs, r) +} + +func (l *requestLog) count() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.reqs) +} From b7024998013e8312912be145204e2a65616bee33 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:44 +0000 Subject: [PATCH 08/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/kev.go | 103 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 internal/vuln/kev.go diff --git a/internal/vuln/kev.go b/internal/vuln/kev.go new file mode 100644 index 0000000..a2c2d06 --- /dev/null +++ b/internal/vuln/kev.go @@ -0,0 +1,103 @@ +package vuln + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + "time" +) + +// DefaultKEVURL is CISA's Known Exploited Vulnerabilities catalog. +const DefaultKEVURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" + +// KEVTTL is how long the catalog is reused before it is fetched again. +const KEVTTL = 6 * time.Hour + +// KEV is the Enricher that flags CVEs listed in CISA's KEV catalog. The +// catalog is held in memory for KEVTTL, so a process fetches it at most once +// per TTL however many scans it runs. +type KEV struct { + URL string + Client *http.Client + TTL time.Duration // KEVTTL if zero + Now func() time.Time + + mu sync.Mutex + listed map[string]bool + fetched time.Time +} + +// NewKEV returns a KEV enricher for the public catalog. +func NewKEV() *KEV { + return &KEV{URL: DefaultKEVURL, Client: &http.Client{Timeout: defaultFeedTimeout}} +} + +var _ Enricher = (*KEV)(nil) + +func (k *KEV) Name() string { return "CISA KEV" } + +// kevFeed is the wire shape of the catalog. Only the CVE ids are read. +type kevFeed struct { + Vulnerabilities []struct { + CVEID string `json:"cveID"` + } `json:"vulnerabilities"` +} + +// Enrich sets KEV on the CVE ids the catalog lists. It fetches the catalog +// only when there is a CVE id to check, and a failed fetch is an error, so +// an unreadable catalog is never read as "not listed". +func (k *KEV) Enrich(ctx context.Context, findingIDs []string) (map[string]Enrichment, error) { + cves := cveIDs(findingIDs) + out := map[string]Enrichment{} + if len(cves) == 0 { + return out, nil + } + listed, err := k.catalog(ctx) + if err != nil { + return nil, err + } + for _, id := range cves { + if listed[id] { + out[id] = Enrichment{KEV: true} + } + } + return out, nil +} + +// catalog returns the set of listed CVE ids, from memory while it is fresh. +func (k *KEV) catalog(ctx context.Context) (map[string]bool, error) { + k.mu.Lock() + defer k.mu.Unlock() + + now := time.Now + if k.Now != nil { + now = k.Now + } + ttl := KEVTTL + if k.TTL != 0 { + ttl = k.TTL + } + at := now() + if k.listed != nil && at.Sub(k.fetched) < ttl { + return k.listed, nil + } + + var feed kevFeed + if err := getJSON(ctx, k.Client, k.URL, nil, &feed); err != nil { + return nil, fmt.Errorf("kev: %w", err) + } + if len(feed.Vulnerabilities) == 0 { + return nil, errors.New("kev: catalog lists no vulnerabilities") + } + listed := make(map[string]bool, len(feed.Vulnerabilities)) + for _, v := range feed.Vulnerabilities { + if v.CVEID == "" { + return nil, errors.New("kev: catalog entry without cveID") + } + listed[v.CVEID] = true + } + k.listed, k.fetched = listed, at + return listed, nil +} From c19bf9d02925882eaa097456dc8ac934a5eebb97 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:46 +0000 Subject: [PATCH 09/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/kev_test.go | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 internal/vuln/kev_test.go diff --git a/internal/vuln/kev_test.go b/internal/vuln/kev_test.go new file mode 100644 index 0000000..619d860 --- /dev/null +++ b/internal/vuln/kev_test.go @@ -0,0 +1,88 @@ +package vuln + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func kevFor(srv *httptest.Server, now func() time.Time) *KEV { + return &KEV{URL: srv.URL + "/kev.json", Client: srv.Client(), Now: now} +} + +func TestKEVFlagsListedCVEsOnly(t *testing.T) { + srv, _ := fixtureServer(t, "kev/known_exploited_vulnerabilities.json") + got, err := kevFor(srv, nil).Enrich(context.Background(), []string{"CVE-2020-11022", "CVE-2019-11358", "GHSA-gxr4-xjj5-5px2"}) + if err != nil { + t.Fatalf("Enrich: %v", err) + } + if len(got) != 1 || !got["CVE-2020-11022"].KEV { + t.Errorf("Enrich = %v, want only CVE-2020-11022 flagged KEV", got) + } +} + +func TestKEVWithNoCVEIDsMakesNoRequest(t *testing.T) { + srv, log := fixtureServer(t, "kev/known_exploited_vulnerabilities.json") + got, err := kevFor(srv, nil).Enrich(context.Background(), []string{"GHSA-gxr4-xjj5-5px2"}) + if err != nil || len(got) != 0 { + t.Fatalf("Enrich = %v, %v; want nothing and no error", got, err) + } + if log.count() != 0 { + t.Errorf("fetched the catalog for non-CVE ids: %d requests", log.count()) + } +} + +func TestKEVCatalogIsCachedForTTL(t *testing.T) { + srv, log := fixtureServer(t, "kev/known_exploited_vulnerabilities.json") + clock := time.Date(2026, 10, 24, 0, 0, 0, 0, time.UTC) + k := kevFor(srv, func() time.Time { return clock }) + ids := []string{"CVE-2020-11022"} + + for i := 0; i < 2; i++ { + if _, err := k.Enrich(context.Background(), ids); err != nil { + t.Fatalf("Enrich: %v", err) + } + } + if log.count() != 1 { + t.Fatalf("fetches within the TTL = %d, want 1", log.count()) + } + + clock = clock.Add(KEVTTL - time.Minute) + if _, err := k.Enrich(context.Background(), ids); err != nil { + t.Fatalf("Enrich: %v", err) + } + if log.count() != 1 { + t.Fatalf("fetches just inside the TTL = %d, want 1", log.count()) + } + + clock = clock.Add(2 * time.Minute) + if _, err := k.Enrich(context.Background(), ids); err != nil { + t.Fatalf("Enrich: %v", err) + } + if log.count() != 2 { + t.Fatalf("fetches after the TTL = %d, want 2", log.count()) + } +} + +func TestKEVFeedErrorIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + t.Cleanup(srv.Close) + got, err := kevFor(srv, nil).Enrich(context.Background(), []string{"CVE-2020-11022"}) + if err == nil || got != nil { + t.Fatalf("Enrich = %v, %v; want an error and no enrichments, never 'not listed'", got, err) + } +} + +func TestKEVEmptyCatalogIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"catalogVersion": "x", "vulnerabilities": []}`)) + })) + t.Cleanup(srv.Close) + if _, err := kevFor(srv, nil).Enrich(context.Background(), []string{"CVE-2020-11022"}); err == nil { + t.Fatal("Enrich: want an error for a catalog with no entries") + } +} From 0f993bf246a2e0f9639e1c657df091209bb0a8e6 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:47 +0000 Subject: [PATCH 10/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/live_test.go | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 internal/vuln/live_test.go diff --git a/internal/vuln/live_test.go b/internal/vuln/live_test.go new file mode 100644 index 0000000..1b21d02 --- /dev/null +++ b/internal/vuln/live_test.go @@ -0,0 +1,42 @@ +//go:build live + +package vuln + +import ( + "context" + "os" + "testing" + "time" +) + +// TestLiveNVDKEVEPSS calls the real NVD, CISA KEV and FIRST EPSS APIs with +// one component. It is opt-in (go test -tags live ./internal/vuln/) and runs +// nightly, never on pull requests. NVD_API_KEY is optional. The assertions +// are shape checks only, because the live data changes. +func TestLiveNVDKEVEPSS(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + findings, err := NewNVD(os.Getenv("NVD_API_KEY")).Lookup(ctx, []Query{{CPE: "cpe:2.3:a:jquery:jquery:1.12.4:*:*:*:*:*:*:*"}}) + if err != nil { + t.Fatalf("NVD lookup: %v", err) + } + if len(findings) == 0 { + t.Fatal("NVD returned no CVEs for jquery 1.12.4") + } + + var ids []string + for _, f := range findings { + ids = append(ids, f.ID) + } + if _, err := NewKEV().Enrich(ctx, ids); err != nil { + t.Fatalf("KEV enrich: %v", err) + } + epss, err := NewEPSS().Enrich(ctx, ids) + if err != nil { + t.Fatalf("EPSS enrich: %v", err) + } + if len(epss) == 0 { + t.Errorf("EPSS scored none of %v", ids) + } +} From ef9db82258cc670e632d3024fd31793dff22548e Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:49 +0000 Subject: [PATCH 11/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/nvd.go | 160 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 internal/vuln/nvd.go diff --git a/internal/vuln/nvd.go b/internal/vuln/nvd.go new file mode 100644 index 0000000..fe36692 --- /dev/null +++ b/internal/vuln/nvd.go @@ -0,0 +1,160 @@ +package vuln + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// DefaultNVDURL is the NIST NVD 2.0 API. +const DefaultNVDURL = "https://services.nvd.nist.gov" + +// nvdPageSize is the largest page NVD serves. A CPE with more matches than +// one page is reported as an error rather than read as complete. +const nvdPageSize = 2000 + +// NVDMaxLookups is how many CPE lookups one scan may make. NVD allows about +// 5 requests per 30 s without an API key and 50 per 30 s with one. Lookups +// past the cap are not made, and the scan reports them as incomplete. +func NVDMaxLookups(apiKey string) int { + if apiKey == "" { + return 5 + } + return 50 +} + +// NVD is the vuln.Source that asks NIST NVD 2.0 for vulnerabilities by CPE. +// It is used for components that have no purl, so OSV cannot answer for +// them. Like OSV it calls with its own client, outside the vendor budget. +type NVD struct { + BaseURL string + Client *http.Client + // APIKey is sent in the apiKey header when set, which raises NVD's rate + // limit. It is never put in a URL, a report or an error message. + APIKey string +} + +// NewNVD returns an NVD source for the public API. apiKey may be empty. +func NewNVD(apiKey string) *NVD { + return &NVD{BaseURL: DefaultNVDURL, Client: &http.Client{Timeout: defaultFeedTimeout}, APIKey: apiKey} +} + +var _ Source = (*NVD)(nil) + +func (n *NVD) Name() string { return "NVD" } + +// nvdResponse is the wire shape of GET /rest/json/cves/2.0. Only the fields +// Touchstone reads are declared. +type nvdResponse struct { + TotalResults int `json:"totalResults"` + Vulnerabilities []struct { + CVE nvdCVE `json:"cve"` + } `json:"vulnerabilities"` +} + +type nvdCVE struct { + ID string `json:"id"` + VulnStatus string `json:"vulnStatus"` + Descriptions []struct { + Lang string `json:"lang"` + Value string `json:"value"` + } `json:"descriptions"` + Metrics struct { + V31 []nvdMetric `json:"cvssMetricV31"` + V30 []nvdMetric `json:"cvssMetricV30"` + V2 []nvdMetric `json:"cvssMetricV2"` + } `json:"metrics"` +} + +type nvdMetric struct { + Type string `json:"type"` + CVSSData struct { + BaseScore float64 `json:"baseScore"` + } `json:"cvssData"` +} + +// Lookup returns the CVEs NVD matches to each query's CPE. Queries without a +// CPE are ignored, and each CPE costs one request. A CVE NVD has rejected is +// dropped. +func (n *NVD) Lookup(ctx context.Context, queries []Query) ([]Finding, error) { + var out []Finding + for _, q := range queries { + if q.CPE == "" { + continue + } + findings, err := n.lookupCPE(ctx, q.CPE) + if err != nil { + return nil, err + } + out = append(out, findings...) + } + return out, nil +} + +func (n *NVD) lookupCPE(ctx context.Context, cpe string) ([]Finding, error) { + q := url.Values{"cpeName": {cpe}, "resultsPerPage": {strconv.Itoa(nvdPageSize)}} + var page nvdResponse + if err := getJSON(ctx, n.Client, n.BaseURL+"/rest/json/cves/2.0?"+q.Encode(), n.header(), &page); err != nil { + return nil, fmt.Errorf("nvd: cpe %s: %w", cpe, err) + } + if page.TotalResults > len(page.Vulnerabilities) { + return nil, fmt.Errorf("nvd: cpe %s: %d results, only %d returned", cpe, page.TotalResults, len(page.Vulnerabilities)) + } + var out []Finding + for _, v := range page.Vulnerabilities { + if v.CVE.ID == "" { + return nil, fmt.Errorf("nvd: cpe %s: result without a CVE id", cpe) + } + if v.CVE.VulnStatus == "Rejected" { + continue + } + out = append(out, Finding{ + ID: v.CVE.ID, + Summary: englishDescription(v.CVE), + CVSSBase: cvssBase(v.CVE.Metrics.V31, v.CVE.Metrics.V30, v.CVE.Metrics.V2), + }) + } + return out, nil +} + +// header carries the API key, if there is one. +func (n *NVD) header() http.Header { + h := http.Header{} + if n.APIKey != "" { + h.Set("apiKey", n.APIKey) + } + return h +} + +// englishDescription returns the English description, or "" if NVD has none. +func englishDescription(c nvdCVE) string { + for _, d := range c.Descriptions { + if d.Lang == "en" { + return d.Value + } + } + return "" +} + +// cvssBase picks one base score: NVD's Primary metric from the newest CVSS +// version that has one, else the first metric of that version. nil if NVD +// has no score. +func cvssBase(lists ...[]nvdMetric) *float64 { + for _, list := range lists { + if len(list) == 0 { + continue + } + pick := list[0] + for _, m := range list { + if m.Type == "Primary" { + pick = m + break + } + } + score := pick.CVSSData.BaseScore + return &score + } + return nil +} From f6f16f64d88de2830918d665ce560bf856751fdc Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:50 +0000 Subject: [PATCH 12/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/nvd_test.go | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 internal/vuln/nvd_test.go diff --git a/internal/vuln/nvd_test.go b/internal/vuln/nvd_test.go new file mode 100644 index 0000000..a813013 --- /dev/null +++ b/internal/vuln/nvd_test.go @@ -0,0 +1,111 @@ +package vuln + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const nginxCPE = "cpe:2.3:a:f5:nginx:1.18.0:*:*:*:*:*:*:*" + +func nvdFor(srv *httptest.Server, apiKey string) *NVD { + return &NVD{BaseURL: srv.URL, Client: srv.Client(), APIKey: apiKey} +} + +func TestNVDLookupByCPE(t *testing.T) { + srv, log := fixtureServer(t, "nvd/cpe-nginx-1.18.0.json") + got, err := nvdFor(srv, "").Lookup(context.Background(), []Query{{CPE: nginxCPE}}) + if err != nil { + t.Fatalf("Lookup: %v", err) + } + if len(got) != 2 { + t.Fatalf("Lookup: got %d findings, want 2 (the rejected CVE is dropped): %+v", len(got), got) + } + if got[0].ID != "CVE-2021-23017" || got[0].CVSSBase == nil || *got[0].CVSSBase != 7.7 { + t.Errorf("first finding = %+v, want CVE-2021-23017 with CVSS 7.7 from the v3.1 primary metric", got[0]) + } + if got[0].Summary != "fixture: off-by-one in the nginx resolver" { + t.Errorf("summary = %q, want the English description", got[0].Summary) + } + if got[1].ID != "CVE-2019-20372" || got[1].CVSSBase == nil || *got[1].CVSSBase != 5.0 { + t.Errorf("second finding = %+v, want CVE-2019-20372 with CVSS 5.0 from the v2 metric", got[1]) + } + if log.count() != 1 { + t.Fatalf("requests = %d, want one per CPE", log.count()) + } + r := log.reqs[0] + if r.URL.Path != "/rest/json/cves/2.0" || r.URL.Query().Get("cpeName") != nginxCPE { + t.Errorf("request = %s, want the CVE 2.0 path with cpeName=%s", r.URL, nginxCPE) + } +} + +func TestNVDIgnoresQueriesWithoutCPE(t *testing.T) { + srv, log := fixtureServer(t, "nvd/cpe-nginx-1.18.0.json") + got, err := nvdFor(srv, "").Lookup(context.Background(), []Query{{Purl: "pkg:npm/jquery@1.12.4"}}) + if err != nil || len(got) != 0 { + t.Fatalf("Lookup = %v, %v; want no findings and no error", got, err) + } + if log.count() != 0 { + t.Errorf("made %d requests for a purl-only query", log.count()) + } +} + +func TestNVDAPIKeyIsSentAsHeaderNotInURL(t *testing.T) { + srv, log := fixtureServer(t, "nvd/cpe-nginx-1.18.0.json") + const key = "test-key-not-real" + if _, err := nvdFor(srv, key).Lookup(context.Background(), []Query{{CPE: nginxCPE}}); err != nil { + t.Fatalf("Lookup: %v", err) + } + r := log.reqs[0] + if r.Header.Get("apiKey") != key { + t.Errorf("apiKey header = %q, want the configured key", r.Header.Get("apiKey")) + } + if r.URL.RawQuery == "" || strings.Contains(r.URL.RawQuery, key) { + t.Errorf("query = %q, want the CPE and no key", r.URL.RawQuery) + } +} + +func TestNVDNoKeySendsNoHeader(t *testing.T) { + srv, log := fixtureServer(t, "nvd/cpe-nginx-1.18.0.json") + if _, err := nvdFor(srv, "").Lookup(context.Background(), []Query{{CPE: nginxCPE}}); err != nil { + t.Fatalf("Lookup: %v", err) + } + if v := log.reqs[0].Header.Values("apiKey"); len(v) != 0 { + t.Errorf("apiKey header sent without a key: %v", v) + } +} + +func TestNVDNonOKStatusIsError(t *testing.T) { + // NVD answers 403 when the key is wrong or the rate limit is exceeded. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Forbidden", http.StatusForbidden) + })) + t.Cleanup(srv.Close) + got, err := nvdFor(srv, "").Lookup(context.Background(), []Query{{CPE: nginxCPE}}) + if err == nil || got != nil { + t.Fatalf("Lookup = %v, %v; want an error and no findings", got, err) + } +} + +func TestNVDTruncatedPageIsError(t *testing.T) { + // totalResults above the number returned means a page was cut short, + // which must not be read as the complete list. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"totalResults": 5, "vulnerabilities": [{"cve": {"id": "CVE-2021-23017", "vulnStatus": "Analyzed"}}]}`)) + })) + t.Cleanup(srv.Close) + if _, err := nvdFor(srv, "").Lookup(context.Background(), []Query{{CPE: nginxCPE}}); err == nil { + t.Fatal("Lookup: want an error when NVD returns fewer results than totalResults") + } +} + +func TestNVDMaxLookups(t *testing.T) { + if got := NVDMaxLookups(""); got != 5 { + t.Errorf("without a key = %d, want 5", got) + } + if got := NVDMaxLookups("k"); got != 50 { + t.Errorf("with a key = %d, want 50", got) + } +} From e1f2818144a1e6ad29c4bcf263938500c6401364 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:52 +0000 Subject: [PATCH 13/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/osv.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/vuln/osv.go b/internal/vuln/osv.go index 994df00..9d08779 100644 --- a/internal/vuln/osv.go +++ b/internal/vuln/osv.go @@ -65,7 +65,7 @@ type osvVuln struct { } // Lookup returns the advisories for every query that carries a purl. CPE -// queries are ignored (NVD handles those, slice S3). Advisories are fetched +// queries are ignored (NVD handles those). Advisories are fetched // with one querybatch call, then one GET per distinct id for details. func (o *OSV) Lookup(ctx context.Context, queries []Query) ([]Finding, error) { var req osvBatchRequest From 1b073bfd6752e0f3ac5c2910ac1a010efd309454 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:53 +0000 Subject: [PATCH 14/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/rank_test.go | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 internal/vuln/rank_test.go diff --git a/internal/vuln/rank_test.go b/internal/vuln/rank_test.go new file mode 100644 index 0000000..34f4caf --- /dev/null +++ b/internal/vuln/rank_test.go @@ -0,0 +1,95 @@ +package vuln + +import ( + "reflect" + "testing" +) + +func ptr(f float64) *float64 { return &f } + +func ids(fs []Finding) []string { + var out []string + for _, f := range fs { + out = append(out, f.ID) + } + return out +} + +func TestRank(t *testing.T) { + tests := []struct { + name string + in []Finding + want []string + }{ + { + name: "KEV first even with a lower EPSS", + in: []Finding{ + {ID: "CVE-A", EPSS: ptr(0.9)}, + {ID: "CVE-B", KEV: true, EPSS: ptr(0.1)}, + }, + want: []string{"CVE-B", "CVE-A"}, + }, + { + name: "EPSS descending within a KEV group", + in: []Finding{ + {ID: "CVE-A", EPSS: ptr(0.2)}, + {ID: "CVE-B", EPSS: ptr(0.7)}, + {ID: "CVE-C", EPSS: ptr(0.4)}, + }, + want: []string{"CVE-B", "CVE-C", "CVE-A"}, + }, + { + name: "missing EPSS sorts last, even above a low score and a high CVSS", + in: []Finding{ + {ID: "CVE-A", CVSSBase: ptr(10)}, + {ID: "CVE-B", EPSS: ptr(0.01)}, + {ID: "CVE-C", EPSS: ptr(0.0)}, + }, + want: []string{"CVE-B", "CVE-C", "CVE-A"}, + }, + { + name: "CVSS descending breaks EPSS ties, missing CVSS last", + in: []Finding{ + {ID: "CVE-A", EPSS: ptr(0.5)}, + {ID: "CVE-B", EPSS: ptr(0.5), CVSSBase: ptr(4.3)}, + {ID: "CVE-C", EPSS: ptr(0.5), CVSSBase: ptr(9.8)}, + }, + want: []string{"CVE-C", "CVE-B", "CVE-A"}, + }, + { + name: "missing EPSS and CVSS fall back to id", + in: []Finding{ + {ID: "GHSA-b"}, + {ID: "CVE-z"}, + {ID: "GHSA-a"}, + }, + want: []string{"CVE-z", "GHSA-a", "GHSA-b"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + Rank(tt.in) + if got := ids(tt.in); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Rank order = %v, want %v", got, tt.want) + } + }) + } +} + +func TestApplySetsKEVAndEPSSByID(t *testing.T) { + fs := []Finding{{ID: "CVE-1"}, {ID: "CVE-2"}, {ID: "GHSA-x"}} + kev := map[string]Enrichment{"CVE-1": {KEV: true}} + epss := map[string]Enrichment{"CVE-1": {EPSS: ptr(0.3)}, "CVE-2": {EPSS: ptr(0.6)}} + + Apply(fs, kev, epss) + + if !fs[0].KEV || fs[0].EPSS == nil || *fs[0].EPSS != 0.3 { + t.Errorf("CVE-1 = %+v, want KEV and EPSS 0.3", fs[0]) + } + if fs[1].KEV || fs[1].EPSS == nil || *fs[1].EPSS != 0.6 { + t.Errorf("CVE-2 = %+v, want no KEV and EPSS 0.6", fs[1]) + } + if fs[2].KEV || fs[2].EPSS != nil { + t.Errorf("GHSA-x = %+v, want no enrichment", fs[2]) + } +} From a32a44bece11b99c4822a103e972a09aa77089e2 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:54 +0000 Subject: [PATCH 15/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/testdata/epss/epss.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 internal/vuln/testdata/epss/epss.json diff --git a/internal/vuln/testdata/epss/epss.json b/internal/vuln/testdata/epss/epss.json new file mode 100644 index 0000000..b6d387a --- /dev/null +++ b/internal/vuln/testdata/epss/epss.json @@ -0,0 +1,14 @@ +{ + "status": "OK", + "status-code": 200, + "version": "1.0", + "access": "public", + "total": 3, + "offset": 0, + "limit": 100, + "data": [ + { "cve": "CVE-2019-11358", "epss": "0.012340000", "percentile": "0.770000000", "date": "2026-10-23" }, + { "cve": "CVE-2020-11022", "epss": "0.943210000", "percentile": "0.996000000", "date": "2026-10-23" }, + { "cve": "CVE-2021-23017", "epss": "0.512000000", "percentile": "0.981000000", "date": "2026-10-23" } + ] +} From 383343909cb7e4d82fc4d51c8d86228ba3b84472 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:56 +0000 Subject: [PATCH 16/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- .../kev/known_exploited_vulnerabilities.json | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 internal/vuln/testdata/kev/known_exploited_vulnerabilities.json diff --git a/internal/vuln/testdata/kev/known_exploited_vulnerabilities.json b/internal/vuln/testdata/kev/known_exploited_vulnerabilities.json new file mode 100644 index 0000000..1e21236 --- /dev/null +++ b/internal/vuln/testdata/kev/known_exploited_vulnerabilities.json @@ -0,0 +1,28 @@ +{ + "title": "CISA Catalog of Known Exploited Vulnerabilities (test fixture)", + "catalogVersion": "fixture", + "dateReleased": "2026-10-23T00:00:00.000Z", + "count": 2, + "vulnerabilities": [ + { + "cveID": "CVE-2020-11022", + "vendorProject": "jQuery", + "product": "jQuery", + "vulnerabilityName": "jQuery Cross-Site Scripting Vulnerability", + "dateAdded": "2026-10-01", + "requiredAction": "Apply updates per vendor instructions.", + "dueDate": "2026-10-22", + "knownRansomwareCampaignUse": "Unknown" + }, + { + "cveID": "CVE-2021-23017", + "vendorProject": "F5", + "product": "NGINX Open Source", + "vulnerabilityName": "F5 NGINX Resolver Off-by-one Vulnerability", + "dateAdded": "2026-10-02", + "requiredAction": "Apply updates per vendor instructions.", + "dueDate": "2026-10-23", + "knownRansomwareCampaignUse": "Unknown" + } + ] +} From 3891726852e2d5b48f40de11bdfafafdc8b26aef Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:57 +0000 Subject: [PATCH 17/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- .../vuln/testdata/nvd/cpe-nginx-1.18.0.json | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 internal/vuln/testdata/nvd/cpe-nginx-1.18.0.json diff --git a/internal/vuln/testdata/nvd/cpe-nginx-1.18.0.json b/internal/vuln/testdata/nvd/cpe-nginx-1.18.0.json new file mode 100644 index 0000000..0c926e4 --- /dev/null +++ b/internal/vuln/testdata/nvd/cpe-nginx-1.18.0.json @@ -0,0 +1,59 @@ +{ + "resultsPerPage": 3, + "startIndex": 0, + "totalResults": 3, + "format": "NVD_CVE", + "version": "2.0", + "vulnerabilities": [ + { + "cve": { + "id": "CVE-2021-23017", + "sourceIdentifier": "security@f5.com", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "fixture: off-by-one in the nginx resolver" } + ], + "metrics": { + "cvssMetricV31": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { "version": "3.1", "baseScore": 7.7 } + } + ] + } + } + }, + { + "cve": { + "id": "CVE-2019-20372", + "sourceIdentifier": "security@f5.com", + "vulnStatus": "Analyzed", + "descriptions": [ + { "lang": "en", "value": "fixture: error_page handling" }, + { "lang": "es", "value": "fixture: manejo de error_page" } + ], + "metrics": { + "cvssMetricV2": [ + { + "source": "nvd@nist.gov", + "type": "Primary", + "cvssData": { "version": "2.0", "baseScore": 5.0 } + } + ] + } + } + }, + { + "cve": { + "id": "CVE-2099-0001", + "sourceIdentifier": "cve@mitre.org", + "vulnStatus": "Rejected", + "descriptions": [ + { "lang": "en", "value": "fixture: rejected entry, must be dropped" } + ], + "metrics": {} + } + } + ] +} From 49b87ae629275bf6b0399ad6ab51f4d5d42995c2 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:59 +0000 Subject: [PATCH 18/19] feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_status (#11) --- internal/vuln/vuln.go | 76 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/internal/vuln/vuln.go b/internal/vuln/vuln.go index e693a33..975a41b 100644 --- a/internal/vuln/vuln.go +++ b/internal/vuln/vuln.go @@ -1,11 +1,13 @@ // Package vuln defines the Source and Enricher seams used to look up and -// enrich vulnerabilities for a component. Real implementations (OSV, NVD, -// KEV, EPSS; see docs/architecture/touchstone.md) land in slice S3; this -// package only fixes the interfaces so S0 callers and their tests can be -// written against them now. +// enrich vulnerabilities for a component, their implementations (OSV by +// purl, NVD by CPE, KEV and EPSS enrichment) and the pure ranking step. See +// docs/architecture/touchstone.md. package vuln -import "context" +import ( + "context" + "sort" +) // Query identifies a component to look up. At least one of Purl or CPE is // set; Source implementations that only support one kind ignore queries @@ -15,12 +17,15 @@ type Query struct { CPE string } -// Finding is one vulnerability returned by a Source, before enrichment. +// Finding is one vulnerability returned by a Source. KEV and EPSS are set +// afterwards by an Enricher (see Apply); a Source leaves them zero. type Finding struct { ID string // CVE id when one exists, else the source's native id Aliases []string Summary string CVSSBase *float64 + KEV bool // listed in CISA's Known Exploited Vulnerabilities catalog + EPSS *float64 // FIRST EPSS probability; nil when there is no score } // Source looks up vulnerabilities for a batch of components. Each @@ -43,3 +48,62 @@ type Enricher interface { Name() string Enrich(ctx context.Context, findingIDs []string) (map[string]Enrichment, error) } + +// Apply copies enrichments onto findings by id. A field an enrichment leaves +// unset is not cleared, so the KEV and EPSS results can be applied one after +// the other. +func Apply(findings []Finding, enrichments ...map[string]Enrichment) { + for i := range findings { + for _, m := range enrichments { + e, ok := m[findings[i].ID] + if !ok { + continue + } + if e.KEV { + findings[i].KEV = true + } + if e.EPSS != nil { + findings[i].EPSS = e.EPSS + } + } + } +} + +// Rank orders findings in place for the report: KEV-listed first, then EPSS +// descending, then CVSS base score descending. A missing EPSS sorts after +// every present one, and a missing CVSS after every present one. Ties fall +// back to the id, so the order is deterministic. +func Rank(findings []Finding) { + sort.SliceStable(findings, func(i, j int) bool { + a, b := findings[i], findings[j] + if a.KEV != b.KEV { + return a.KEV + } + if c := compareDesc(a.EPSS, b.EPSS); c != 0 { + return c < 0 + } + if c := compareDesc(a.CVSSBase, b.CVSSBase); c != 0 { + return c < 0 + } + return a.ID < b.ID + }) +} + +// compareDesc orders optional scores largest first with nil last. It returns +// a negative number when a comes before b. +func compareDesc(a, b *float64) int { + switch { + case a == nil && b == nil: + return 0 + case a == nil: + return 1 + case b == nil: + return -1 + case *a > *b: + return -1 + case *a < *b: + return 1 + default: + return 0 + } +} From 4eb588838ac57eff4f810de2f89d9a259ba0eaa7 Mon Sep 17 00:00:00 2001 From: "containarium-ci[bot]" <287705844+containarium-ci[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:21:47 +0000 Subject: [PATCH 19/19] feat(vuln): NVD CPE fallback, KEV and EPSS ranking, rebased on serve (#11) Co-authored-by: hsinatfootprintai <149764663+hsinatfootprintai@users.noreply.github.com> --- .dockerignore | 9 + .gitignore | 7 + Dockerfile | 28 +++ Makefile | 7 +- NOTICE | 7 + README.md | 17 ++ cmd/touchstone/main.go | 12 +- cmd/touchstone/serve.go | 119 ++++++++++++ cmd/touchstone/serve_test.go | 191 ++++++++++++++++++++ docker-compose.yml | 39 ++++ internal/fingerprint/data/signatures.json | 23 ++- internal/fingerprint/fingerprint.go | 84 +++++++-- internal/fingerprint/fingerprint_test.go | 71 ++++++++ internal/server/server.go | 30 +++ internal/server/server_test.go | 100 ++++++++++ internal/service/scanner.go | 10 +- test/e2e/run.sh | 71 ++++++++ test/fixtures/fixture-vendor.conf | 20 ++ test/fixtures/sites/index.html | 11 ++ test/fixtures/sites/js/jquery-1.12.4.min.js | 2 + web/embed.go | 21 +++ web/next.config.mjs | 8 + web/out/.gitkeep | 0 web/package.json | 13 +- web/postcss.config.mjs | 7 + web/src/app/globals.css | 1 + web/src/app/layout.tsx | 24 +++ web/src/app/page.tsx | 5 + web/src/components/component-card.tsx | 87 +++++++++ web/src/components/report-view.test.tsx | 154 ++++++++++++++++ web/src/components/report-view.tsx | 146 +++++++++++++++ web/src/components/scan-form.tsx | 53 ++++++ web/src/components/scan-page.test.tsx | 96 ++++++++++ web/src/components/scan-page.tsx | 54 ++++++ web/src/lib/api.test.ts | 63 +++++++ web/src/lib/api.ts | 66 +++++++ web/src/lib/report.ts | 52 ++++++ web/src/lib/url.test.ts | 18 ++ web/src/lib/url.ts | 9 + web/src/report.test.ts | 6 +- web/src/test/golden.ts | 22 +++ web/tsconfig.json | 1 + web/vitest.config.ts | 13 ++ 43 files changed, 1752 insertions(+), 25 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 NOTICE create mode 100644 cmd/touchstone/serve.go create mode 100644 cmd/touchstone/serve_test.go create mode 100644 docker-compose.yml create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 test/e2e/run.sh create mode 100644 test/fixtures/fixture-vendor.conf create mode 100644 test/fixtures/sites/index.html create mode 100644 test/fixtures/sites/js/jquery-1.12.4.min.js create mode 100644 web/embed.go create mode 100644 web/next.config.mjs create mode 100644 web/out/.gitkeep create mode 100644 web/postcss.config.mjs create mode 100644 web/src/app/globals.css create mode 100644 web/src/app/layout.tsx create mode 100644 web/src/app/page.tsx create mode 100644 web/src/components/component-card.tsx create mode 100644 web/src/components/report-view.test.tsx create mode 100644 web/src/components/report-view.tsx create mode 100644 web/src/components/scan-form.tsx create mode 100644 web/src/components/scan-page.test.tsx create mode 100644 web/src/components/scan-page.tsx create mode 100644 web/src/lib/api.test.ts create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/report.ts create mode 100644 web/src/lib/url.test.ts create mode 100644 web/src/lib/url.ts create mode 100644 web/src/test/golden.ts create mode 100644 web/vitest.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..726dd1e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.github +node_modules +**/node_modules +web/.next +web/next-env.d.ts +web/out/* +!web/out/.gitkeep +test/e2e/out diff --git a/.gitignore b/.gitignore index c2658d7..14e39d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ 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 +# e2e output (test/e2e/run.sh): reports, access log, compose logs +test/e2e/out/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..076bf62 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# One image with two commands: `touchstone serve` (the page and POST /v1/scans) +# and `touchstone scan ` (the CLI). See docs/architecture/touchstone.md, +# "Deployment shape". + +# The page is embedded at build time (web/embed.go), so it is built first. +FROM node:20-alpine AS web +RUN apk add --no-cache make +WORKDIR /src +COPY Makefile ./ +COPY web ./web +RUN make web-build + +FROM golang:1.26 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=web /src/web/out/ ./web/out/ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/touchstone ./cmd/touchstone + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/touchstone /touchstone +# serve listens on all interfaces inside the container; the default is loopback. +ENV TOUCHSTONE_ADDR=0.0.0.0:8080 +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/touchstone"] +CMD ["serve"] 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/NOTICE b/NOTICE new file mode 100644 index 0000000..3bb3d61 --- /dev/null +++ b/NOTICE @@ -0,0 +1,7 @@ +Touchstone +Copyright FootprintAI + +This product includes signature data derived from Retire.js +(https://github.com/RetireJS/retire.js), jsrepository-v4.json, +licensed under the Apache License, Version 2.0 +(http://www.apache.org/licenses/LICENSE-2.0). diff --git a/README.md b/README.md index 110ed40..9331c63 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,23 @@ stale. `make test` and `make web-test` run the Go and web suites. See the design [`docs/architecture/touchstone.md`](docs/architecture/touchstone.md) and the PRD at [`docs/product/touchstone.md`](docs/product/touchstone.md). +### Web page: `touchstone serve` + +`touchstone serve` runs the web page and `POST /v1/scans` on one port. + +```sh +make web-build # exports the page to web/out +go build ./cmd/touchstone +./touchstone serve --addr 127.0.0.1:8080 +``` + +- `--addr host:port` sets the listen address. The default is `127.0.0.1:8080`. +- `TOUCHSTONE_ADDR` sets the same address from the environment. The flag wins when both are set. +- Run `make web-build` before `go build`. The page is embedded at build time, so a binary built + without it serves 404 at `/`. +- The server has no authentication. When the address is not loopback (for example `0.0.0.0:8080` + or `:8080`), `serve` prints a warning on stderr. Bind to `127.0.0.1` unless you mean to expose it. + ## License Apache 2.0, see [LICENSE](LICENSE). diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index bd19863..91f2d12 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -1,8 +1,9 @@ // 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 (by purl) and NVD (by CPE), enriched with KEV -// and EPSS. +// (--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 (by purl) and NVD (by CPE), enriched with KEV and +// EPSS. package main import ( @@ -21,7 +22,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 @@ -48,6 +49,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, os.Stderr, 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..5bb68ff --- /dev/null +++ b/cmd/touchstone/serve.go @@ -0,0 +1,119 @@ +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. It has no +// authentication either, so a non-loopback address gets a warning on errOut. +func serve(ctx context.Context, args []string, out, errOut 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 + } + if warning := nonLoopbackWarning(*addr); warning != "" { + fmt.Fprint(errOut, warning) + } + 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)) + }) +} + +// nonLoopbackWarning returns the stderr warning for an address that other +// hosts can reach, or "" for a loopback address. An empty host such as ":8080" +// listens on every interface, so it warns. +func nonLoopbackWarning(addr string) string { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return "" // net.Listen reports the malformed address + } + if host == "localhost" { + return "" + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + return "" + } + return fmt.Sprintf("touchstone: warning: serving on %s, which other hosts can reach; the server has no authentication. Use a loopback address such as 127.0.0.1 unless that is intended.\n", addr) +} + +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..5b1deb2 --- /dev/null +++ b/cmd/touchstone/serve_test.go @@ -0,0 +1,191 @@ +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 TestNonLoopbackWarning(t *testing.T) { + for _, addr := range []string{"127.0.0.1:8080", "[::1]:8080", "localhost:8080"} { + if got := nonLoopbackWarning(addr); got != "" { + t.Errorf("nonLoopbackWarning(%q) = %q, want no warning", addr, got) + } + } + for _, addr := range []string{"0.0.0.0:8080", "[::]:8080", ":8080", "192.0.2.10:8080", "example.com:8080"} { + got := nonLoopbackWarning(addr) + if !strings.Contains(got, "warning") || !strings.Contains(got, "no authentication") || !strings.HasSuffix(got, "\n") { + t.Errorf("nonLoopbackWarning(%q) = %q, want a warning line", addr, got) + } + } +} + +// startServe runs serve on addr until it is listening, stops it, and returns +// what it wrote to stderr. +func startServe(t *testing.T, addr string) string { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + out, errOut := &syncBuffer{}, &syncBuffer{} + done := make(chan error, 1) + go func() { done <- serve(ctx, []string{"--addr", addr}, out, errOut, fixedStub) }() + for !strings.Contains(out.String(), "listening on") { + select { + case err := <-done: + t.Fatalf("serve(%s) exited early: %v", addr, err) + default: + time.Sleep(5 * time.Millisecond) + } + } + cancel() + if err := <-done; err != nil { + t.Errorf("serve(%s) after cancel: %v", addr, err) + } + return errOut.String() +} + +func TestServeWarnsOnNonLoopbackBind(t *testing.T) { + if got := startServe(t, "127.0.0.1:0"); got != "" { + t.Errorf("loopback bind wrote to stderr: %q", got) + } + if got := startServe(t, "0.0.0.0:0"); !strings.Contains(got, "0.0.0.0:0") || !strings.Contains(got, "no authentication") { + t.Errorf("non-loopback bind stderr = %q, want a warning", got) + } +} + +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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..248b4ce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# Local dev and the passive-only e2e (test/e2e/run.sh). +# +# The vendor network uses the documentation range 198.51.100.0/24, which the +# SSRF guard (internal/fetch/passive.go, IsPrivate) does not refuse. So both +# `serve` and the CLI scan fixture-vendor with the guard fully on; no +# --allow-private is used anywhere in this stack. +services: + touchstone: + build: . + command: ["serve"] + ports: + - "127.0.0.1:8080:8080" + networks: + vendor: + depends_on: + fixture-vendor: + condition: service_healthy + + fixture-vendor: + image: nginx:1.27-alpine + volumes: + - ./test/fixtures/sites:/usr/share/nginx/html:ro + - ./test/fixtures/fixture-vendor.conf:/etc/nginx/conf.d/default.conf:ro + # The access log the e2e reads; see test/e2e/run.sh. + - ./test/e2e/out/access:/var/log/fixture + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"] + interval: 2s + timeout: 2s + retries: 15 + networks: + vendor: + ipv4_address: 198.51.100.10 + +networks: + vendor: + ipam: + config: + - subnet: 198.51.100.0/24 diff --git a/internal/fingerprint/data/signatures.json b/internal/fingerprint/data/signatures.json index 571c088..d82d691 100644 --- a/internal/fingerprint/data/signatures.json +++ b/internal/fingerprint/data/signatures.json @@ -1,5 +1,5 @@ { - "_comment": "Retire.js-style signatures, uri and filename extractors only. Hand-curated for slice S2 (jQuery only); to be replaced by a pinned snapshot of Retire.js jsrepository in S7. {version} in purl and cpe is replaced by the captured version.", + "_comment": "Retire.js-style signatures (uri, filename, filecontent, hashes). Hand-curated subset in the Retire.js jsrepository shape; to be replaced by a pinned snapshot of jsrepository-v4.json (Apache-2.0, see NOTICE). {version} in purl and cpe is replaced by the captured version; §§version§§ in a filecontent pattern is the version capture.", "libraries": [ { "name": "jquery", @@ -9,6 +9,27 @@ "uri": ["jquery[-.]v?([0-9]+(?:\\.[0-9]+)+)(?:\\.min|\\.slim|\\.slim\\.min)?\\.js"], "filename": ["^jquery[-.]v?([0-9]+(?:\\.[0-9]+)+)(?:\\.min|\\.slim|\\.slim\\.min)?\\.js$"] } + }, + { + "name": "react", + "purl": "pkg:npm/react@{version}", + "cpe": "cpe:2.3:a:facebook:react:{version}:*:*:*:*:*:*:*", + "extractors": { + "filecontent": [ + "/\\*\\*?\\s*@license React v§§version§§" + ] + } + }, + { + "name": "vue", + "purl": "pkg:npm/vue@{version}", + "cpe": "cpe:2.3:a:vuejs:vue:{version}:*:*:*:*:*:*:*", + "extractors": { + "filecontent": [ + "/\\*!\\s*\\*\\s*Vue\\.js v§§version§§", + "Vue\\.version\\s*=\\s*[\"']§§version§§[\"']" + ] + } } ] } diff --git a/internal/fingerprint/fingerprint.go b/internal/fingerprint/fingerprint.go index 5aaedd6..f66ed50 100644 --- a/internal/fingerprint/fingerprint.go +++ b/internal/fingerprint/fingerprint.go @@ -6,7 +6,9 @@ package fingerprint import ( "bytes" + "crypto/sha1" _ "embed" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -36,16 +38,21 @@ type signatureFile struct { Purl string `json:"purl"` CPE string `json:"cpe"` Extractors struct { - URI []string `json:"uri"` - Filename []string `json:"filename"` + URI []string `json:"uri"` + Filename []string `json:"filename"` + FileContent []string `json:"filecontent"` + Hashes map[string]string `json:"hashes"` } `json:"extractors"` } `json:"libraries"` } -// library is one compiled signature entry. +// library is one compiled signature entry. hashes maps the lower-case hex +// SHA-1 of a whole asset to the version it is. type library struct { name, purl, cpe string uri, filename []*regexp2.Regexp + filecontent []*regexp2.Regexp + hashes map[string]string } // libraries is compiled once at start-up from the embedded signatures. A @@ -68,7 +75,7 @@ func compile(data []byte) ([]library, error) { } var out []library for _, l := range sf.Libraries { - lib := library{name: l.Name, purl: l.Purl, cpe: l.CPE} + lib := library{name: l.Name, purl: l.Purl, cpe: l.CPE, hashes: map[string]string{}} var err error if lib.uri, err = compileAll(l.Name, "uri", l.Extractors.URI); err != nil { return nil, err @@ -76,6 +83,12 @@ func compile(data []byte) ([]library, error) { if lib.filename, err = compileAll(l.Name, "filename", l.Extractors.Filename); err != nil { return nil, err } + if lib.filecontent, err = compileAll(l.Name, "filecontent", l.Extractors.FileContent); err != nil { + return nil, err + } + for digest, version := range l.Extractors.Hashes { + lib.hashes[strings.ToLower(digest)] = version + } out = append(out, lib) } return out, nil @@ -131,6 +144,45 @@ func FromURL(assetURL string) ([]pipeline.Component, error) { return out, errors.Join(errs...) } +// FromContent matches the bytes of a script that FromURL did not recognise: +// the filecontent extractors run over the text, and the hashes extractors +// over the SHA-1 of the whole asset. Each library it recognises yields one +// Low-confidence component, with the asset URL as evidence. +func FromContent(assetURL string, body []byte) ([]pipeline.Component, error) { + return matchContent(libraries, assetURL, body) +} + +func matchContent(libs []library, assetURL string, body []byte) ([]pipeline.Component, error) { + sum := sha1.Sum(body) + digest := hex.EncodeToString(sum[:]) + text := string(body) + var out []pipeline.Component + var errs []error + for _, lib := range libs { + if version, ok := lib.hashes[digest]; ok { + c := lib.component(version) + c.Evidence = []pipeline.Evidence{{Kind: "FINGERPRINT_ASSET", Location: assetURL, Detail: "sha1 " + digest}} + out = append(out, c) + continue + } + c, ok, err := lib.match(text, lib.filecontent) + if err != nil { + errs = append(errs, err) + continue + } + if !ok { + continue + } + c.Evidence = []pipeline.Evidence{{ + Kind: "FINGERPRINT_ASSET", + Location: assetURL, + Detail: c.Evidence[0].Detail, + }} + out = append(out, c) + } + return out, errors.Join(errs...) +} + // match runs res against input and, on the first hit, builds a component // with the captured version. The evidence detail is the matched text. func (l library) match(input string, res []*regexp2.Regexp) (pipeline.Component, bool, error) { @@ -142,20 +194,25 @@ func (l library) match(input string, res []*regexp2.Regexp) (pipeline.Component, if m == nil { continue } - version := m.GroupByNumber(1).String() - c := pipeline.Component{ - Name: l.name, - Version: version, - Purl: strings.ReplaceAll(l.purl, "{version}", version), - CPE: strings.ReplaceAll(l.cpe, "{version}", version), - Confidence: "LOW", - Evidence: []pipeline.Evidence{{Detail: m.String()}}, - } + c := l.component(m.GroupByNumber(1).String()) + c.Evidence = []pipeline.Evidence{{Detail: m.String()}} return c, true, nil } return pipeline.Component{}, false, nil } +// component builds the library's component at version, with its purl and +// CPE filled in. The caller adds the evidence. +func (l library) component(version string) pipeline.Component { + return pipeline.Component{ + Name: l.name, + Version: version, + Purl: strings.ReplaceAll(l.purl, "{version}", version), + CPE: strings.ReplaceAll(l.cpe, "{version}", version), + Confidence: "LOW", + } +} + // headerRule maps one response header value to a component. Banners // describe the server software, which distributions may patch without // changing the version string, hence the MAY_BE_BACKPORTED caveat. @@ -169,6 +226,7 @@ type headerRule struct { var headerRules = []headerRule{ {"Server", regexp.MustCompile(`^Apache/([0-9]+(?:\.[0-9]+)+)`), "apache http_server", "cpe:2.3:a:apache:http_server:%s:*:*:*:*:*:*:*"}, {"Server", regexp.MustCompile(`^nginx/([0-9]+(?:\.[0-9]+)+)`), "nginx", "cpe:2.3:a:f5:nginx:%s:*:*:*:*:*:*:*"}, + {"Server", regexp.MustCompile(`^Microsoft-IIS/([0-9]+(?:\.[0-9]+)+)`), "iis", "cpe:2.3:a:microsoft:internet_information_services:%s:*:*:*:*:*:*:*"}, {"X-Powered-By", regexp.MustCompile(`^PHP/([0-9]+(?:\.[0-9]+)+)`), "php", "cpe:2.3:a:php:php:%s:*:*:*:*:*:*:*"}, } diff --git a/internal/fingerprint/fingerprint_test.go b/internal/fingerprint/fingerprint_test.go index 4e2d13d..6ec00d6 100644 --- a/internal/fingerprint/fingerprint_test.go +++ b/internal/fingerprint/fingerprint_test.go @@ -6,6 +6,7 @@ import ( "reflect" "strings" "testing" + "time" ) func TestSignaturesCompile(t *testing.T) { @@ -98,6 +99,7 @@ func TestFromHeaders(t *testing.T) { }{ {"apache", http.Header{"Server": {"Apache/2.4.29 (Ubuntu)"}}, []string{"apache http_server 2.4.29"}}, {"nginx", http.Header{"Server": {"nginx/1.18.0"}}, []string{"nginx 1.18.0"}}, + {"iis", http.Header{"Server": {"Microsoft-IIS/10.0"}}, []string{"iis 10.0"}}, {"php", http.Header{"X-Powered-By": {"PHP/7.2.34"}}, []string{"php 7.2.34"}}, {"unknown banner", http.Header{"Server": {"cloudflare"}}, nil}, {"no headers", http.Header{}, nil}, @@ -128,6 +130,75 @@ func TestFromHeaders(t *testing.T) { } } +// testSignatures has one library with a content pattern and one with a +// SHA-1 entry. sha1("abc") is a9993e36 4706816a ba3e2571 7850c26c 9cd0d89d. +const testSignatures = `{"libraries":[ + {"name":"fakelib","purl":"pkg:npm/fakelib@{version}","cpe":"cpe:2.3:a:fake:fakelib:{version}:*:*:*:*:*:*:*", + "extractors":{"filecontent":["/\\*! FakeLib v([0-9]+\\.[0-9]+\\.[0-9]+)"]}}, + {"name":"hashed","purl":"pkg:npm/hashed@{version}","cpe":"","extractors":{"hashes":{"A9993E364706816ABA3E25717850C26C9CD0D89D":"1.0.0"}}} +]}` + +func TestFromContent(t *testing.T) { + libs, err := compile([]byte(testSignatures)) + if err != nil { + t.Fatalf("compile: %v", err) + } + const asset = "https://vendor.example/static/bundle.js" + tests := []struct { + name string + body string + want string // "name version"; "" = no component + detail string + }{ + {"banner in content", "/*! FakeLib v2.3.4 */ var x = 1;", "fakelib 2.3.4", "/*! FakeLib v2.3.4"}, + {"sha1 of whole asset", "abc", "hashed 1.0.0", "sha1 a9993e364706816aba3e25717850c26c9cd0d89d"}, + {"no match", "var x = 1;", "", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := matchContent(libs, asset, []byte(tt.body)) + if err != nil { + t.Fatalf("matchContent: %v", err) + } + if tt.want == "" { + if len(got) != 0 { + t.Fatalf("matchContent = %+v, want no components", got) + } + return + } + if len(got) != 1 { + t.Fatalf("matchContent = %+v, want one component", got) + } + c := got[0] + if c.Name+" "+c.Version != tt.want || c.Confidence != "LOW" { + t.Errorf("component = %q %q confidence %q; want %q LOW", c.Name, c.Version, c.Confidence, tt.want) + } + if len(c.Evidence) != 1 || c.Evidence[0].Kind != "FINGERPRINT_ASSET" || + c.Evidence[0].Location != asset || c.Evidence[0].Detail != tt.detail { + t.Errorf("evidence = %+v, want asset evidence at %s detail %q", c.Evidence, asset, tt.detail) + } + }) + } +} + +// A catastrophic pattern must be stopped by MatchTimeout and reported as an +// error, not left to run for the length of the input. +func TestMatchTimeout(t *testing.T) { + libs, err := compile([]byte(`{"libraries":[{"name":"redos","purl":"","cpe":"","extractors":{"filecontent":["^(a+)+$"]}}]}`)) + if err != nil { + t.Fatalf("compile: %v", err) + } + body := []byte(strings.Repeat("a", 40) + "!") + start := time.Now() + _, err = matchContent(libs, "https://vendor.example/x.js", body) + if err == nil { + t.Fatal("matchContent returned no error; want a timeout") + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("matchContent took %v; want it stopped near the 100ms timeout", elapsed) + } +} + func contains(list []string, want string) bool { for _, s := range list { if s == want { 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..5c785b2 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,100 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/service" +) + +func newTestServer(t *testing.T) *httptest.Server { + t.Helper() + return newServerFor(t, &service.Stub{Now: func() time.Time { return time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC) }}) +} + +func newServerFor(t *testing.T, svc touchstonev1.ScanServiceServer) *httptest.Server { + t.Helper() + static := fstest.MapFS{"index.html": {Data: []byte("touchstone page")}} + 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) + } +} + +// countingService counts the Scan calls that reach the service. +type countingService struct { + *service.Stub + calls int +} + +func (c *countingService) Scan(ctx context.Context, req *touchstonev1.ScanRequest) (*touchstonev1.ScanReport, error) { + c.calls++ + return c.Stub.Scan(ctx, req) +} + +func TestPostScanRejectsOversizedBody(t *testing.T) { + svc := &countingService{Stub: &service.Stub{Now: func() time.Time { return time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC) }}} + srv := newServerFor(t, svc) + big := `{"url":"https://example.com","sbom_document":"` + strings.Repeat("A", maxRequestBytes) + `"}` + // The gateway turns the body-read error from MaxBytesHandler into InvalidArgument, which is 400. + if status, body := post(t, srv, big); status != http.StatusBadRequest { + t.Errorf("oversized POST = %d, want 400, body %s", status, body) + } + if svc.calls != 0 { + t.Errorf("service was called %d times for an oversized body, want 0", svc.calls) + } +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 18f8917..7c7deba 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -120,10 +120,18 @@ func collect(ctx context.Context, f fetch.Fetcher, page *fetch.Response) ([]pipe continue } found, err := fingerprint.FromURL(src) - comps = append(comps, found...) if err != nil { errs = append(errs, err) } + if len(found) == 0 { + // Content is matched only when the URL names no library, so one + // script yields at most one match per library. + found, err = fingerprint.FromContent(src, resp.Body) + if err != nil { + errs = append(errs, err) + } + } + comps = append(comps, found...) } return merge(comps), errors.Join(errs...) } diff --git a/test/e2e/run.sh b/test/e2e/run.sh new file mode 100644 index 0000000..1c17153 --- /dev/null +++ b/test/e2e/run.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Passive-only e2e for docker-compose.yml. It scans fixture-vendor through the +# CLI and through POST /v1/scans, then checks that the two reports agree and +# that the vendor's nginx access log matches the report's checked list. +# +# Usage: bash test/e2e/run.sh (needs docker compose, curl and jq) +set -euo pipefail + +cd "$(dirname "$0")/../.." + +OUT=test/e2e/out +TARGET=http://fixture-vendor/ +SERVE=http://127.0.0.1:8080 +ALLOWED='["TARGET","SUBRESOURCE","DISCOVERY_PATH","DECLARED"]' +# The two scans start and finish at different times; nothing else may differ. +STRIP='del(.startedAt, .completedAt, .vulnDataAsOf)' + +fail() { + echo "e2e: FAIL: $*" >&2 + exit 1 +} + +teardown() { + docker compose logs --no-color > "$OUT/compose.log" 2>&1 || true + docker compose down -v +} + +rm -rf "$OUT" +mkdir -p "$OUT/access" +trap teardown EXIT + +docker compose up -d --build --wait + +for _ in $(seq 60); do + curl -fsS -o /dev/null "$SERVE/" 2>/dev/null && break + sleep 1 +done +curl -fsS -o /dev/null "$SERVE/" || fail "touchstone serve did not answer on $SERVE" + +# Both scans go to fixture-vendor by its compose name; neither uses +# --allow-private, so the SSRF guard is on for both. +docker compose run --rm -T touchstone scan "$TARGET" > "$OUT/cli.json" +curl -fsS -X POST -H 'Content-Type: application/json' \ + -d "{\"url\": \"$TARGET\"}" "$SERVE/v1/scans" > "$OUT/http.json" + +jq -S "$STRIP" "$OUT/cli.json" > "$OUT/cli.norm.json" +jq -S "$STRIP" "$OUT/http.json" > "$OUT/http.norm.json" +diff -u "$OUT/cli.norm.json" "$OUT/http.norm.json" \ + || fail "CLI and POST /v1/scans reports differ (see diff above)" + +jq -e --argjson allowed "$ALLOWED" \ + '[.checked[] | select(.reason as $r | $allowed | contains([$r]) | not)] | length == 0' \ + "$OUT/cli.json" > /dev/null \ + || fail "checked list has a location with a reason outside $ALLOWED" + +# Passive-only proof. Refused requests never left touchstone, so they are +# left out of the checked side. The access log is combined format: field 2 is +# the request line. +LOG="$OUT/access/access.log" +[ -s "$LOG" ] || fail "no access log at $LOG" +awk -F'"' '{ split($2, r, " "); print r[1] }' "$LOG" | sort -u > "$OUT/methods.txt" +[ "$(cat "$OUT/methods.txt")" = "GET" ] \ + || fail "fixture-vendor saw methods other than GET: $(tr '\n' ' ' < "$OUT/methods.txt")" + +awk -F'"' '{ split($2, r, " "); print r[2] }' "$LOG" | sort -u > "$OUT/logged.txt" +jq -r '.checked[] | select(.result != "RESULT_REFUSED") | .url | sub("^https?://[^/]+"; "")' \ + "$OUT/cli.json" | sort -u > "$OUT/sent.txt" +diff -u "$OUT/sent.txt" "$OUT/logged.txt" \ + || fail "access log and checked list differ: '-' is checked but never requested, '+' is requested but not checked" + +echo "e2e: OK: $(wc -l < "$OUT/logged.txt") requested paths, all in the checked list; CLI and POST /v1/scans reports identical" diff --git a/test/fixtures/fixture-vendor.conf b/test/fixtures/fixture-vendor.conf new file mode 100644 index 0000000..aa3daaa --- /dev/null +++ b/test/fixtures/fixture-vendor.conf @@ -0,0 +1,20 @@ +# fixture-vendor: serves test/fixtures/sites and logs every request to the +# access log that test/e2e/run.sh checks against the report's checked list. +server { + listen 80; + root /usr/share/nginx/html; + + access_log /var/log/fixture/access.log combined; + + # Compose's health check. Not logged, so the passive-only check never + # sees it. + location = /healthz { + access_log off; + default_type text/plain; + return 200 "ok\n"; + } + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/test/fixtures/sites/index.html b/test/fixtures/sites/index.html new file mode 100644 index 0000000..2f63a9c --- /dev/null +++ b/test/fixtures/sites/index.html @@ -0,0 +1,11 @@ + + + + +Fixture vendor + + +

Fixture vendor

+ + + diff --git a/test/fixtures/sites/js/jquery-1.12.4.min.js b/test/fixtures/sites/js/jquery-1.12.4.min.js new file mode 100644 index 0000000..cc92ce8 --- /dev/null +++ b/test/fixtures/sites/js/jquery-1.12.4.min.js @@ -0,0 +1,2 @@ +/*! jQuery v1.12.4 fixture stand-in | the scanner reads the version from the URL, not from this body */ +window.jQuery = window.$ = function () { return {}; }; 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..707d898 --- /dev/null +++ b/web/src/components/report-view.test.tsx @@ -0,0 +1,154 @@ +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, + VulnerabilitySchema, +} 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("lists a component's CVEs in the order the server sent them", () => { + // Neither ascending nor descending, so a sort or a reverse in the component fails. + const report = goldenReport(); + report.components.push( + create(ComponentSchema, { + name: "openssl", + version: "1.1.1", + confidence: Confidence.LOW, + vulnerabilities: [ + create(VulnerabilitySchema, { id: "CVE-2024-0002" }), + create(VulnerabilitySchema, { id: "CVE-2023-0001" }), + create(VulnerabilitySchema, { id: "CVE-2025-0003" }), + ], + }), + ); + render(); + + const card = within(lowTier()).getByRole("heading", { name: /openssl/ }).closest("article"); + expect(card).toBeTruthy(); + const ids = within(card as HTMLElement) + .getAllByText(/^CVE-/) + .map((el) => el.textContent); + expect(ids).toEqual(["CVE-2024-0002", "CVE-2023-0001", "CVE-2025-0003"]); + }); + + 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)) }, + }, +});