diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index 19cf4e7..59d7da5 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -1,7 +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 (slice S2). +// looks components up in OSV (slice S2). It also probes the discovery +// paths (security.txt, CSAF, TEA) and accepts a reviewer-supplied SBOM/VEX +// by --sbom-url or --sbom (slice S5). package main import ( @@ -20,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] [--sbom-url ] [--sbom ] " // newService builds the ScanService for one CLI invocation. The fetch // options carry CLI-only settings such as --allow-private, which the @@ -45,6 +47,8 @@ func run(ctx context.Context, args []string, out io.Writer, newSvc newService) e fs.SetOutput(io.Discard) allowPrivate := fs.Bool("allow-private", false, "allow fetching loopback and private addresses (local fixtures only)") format := fs.String("format", "json", "output format: json (protojson) or md (Markdown)") + sbomURL := fs.String("sbom-url", "", "URL of an SBOM/VEX you already have (CycloneDX, SPDX, OpenVEX or CSAF VEX)") + sbomFile := fs.String("sbom", "", "path of an SBOM/VEX file you already have") if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 1 { return errors.New(usage) } @@ -52,8 +56,14 @@ func run(ctx context.Context, args []string, out io.Writer, newSvc newService) e if err != nil { return err } + req := &touchstonev1.ScanRequest{Url: fs.Arg(0), SbomUrl: *sbomURL} + if *sbomFile != "" { + if req.SbomDocument, err = os.ReadFile(*sbomFile); err != nil { + return fmt.Errorf("read --sbom: %w", err) + } + } svc := newSvc(fetch.Options{AllowPrivate: *allowPrivate, Version: service.ToolVersion}) - rep, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: fs.Arg(0)}) + rep, err := svc.Scan(ctx, req) if err != nil { return err } diff --git a/cmd/touchstone/main_test.go b/cmd/touchstone/main_test.go index 3bb0aa5..0975e84 100644 --- a/cmd/touchstone/main_test.go +++ b/cmd/touchstone/main_test.go @@ -174,3 +174,35 @@ func TestScanRejectsBadArgs(t *testing.T) { } } } + +// capturingService records the ScanRequest it receives. +type capturingService struct { + service.Stub + req *touchstonev1.ScanRequest +} + +func (c *capturingService) Scan(ctx context.Context, req *touchstonev1.ScanRequest) (*touchstonev1.ScanReport, error) { + c.req = req + return c.Stub.Scan(ctx, req) +} + +func TestScanSuppliedSBOMFlags(t *testing.T) { + file := filepath.Join(t.TempDir(), "sbom.json") + if err := os.WriteFile(file, []byte(`{"bomFormat":"CycloneDX"}`), 0o644); err != nil { + t.Fatal(err) + } + svc := &capturingService{} + newSvc := func(fetch.Options) touchstonev1.ScanServiceServer { return svc } + args := []string{"scan", "--sbom-url", "https://v.example/sbom.json", "--sbom", file, "https://example.com"} + if err := run(context.Background(), args, &bytes.Buffer{}, newSvc); err != nil { + t.Fatalf("run: %v", err) + } + if svc.req.GetSbomUrl() != "https://v.example/sbom.json" || string(svc.req.GetSbomDocument()) != `{"bomFormat":"CycloneDX"}` { + t.Errorf("request = %v, want sbom_url and the file's contents as sbom_document", svc.req) + } + + missing := []string{"scan", "--sbom", filepath.Join(t.TempDir(), "nope.json"), "https://example.com"} + if err := run(context.Background(), missing, &bytes.Buffer{}, newSvc); err == nil { + t.Error("run with a missing --sbom file = nil error, want error") + } +} diff --git a/internal/discover/discover.go b/internal/discover/discover.go new file mode 100644 index 0000000..a828241 --- /dev/null +++ b/internal/discover/discover.go @@ -0,0 +1,235 @@ +// Package discover finds the evidence a vendor publishes about itself: +// security.txt (RFC 9116), CSAF provider metadata and the VEX documents it +// points to, the /.well-known/tea record, and source-repo links in the +// page (see docs/architecture/touchstone.md, "Evidence discovery"). Every +// request goes through a fetch.Fetcher, so the passive rule and the +// checked list are enforced there, not here. +package discover + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/FootprintAI/touchstone/internal/fetch" + "github.com/FootprintAI/touchstone/internal/sbom" +) + +// MaxVEXDocuments bounds how many documents named by CSAF ROLIE feeds are +// fetched in one scan; the request budget bounds the rest. +const MaxVEXDocuments = 10 + +const ( + pathSecurityTxt = "/.well-known/security.txt" + pathSecurityTxtOld = "/security.txt" + pathProviderMeta = "/.well-known/csaf/provider-metadata.json" + pathTEA = "/.well-known/tea" + maxROLIEFeedsPerRun = 3 +) + +// VEX is one parsed CSAF VEX document and the URL it came from. +type VEX struct { + URL string + Doc *sbom.Document +} + +// Result is what discovery found. Errors lists documents that were +// fetched but could not be understood; fetch failures are in the +// fetcher's checked list instead. +type Result struct { + VEX []VEX + RepoLinks []string + Errors []error +} + +// SecurityTxt is the part of an RFC 9116 file that discovery uses. +type SecurityTxt struct { + CSAF []string + Contact []string +} + +// ParseSecurityTxt reads an RFC 9116 security.txt. Field names are case +// insensitive, lines may end in CRLF, comments and unknown fields are +// ignored, and CSAF: may appear any number of times. +func ParseSecurityTxt(b []byte) SecurityTxt { + var out SecurityTxt + text := strings.TrimPrefix(string(b), "\ufeff") + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + name, value, ok := strings.Cut(line, ":") + value = strings.TrimSpace(value) + if !ok || value == "" { + continue + } + switch strings.ToLower(strings.TrimSpace(name)) { + case "csaf": + out.CSAF = append(out.CSAF, value) + case "contact": + out.Contact = append(out.Contact, value) + } + } + return out +} + +// Run probes the discovery paths on target's origin, follows what they +// declare, and extracts repo links from page. Nothing here fails the +// scan: an unreachable or malformed document is recorded and skipped. +func Run(ctx context.Context, f fetch.Fetcher, target *url.URL, page []byte) *Result { + r := &Result{} + d := &run{ctx: ctx, f: f, target: target, res: r, seen: map[string]bool{}} + + if body, u := d.probe(pathSecurityTxt); body != nil { + d.securityTxt(body, u) + } else if body, u := d.probe(pathSecurityTxtOld); body != nil { + d.securityTxt(body, u) + } + if body, u := d.probe(pathProviderMeta); body != nil { + d.providerMetadata(body, u) + } + // The TEA record is only recorded: querying it needs a TEI the site + // URL doesn't give us. + d.probe(pathTEA) + + r.RepoLinks = RepoLinks(target, page) + return r +} + +type run struct { + ctx context.Context + f fetch.Fetcher + target *url.URL + res *Result + seen map[string]bool + docs int +} + +// probe fetches one fixed discovery path on the target's origin and +// returns the body of a 2xx response. +func (d *run) probe(path string) ([]byte, *url.URL) { + u := d.target.ResolveReference(&url.URL{Path: path}) + return d.get(u.String(), fetch.ReasonDiscoveryPath) +} + +// get fetches rawURL once per scan; a 2xx body is returned with its URL. +func (d *run) get(rawURL string, reason fetch.Reason) ([]byte, *url.URL) { + if d.seen[rawURL] { + return nil, nil + } + d.seen[rawURL] = true + resp, err := d.f.Get(d.ctx, rawURL, reason) + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, nil + } + u, err := url.Parse(resp.URL) + if err != nil || resp.URL == "" { + u, _ = url.Parse(rawURL) + } + return resp.Body, u +} + +func (d *run) securityTxt(body []byte, base *url.URL) { + for _, ref := range ParseSecurityTxt(body).CSAF { + u, ok := resolveHTTP(base, ref) + if !ok { + d.res.Errors = append(d.res.Errors, fmt.Errorf("security.txt: CSAF %q is not an http(s) URL", ref)) + continue + } + if meta, mu := d.get(u, fetch.ReasonDeclared); meta != nil { + d.providerMetadata(meta, mu) + } + } +} + +type providerMetadata struct { + Distributions []struct { + Rolie struct { + Feeds []struct { + URL string `json:"url"` + } `json:"feeds"` + } `json:"rolie"` + } `json:"distributions"` +} + +type rolieFeed struct { + Feed struct { + Entry []struct { + Content struct { + Src string `json:"src"` + } `json:"content"` + } `json:"entry"` + } `json:"feed"` +} + +// providerMetadata follows the ROLIE feeds a provider-metadata.json +// declares to the documents they list. Only URLs the metadata names are +// fetched; the directory layout is never guessed. +func (d *run) providerMetadata(body []byte, base *url.URL) { + var pm providerMetadata + if err := json.Unmarshal(body, &pm); err != nil { + d.res.Errors = append(d.res.Errors, fmt.Errorf("CSAF provider metadata %s: %w", base, err)) + return + } + feeds := 0 + for _, dist := range pm.Distributions { + for _, fd := range dist.Rolie.Feeds { + feedURL, ok := resolveHTTP(base, fd.URL) + if !ok || feeds >= maxROLIEFeedsPerRun { + continue + } + feeds++ + feedBody, fu := d.get(feedURL, fetch.ReasonDeclared) + if feedBody == nil { + continue + } + var feed rolieFeed + if err := json.Unmarshal(feedBody, &feed); err != nil { + d.res.Errors = append(d.res.Errors, fmt.Errorf("ROLIE feed %s: %w", fu, err)) + continue + } + for _, e := range feed.Feed.Entry { + if docURL, ok := resolveHTTP(fu, e.Content.Src); ok { + d.vex(docURL) + } + } + } + } +} + +// vex fetches one declared CSAF document and keeps it if it is a VEX. +// Other CSAF documents (plain advisories) are ignored. +func (d *run) vex(docURL string) { + if d.docs >= MaxVEXDocuments { + return + } + body, u := d.get(docURL, fetch.ReasonDeclared) + if body == nil { + return + } + d.docs++ + doc, err := sbom.Parse(body) + switch { + case errors.Is(err, sbom.ErrUnknownFormat): + case err != nil: + d.res.Errors = append(d.res.Errors, fmt.Errorf("%s: %w", u, err)) + default: + d.res.VEX = append(d.res.VEX, VEX{URL: u.String(), Doc: doc}) + } +} + +func resolveHTTP(base *url.URL, ref string) (string, bool) { + r, err := url.Parse(strings.TrimSpace(ref)) + if err != nil || ref == "" { + return "", false + } + abs := base.ResolveReference(r) + if (abs.Scheme != "http" && abs.Scheme != "https") || abs.Host == "" { + return "", false + } + return abs.String(), true +} diff --git a/internal/discover/discover_test.go b/internal/discover/discover_test.go new file mode 100644 index 0000000..662ae2c --- /dev/null +++ b/internal/discover/discover_test.go @@ -0,0 +1,147 @@ +package discover + +import ( + "context" + "net/url" + "reflect" + "testing" + + "github.com/FootprintAI/touchstone/internal/fetch" + "github.com/FootprintAI/touchstone/internal/sbom" +) + +func TestParseSecurityTxt(t *testing.T) { + in := "\ufeff# our policy\r\nContact: mailto:sec@vendor.example\r\n" + + "csaf: https://vendor.example/.well-known/csaf/provider-metadata.json\r\n" + + "\r\nCSAF: https://vendor.example/other/provider-metadata.json\r\n" + + "Expires: 2030-01-01T00:00:00Z\r\nNot a field line\r\nCSAF:\r\n" + got := ParseSecurityTxt([]byte(in)) + wantCSAF := []string{ + "https://vendor.example/.well-known/csaf/provider-metadata.json", + "https://vendor.example/other/provider-metadata.json", + } + if !reflect.DeepEqual(got.CSAF, wantCSAF) { + t.Errorf("CSAF = %q, want %q", got.CSAF, wantCSAF) + } + if !reflect.DeepEqual(got.Contact, []string{"mailto:sec@vendor.example"}) { + t.Errorf("Contact = %q", got.Contact) + } +} + +const ( + site = "https://vendor.example" + vexDoc = `{"document":{"category":"csaf_vex"},"product_tree":{"full_product_names":[{"name":"Widget 2.0","product_id":"W","product_identification_helper":{"purl":"pkg:npm/widget@2.0.0"}}]}}` + advisory = `{"document":{"category":"csaf_security_advisory"}}` +) + +func ok(body string) *fetch.Response { return &fetch.Response{StatusCode: 200, Body: []byte(body)} } + +func TestRunFollowsSecurityTxtToVEX(t *testing.T) { + f := fetch.NewFake() + f.SetResponse(site+"/.well-known/security.txt", ok("Contact: mailto:a@b.example\nCSAF: "+site+"/.well-known/csaf/provider-metadata.json\n")) + f.SetResponse(site+"/.well-known/csaf/provider-metadata.json", + ok(`{"distributions":[{"rolie":{"feeds":[{"url":"/csaf/feed.json"}]}}]}`)) + f.SetResponse(site+"/csaf/feed.json", + ok(`{"feed":{"entry":[{"content":{"src":"/csaf/adv.json"}},{"content":{"src":"/csaf/vex.json"}}]}}`)) + f.SetResponse(site+"/csaf/adv.json", ok(advisory)) + f.SetResponse(site+"/csaf/vex.json", ok(vexDoc)) + f.SetResponse(site+"/.well-known/tea", ok(`{"endpoints":["https://tea.vendor.example/v1"]}`)) + + target, _ := url.Parse(site + "/") + res := Run(context.Background(), f, target, nil) + + if len(res.VEX) != 1 || res.VEX[0].URL != site+"/csaf/vex.json" || res.VEX[0].Doc.Format != sbom.CSAFVEX || + res.VEX[0].Doc.Components[0].Purl != "pkg:npm/widget@2.0.0" { + t.Fatalf("VEX = %+v, want the one VEX document", res.VEX) + } + if len(res.Errors) != 0 { + t.Errorf("Errors = %v", res.Errors) + } + + want := []fetch.Call{ + {URL: site + "/.well-known/security.txt", Reason: fetch.ReasonDiscoveryPath}, + {URL: site + "/.well-known/csaf/provider-metadata.json", Reason: fetch.ReasonDeclared}, + {URL: site + "/csaf/feed.json", Reason: fetch.ReasonDeclared}, + {URL: site + "/csaf/adv.json", Reason: fetch.ReasonDeclared}, + {URL: site + "/csaf/vex.json", Reason: fetch.ReasonDeclared}, + {URL: site + "/.well-known/tea", Reason: fetch.ReasonDiscoveryPath}, + } + if !reflect.DeepEqual(f.Calls, want) { + t.Errorf("calls = %+v\nwant %+v", f.Calls, want) + } +} + +func TestRunRecordsTEAWithoutQueryingIt(t *testing.T) { + f := fetch.NewFake() + f.SetResponse(site+"/.well-known/tea", ok(`{"endpoints":["https://tea.vendor.example/v1"]}`)) + target, _ := url.Parse(site) + Run(context.Background(), f, target, nil) + + for _, c := range f.Calls { + if c.URL == "https://tea.vendor.example/v1" { + t.Errorf("TEA endpoint was queried: %+v", c) + } + } + last := f.Calls[len(f.Calls)-1] + if last.URL != site+"/.well-known/tea" || last.Reason != fetch.ReasonDiscoveryPath { + t.Errorf("last call = %+v, want the TEA record as DISCOVERY_PATH", last) + } +} + +func TestRunFallsBackToRootSecurityTxtAndKeepsGoingOnErrors(t *testing.T) { + f := fetch.NewFake() + f.SetResponse(site+"/.well-known/security.txt", &fetch.Response{StatusCode: 404}) + f.SetResponse(site+"/security.txt", ok("CSAF: "+site+"/pm.json\n")) + f.SetResponse(site+"/pm.json", ok(`not json`)) + target, _ := url.Parse(site) + res := Run(context.Background(), f, target, nil) + + if len(res.VEX) != 0 || len(res.Errors) != 1 { + t.Errorf("VEX = %+v, Errors = %v; want no VEX and one error for the bad metadata", res.VEX, res.Errors) + } + // Provider metadata is probed too (not found) and TEA after it. + if n := len(f.Calls); n != 5 { + t.Errorf("calls = %+v, want 5 (2 security.txt, declared metadata, provider path, tea)", f.Calls) + } +} + +func TestRunRejectsNonHTTPCSAFField(t *testing.T) { + f := fetch.NewFake() + f.SetResponse(site+"/.well-known/security.txt", ok("CSAF: ftp://vendor.example/pm.json\n")) + target, _ := url.Parse(site) + res := Run(context.Background(), f, target, nil) + if len(res.Errors) != 1 { + t.Errorf("Errors = %v, want one for the ftp CSAF field", res.Errors) + } + for _, c := range f.Calls { + if c.Reason == fetch.ReasonDeclared { + t.Errorf("declared fetch for a non-http URL: %+v", c) + } + } +} + +func TestRepoLinks(t *testing.T) { + page, _ := url.Parse(site + "/about/") + body := ` +source +issues again +other + + +share +tweet +org only +features +pipeline +local +` + got := RepoLinks(page, []byte(body)) + want := []string{ + "https://gitlab.com/vendor/widget", + "https://github.com/vendor/app", + "https://github.com/vendor/other", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("RepoLinks = %q, want %q", got, want) + } +} diff --git a/internal/discover/repolinks.go b/internal/discover/repolinks.go new file mode 100644 index 0000000..fc76fcc --- /dev/null +++ b/internal/discover/repolinks.go @@ -0,0 +1,115 @@ +package discover + +import ( + "bytes" + "net/url" + "strings" + + "golang.org/x/net/html" +) + +// notOwners are GitHub and GitLab path prefixes that are site pages, not +// a repo owner (share buttons, login links, marketing pages). +var notOwners = map[string]bool{ + "sharer": true, "share": true, "intent": true, "login": true, "signup": true, "join": true, + "features": true, "pricing": true, "about": true, "orgs": true, "sponsors": true, + "topics": true, "marketplace": true, "settings": true, "apps": true, "enterprise": true, + "explore": true, "users": true, "groups": true, "help": true, "search": true, +} + +// notRepoPaths are path segments after owner/repo that mark a badge or +// share link rather than a link to the repo itself. +var notRepoPaths = map[string]bool{ + "badge": true, "badges": true, "actions": true, "workflows": true, "sharer": true, "share": true, +} + +// RepoLinks returns the GitHub and GitLab repository URLs the page links +// to with or , normalized to https://host/owner/repo, +// in document order and without duplicates. Badge images and their +// links, share buttons and site pages are ignored. +func RepoLinks(page *url.URL, body []byte) []string { + doc, err := html.Parse(bytes.NewReader(body)) + if err != nil { + return nil + } + seen := map[string]bool{} + var out []string + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && (n.Data == "a" || n.Data == "link") && !hasBadgeImage(n) { + for _, a := range n.Attr { + if a.Key != "href" { + continue + } + if repo, ok := repoURL(page, a.Val); ok && !seen[repo] { + seen[repo] = true + out = append(out, repo) + } + break + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(doc) + return out +} + +// hasBadgeImage reports whether n wraps an that is a status badge. +func hasBadgeImage(n *html.Node) bool { + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.Data == "img" { + for _, a := range c.Attr { + if a.Key == "src" && isBadgeSrc(a.Val) { + return true + } + } + } + if hasBadgeImage(c) { + return true + } + } + return false +} + +func isBadgeSrc(src string) bool { + u, err := url.Parse(src) + if err != nil { + return false + } + host := strings.ToLower(u.Hostname()) + return strings.HasSuffix(host, "shields.io") || strings.Contains(host, "badge") || + strings.Contains(strings.ToLower(u.Path), "badge") +} + +func repoURL(page *url.URL, href string) (string, bool) { + ref, err := url.Parse(strings.TrimSpace(href)) + if err != nil { + return "", false + } + u := page.ResolveReference(ref) + host := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.") + if (u.Scheme != "http" && u.Scheme != "https") || (host != "github.com" && host != "gitlab.com") { + return "", false + } + var segs []string + for _, s := range strings.Split(u.Path, "/") { + if s != "" { + segs = append(segs, s) + } + } + if len(segs) < 2 || notOwners[strings.ToLower(segs[0])] { + return "", false + } + if len(segs) > 2 && notRepoPaths[strings.ToLower(segs[2])] { + return "", false + } + if len(segs) > 3 && segs[2] == "-" && notRepoPaths[strings.ToLower(segs[3])] { + return "", false + } + if strings.HasSuffix(strings.ToLower(segs[len(segs)-1]), "badge.svg") { + return "", false + } + return "https://" + host + "/" + segs[0] + "/" + strings.TrimSuffix(segs[1], ".git"), true +} diff --git a/internal/sbom/sbom.go b/internal/sbom/sbom.go new file mode 100644 index 0000000..02c29f1 --- /dev/null +++ b/internal/sbom/sbom.go @@ -0,0 +1,211 @@ +// Package sbom extracts components from SBOM and VEX documents +// (CycloneDX, SPDX, OpenVEX and CSAF 2.0 VEX). It does no I/O: callers +// fetch the bytes and pass them in. Only the components are read here; +// applying VEX statements to vulnerabilities lands with slice S4. +package sbom + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" +) + +// Format is the kind of document Parse recognised. +type Format string + +const ( + CycloneDX Format = "CYCLONEDX" + SPDX Format = "SPDX" + OpenVEX Format = "OPENVEX" + CSAFVEX Format = "CSAF_VEX" +) + +// ErrUnknownFormat is returned for well-formed JSON that is none of the +// supported formats (including CSAF documents that are not VEX). +var ErrUnknownFormat = errors.New("sbom: not a supported SBOM or VEX document") + +// Component is one product named by a document. +type Component struct { + Name string + Version string + Purl string + CPE string +} + +// Document is the normalized content of one SBOM or VEX document. +type Document struct { + Format Format + Components []Component +} + +type cdxComponent struct { + Name string `json:"name"` + Version string `json:"version"` + Purl string `json:"purl"` + CPE string `json:"cpe"` + Components []cdxComponent `json:"components"` +} + +type spdxPackage struct { + Name string `json:"name"` + VersionInfo string `json:"versionInfo"` + ExternalRef []struct { + Type string `json:"referenceType"` + Locator string `json:"referenceLocator"` + } `json:"externalRefs"` +} + +type csafProduct struct { + Name string `json:"name"` + Helper struct { + Purl string `json:"purl"` + CPE string `json:"cpe"` + } `json:"product_identification_helper"` +} + +type csafBranch struct { + Product *csafProduct `json:"product"` + Branches []csafBranch `json:"branches"` +} + +type raw struct { + BomFormat string `json:"bomFormat"` + Components []cdxComponent `json:"components"` + + SPDXVersion string `json:"spdxVersion"` + Packages []spdxPackage `json:"packages"` + + Context string `json:"@context"` + Statements []struct { + Products []struct { + ID string `json:"@id"` + Identifiers map[string]string `json:"identifiers"` + } `json:"products"` + } `json:"statements"` + + Document struct { + Category string `json:"category"` + } `json:"document"` + ProductTree struct { + Branches []csafBranch `json:"branches"` + FullProductNames []csafProduct `json:"full_product_names"` + } `json:"product_tree"` +} + +// Parse decodes one document. Malformed JSON is an error wrapping the +// decoder's; JSON that is not a supported format is ErrUnknownFormat. +func Parse(b []byte) (*Document, error) { + var r raw + if err := json.Unmarshal(b, &r); err != nil { + return nil, fmt.Errorf("sbom: %w", err) + } + var doc Document + switch { + case r.BomFormat == "CycloneDX": + doc.Format = CycloneDX + cdx(&doc, r.Components) + case strings.HasPrefix(r.SPDXVersion, "SPDX-"): + doc.Format = SPDX + for _, p := range r.Packages { + c := Component{Name: p.Name, Version: p.VersionInfo} + for _, ref := range p.ExternalRef { + switch ref.Type { + case "purl": + c.Purl = ref.Locator + case "cpe23Type": + c.CPE = ref.Locator + } + } + doc.add(c) + } + case strings.Contains(r.Context, "openvex"): + doc.Format = OpenVEX + for _, s := range r.Statements { + for _, p := range s.Products { + id := p.ID + if v := p.Identifiers["purl"]; v != "" { + id = v + } + if strings.HasPrefix(id, "pkg:") { + doc.add(Component{Purl: id}) + } + } + } + case r.Document.Category == "csaf_vex": + doc.Format = CSAFVEX + for _, p := range r.ProductTree.FullProductNames { + doc.addCSAF(p) + } + var walk func([]csafBranch) + walk = func(bs []csafBranch) { + for _, b := range bs { + if b.Product != nil { + doc.addCSAF(*b.Product) + } + walk(b.Branches) + } + } + walk(r.ProductTree.Branches) + default: + return nil, ErrUnknownFormat + } + return &doc, nil +} + +func cdx(doc *Document, cs []cdxComponent) { + for _, c := range cs { + doc.add(Component{Name: c.Name, Version: c.Version, Purl: c.Purl, CPE: c.CPE}) + cdx(doc, c.Components) + } +} + +func (d *Document) addCSAF(p csafProduct) { + c := Component{Name: p.Name, Purl: p.Helper.Purl, CPE: p.Helper.CPE} + // A CSAF product name is display text ("Foo 1.2 on Linux"); the purl's + // name and version are the machine-readable ones. + if name, version := splitPurl(c.Purl); name != "" { + c.Name, c.Version = name, version + } + d.add(c) +} + +// add fills a missing name or version from the purl, and skips +// components that name nothing or repeat an earlier one. +func (d *Document) add(c Component) { + if name, version := splitPurl(c.Purl); c.Purl != "" { + if c.Name == "" { + c.Name = name + } + if c.Version == "" { + c.Version = version + } + } + if c.Name == "" { + return + } + for _, e := range d.Components { + if e.Name == c.Name && e.Version == c.Version && e.Purl == c.Purl { + return + } + } + d.Components = append(d.Components, c) +} + +// splitPurl returns the name and version of a package URL +// (pkg:type/namespace/name@version?qualifiers#subpath). +func splitPurl(purl string) (name, version string) { + s := strings.TrimPrefix(purl, "pkg:") + s, _, _ = strings.Cut(s, "#") + s, _, _ = strings.Cut(s, "?") + if i := strings.LastIndex(s, "@"); i >= 0 { + version, _ = url.PathUnescape(s[i+1:]) + s = s[:i] + } + if i := strings.LastIndex(s, "/"); i >= 0 { + s = s[i+1:] + } + name, _ = url.PathUnescape(s) + return name, version +} diff --git a/internal/sbom/sbom_test.go b/internal/sbom/sbom_test.go new file mode 100644 index 0000000..ff11e5e --- /dev/null +++ b/internal/sbom/sbom_test.go @@ -0,0 +1,76 @@ +package sbom + +import ( + "errors" + "testing" +) + +func TestParse(t *testing.T) { + for _, tt := range []struct { + name string + doc string + want Format + got []Component + }{ + { + name: "cyclonedx with nested component", + doc: `{"bomFormat":"CycloneDX","components":[ + {"name":"jquery","version":"3.7.1","purl":"pkg:npm/jquery@3.7.1","components":[{"name":"inner","version":"1"}]}]}`, + want: CycloneDX, + got: []Component{ + {Name: "jquery", Version: "3.7.1", Purl: "pkg:npm/jquery@3.7.1"}, + {Name: "inner", Version: "1"}, + }, + }, + { + name: "spdx purl and cpe refs", + doc: `{"spdxVersion":"SPDX-2.3","packages":[{"name":"openssl","versionInfo":"3.0.1","externalRefs":[ + {"referenceType":"purl","referenceLocator":"pkg:generic/openssl@3.0.1"}, + {"referenceType":"cpe23Type","referenceLocator":"cpe:2.3:a:openssl:openssl:3.0.1:*:*:*:*:*:*:*"}]}]}`, + want: SPDX, + got: []Component{{Name: "openssl", Version: "3.0.1", Purl: "pkg:generic/openssl@3.0.1", + CPE: "cpe:2.3:a:openssl:openssl:3.0.1:*:*:*:*:*:*:*"}}, + }, + { + name: "openvex products by purl", + doc: `{"@context":"https://openvex.dev/ns/v0.2.0","statements":[ + {"vulnerability":{"name":"CVE-2024-1"},"products":[{"@id":"pkg:npm/%40scope/lib@1.0.0"}]}]}`, + want: OpenVEX, + got: []Component{{Name: "lib", Version: "1.0.0", Purl: "pkg:npm/%40scope/lib@1.0.0"}}, + }, + { + name: "csaf vex product tree", + doc: `{"document":{"category":"csaf_vex"},"product_tree":{"branches":[{"branches":[ + {"product":{"name":"Foo 1.2 on Linux","product_id":"P1","product_identification_helper":{"purl":"pkg:maven/org.foo/foo@1.2"}}}]}]}}`, + want: CSAFVEX, + got: []Component{{Name: "foo", Version: "1.2", Purl: "pkg:maven/org.foo/foo@1.2"}}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + doc, err := Parse([]byte(tt.doc)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if doc.Format != tt.want { + t.Errorf("format = %s, want %s", doc.Format, tt.want) + } + if len(doc.Components) != len(tt.got) { + t.Fatalf("components = %+v, want %+v", doc.Components, tt.got) + } + for i, c := range tt.got { + if doc.Components[i] != c { + t.Errorf("component %d = %+v, want %+v", i, doc.Components[i], c) + } + } + }) + } +} + +func TestParseRejects(t *testing.T) { + if _, err := Parse([]byte(`{"document":{"category":"csaf_security_advisory"}}`)); !errors.Is(err, ErrUnknownFormat) { + t.Errorf("non-VEX CSAF: err = %v, want ErrUnknownFormat", err) + } + if _, err := Parse([]byte(`{`)); err == nil || errors.Is(err, ErrUnknownFormat) { + t.Errorf("malformed JSON: err = %v, want a decode error", err) + } +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index 76c139e..e59e8ea 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -3,14 +3,17 @@ package service import ( "context" "errors" + "fmt" "net/url" "strings" "time" touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/discover" "github.com/FootprintAI/touchstone/internal/fetch" "github.com/FootprintAI/touchstone/internal/fingerprint" "github.com/FootprintAI/touchstone/internal/pipeline" + "github.com/FootprintAI/touchstone/internal/sbom" "github.com/FootprintAI/touchstone/internal/vuln" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -46,6 +49,9 @@ func (s *Scanner) Scan(ctx context.Context, req *touchstonev1.ScanRequest) (*tou if err != nil { return nil, err } + if err := validateSupplied(req); err != nil { + return nil, err + } opts := s.Fetch opts.Budget = req.GetRequestBudget() f, err := fetch.New(target.String(), opts) @@ -81,6 +87,26 @@ func (s *Scanner) Scan(ctx context.Context, req *touchstonev1.ScanRequest) (*tou Error: errText(fpErr), }) + // Discovery runs after the page's own scripts, so a tight budget goes + // to what a browser would load first. + disc := discover.Run(ctx, f, target, page.Body) + rep.Sources = append(rep.Sources, &touchstonev1.SourceStatus{ + Source: "discover", + Ok: len(disc.Errors) == 0, + Error: errText(errors.Join(disc.Errors...)), + }) + supplied, supErr := suppliedComponents(ctx, f, req) + if req.GetSbomUrl() != "" || len(req.GetSbomDocument()) > 0 { + rep.Sources = append(rep.Sources, &touchstonev1.SourceStatus{ + Source: "sbom-supplied", + Ok: supErr == nil, + Error: errText(supErr), + }) + } + // High-confidence components first, so merge keeps their version. + high := append(supplied, vexComponents(disc.VEX)...) + comps = merge(append(high, comps...)) + rep.LookupStatus, rep.Components = s.lookup(ctx, rep, comps) s.finish(rep, f, now) return rep, nil @@ -118,6 +144,77 @@ func collect(ctx context.Context, f fetch.Fetcher, page *fetch.Response) ([]pipe return merge(comps), errors.Join(errs...) } +// validateSupplied rejects a bad reviewer-supplied SBOM before any +// request is sent. +func validateSupplied(req *touchstonev1.ScanRequest) error { + if u := req.GetSbomUrl(); u != "" { + if _, err := absoluteURL(u); err != nil { + return status.Errorf(codes.InvalidArgument, "sbom_url must be an absolute http(s) URL, got %q", u) + } + } + if n := len(req.GetSbomDocument()); n > fetch.MaxResponseBytes { + return status.Errorf(codes.InvalidArgument, "sbom_document is %d bytes, the maximum is %d", n, fetch.MaxResponseBytes) + } + return nil +} + +// suppliedComponents reads the reviewer-supplied SBOM/VEX: the uploaded +// document, and the one at sbom_url, which is fetched as a DECLARED +// request so it shows in checked and counts against the budget. +func suppliedComponents(ctx context.Context, f fetch.Fetcher, req *touchstonev1.ScanRequest) ([]pipeline.Component, error) { + var comps []pipeline.Component + var errs []error + add := func(location string, body []byte) { + doc, err := sbom.Parse(body) + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", location, err)) + return + } + comps = append(comps, fromSBOM(doc, location, "SBOM_SUPPLIED")...) + } + if body := req.GetSbomDocument(); len(body) > 0 { + add("uploaded document", body) + } + if u := req.GetSbomUrl(); u != "" { + resp, err := f.Get(ctx, u, fetch.ReasonDeclared) + switch { + case err != nil: + errs = append(errs, err) + case resp.StatusCode < 200 || resp.StatusCode >= 300: + errs = append(errs, fmt.Errorf("%s: HTTP %d", u, resp.StatusCode)) + default: + add(u, resp.Body) + } + } + return comps, errors.Join(errs...) +} + +// vexComponents converts the CSAF VEX documents discovery parsed. +func vexComponents(docs []discover.VEX) []pipeline.Component { + var out []pipeline.Component + for _, d := range docs { + out = append(out, fromSBOM(d.Doc, d.URL, "VEX")...) + } + return out +} + +// fromSBOM makes High-confidence components with evidence of the given +// kind (a proto EvidenceKind name without its prefix). +func fromSBOM(doc *sbom.Document, location, kind string) []pipeline.Component { + var out []pipeline.Component + for _, c := range doc.Components { + out = append(out, pipeline.Component{ + Name: c.Name, + Version: c.Version, + Purl: c.Purl, + CPE: c.CPE, + Confidence: "HIGH", + Evidence: []pipeline.Evidence{{Kind: kind, Location: location, Detail: string(doc.Format)}}, + }) + } + return out +} + // 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. diff --git a/internal/service/scanner_discover_test.go b/internal/service/scanner_discover_test.go new file mode 100644 index 0000000..6515a0a --- /dev/null +++ b/internal/service/scanner_discover_test.go @@ -0,0 +1,165 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/fetch" + "github.com/FootprintAI/touchstone/internal/vuln" +) + +const ( + cycloneDXSample = `{"bomFormat":"CycloneDX","components":[{"name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"}]}` + csafVEXSample = `{"document":{"category":"csaf_vex"},"product_tree":{"full_product_names":[{"name":"Widget","product_id":"W","product_identification_helper":{"purl":"pkg:npm/widget@2.0.0"}}]}}` +) + +// csafSite serves a page plus security.txt → provider metadata → ROLIE +// feed → one VEX document, and a reviewer-supplied SBOM at /sbom.json. +func csafSite(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`src`)) + }) + mux.HandleFunc("/.well-known/security.txt", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("Contact: mailto:sec@vendor.example\r\nCSAF: http://" + r.Host + "/.well-known/csaf/provider-metadata.json\r\n")) + }) + mux.HandleFunc("/.well-known/csaf/provider-metadata.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"distributions":[{"rolie":{"feeds":[{"url":"/csaf/feed.json"}]}}]}`)) + }) + mux.HandleFunc("/csaf/feed.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"feed":{"entry":[{"content":{"src":"/csaf/vex.json"}}]}}`)) + }) + mux.HandleFunc("/csaf/vex.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(csafVEXSample)) + }) + mux.HandleFunc("/sbom.json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(cycloneDXSample)) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func scannerWithNoVulns() *Scanner { + return &Scanner{ + Fetch: fetch.Options{AllowPrivate: true, Version: ToolVersion}, + Vulns: &vuln.FakeSource{SourceName: "fake"}, + } +} + +func findComponent(rep *touchstonev1.ScanReport, name string) *touchstonev1.Component { + for _, c := range rep.GetComponents() { + if c.GetName() == name { + return c + } + } + return nil +} + +func TestScannerFollowsCSAFToVEX(t *testing.T) { + site := csafSite(t) + rep, err := scannerWithNoVulns().Scan(context.Background(), &touchstonev1.ScanRequest{Url: site.URL + "/"}) + if err != nil { + t.Fatalf("Scan: %v", err) + } + + found := map[string]string{} + for _, c := range rep.GetChecked() { + if c.GetResult() == touchstonev1.CheckedLocation_RESULT_FOUND { + found[c.GetUrl()] = c.GetReason() + } + } + for url, reason := range map[string]string{ + site.URL + "/.well-known/security.txt": "DISCOVERY_PATH", + site.URL + "/.well-known/csaf/provider-metadata.json": "DECLARED", + site.URL + "/csaf/vex.json": "DECLARED", + } { + if found[url] != reason { + t.Errorf("checked lacks FOUND %s %s; found = %v", reason, url, found) + } + } + + w := findComponent(rep, "widget") + if w == nil { + t.Fatalf("no widget component from the VEX document in %v", rep.GetComponents()) + } + if w.GetConfidence() != touchstonev1.Confidence_CONFIDENCE_HIGH || + w.GetEvidence()[0].GetKind() != touchstonev1.EvidenceKind_EVIDENCE_KIND_VEX || + w.GetEvidence()[0].GetLocation() != site.URL+"/csaf/vex.json" { + t.Errorf("widget = %v, want High confidence VEX evidence from the VEX URL", w) + } +} + +func TestScannerSuppliedSBOMIsHighConfidence(t *testing.T) { + site := csafSite(t) + for name, req := range map[string]*touchstonev1.ScanRequest{ + "document": {Url: site.URL + "/", SbomDocument: []byte(cycloneDXSample)}, + "url": {Url: site.URL + "/", SbomUrl: site.URL + "/sbom.json"}, + } { + t.Run(name, func(t *testing.T) { + rep, err := scannerWithNoVulns().Scan(context.Background(), req) + if err != nil { + t.Fatalf("Scan: %v", err) + } + c := findComponent(rep, "lodash") + if c == nil { + t.Fatalf("no lodash component in %v", rep.GetComponents()) + } + if c.GetConfidence() != touchstonev1.Confidence_CONFIDENCE_HIGH || c.GetVersion() != "4.17.21" || + c.GetEvidence()[0].GetKind() != touchstonev1.EvidenceKind_EVIDENCE_KIND_SBOM_SUPPLIED { + t.Errorf("lodash = %v, want High confidence with SBOM_SUPPLIED evidence", c) + } + var supplied *touchstonev1.SourceStatus + for _, s := range rep.GetSources() { + if s.GetSource() == "sbom-supplied" { + supplied = s + } + } + if supplied == nil || !supplied.GetOk() { + t.Errorf("sbom-supplied source = %v, want ok", supplied) + } + }) + } +} + +func TestScannerSuppliedSBOMURLAppearsInChecked(t *testing.T) { + site := csafSite(t) + rep, err := scannerWithNoVulns().Scan(context.Background(), + &touchstonev1.ScanRequest{Url: site.URL + "/", SbomUrl: site.URL + "/sbom.json"}) + if err != nil { + t.Fatalf("Scan: %v", err) + } + for _, c := range rep.GetChecked() { + if c.GetUrl() == site.URL+"/sbom.json" { + if c.GetReason() != "DECLARED" || c.GetResult() != touchstonev1.CheckedLocation_RESULT_FOUND { + t.Errorf("sbom_url entry = %v, want DECLARED FOUND", c) + } + return + } + } + t.Errorf("sbom_url missing from checked: %v", rep.GetChecked()) +} + +func TestScannerBadSuppliedSBOM(t *testing.T) { + site := csafSite(t) + if _, err := scannerWithNoVulns().Scan(context.Background(), + &touchstonev1.ScanRequest{Url: site.URL + "/", SbomUrl: "ftp://x.example/sbom.json"}); err == nil { + t.Error("Scan: want InvalidArgument for a non-http sbom_url") + } + + rep, err := scannerWithNoVulns().Scan(context.Background(), + &touchstonev1.ScanRequest{Url: site.URL + "/", SbomDocument: []byte(`{"unknown":true}`)}) + if err != nil { + t.Fatalf("Scan: an unparseable SBOM is a source error, not an RPC error: %v", err) + } + for _, s := range rep.GetSources() { + if s.GetSource() == "sbom-supplied" && !s.GetOk() && s.GetError() != "" { + return + } + } + t.Errorf("sources = %v, want sbom-supplied ok=false with an error", rep.GetSources()) +} diff --git a/internal/service/scanner_test.go b/internal/service/scanner_test.go index d73098b..fcadd16 100644 --- a/internal/service/scanner_test.go +++ b/internal/service/scanner_test.go @@ -129,17 +129,23 @@ func TestScannerCheckedListMatchesRequests(t *testing.T) { t.Fatalf("Scan: %v", err) } checked := rep.GetChecked() - if len(checked) != 2 { - t.Fatalf("checked = %v, want the target and the script", checked) - } - want := []struct{ url, reason string }{ - {site.URL + "/", "TARGET"}, - {site.URL + "/js/jquery-1.12.4.min.js", "SUBRESOURCE"}, + if len(checked) != 6 { + t.Fatalf("checked = %v, want the target, the script and the four discovery paths", checked) + } + want := []struct { + url, reason string + result touchstonev1.CheckedLocation_Result + }{ + {site.URL + "/", "TARGET", touchstonev1.CheckedLocation_RESULT_FOUND}, + {site.URL + "/js/jquery-1.12.4.min.js", "SUBRESOURCE", touchstonev1.CheckedLocation_RESULT_FOUND}, + {site.URL + "/.well-known/security.txt", "DISCOVERY_PATH", touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, + {site.URL + "/security.txt", "DISCOVERY_PATH", touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, + {site.URL + "/.well-known/csaf/provider-metadata.json", "DISCOVERY_PATH", touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, + {site.URL + "/.well-known/tea", "DISCOVERY_PATH", touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, } for i, w := range want { - if checked[i].GetUrl() != w.url || checked[i].GetReason() != w.reason || - checked[i].GetResult() != touchstonev1.CheckedLocation_RESULT_FOUND { - t.Errorf("checked[%d] = %v, want %s %s FOUND", i, checked[i], w.reason, w.url) + if checked[i].GetUrl() != w.url || checked[i].GetReason() != w.reason || checked[i].GetResult() != w.result { + t.Errorf("checked[%d] = %v, want %s %s %v", i, checked[i], w.reason, w.url, w.result) } } if rep.GetRequestsUsed() != uint32(len(checked)) { @@ -158,9 +164,14 @@ func TestScannerRecordsScriptRefusedByBudget(t *testing.T) { t.Fatalf("Scan: %v", err) } checked := rep.GetChecked() - if len(checked) != 2 || checked[1].GetResult() != touchstonev1.CheckedLocation_RESULT_REFUSED { + if len(checked) < 2 || checked[1].GetResult() != touchstonev1.CheckedLocation_RESULT_REFUSED { t.Fatalf("checked = %v, want the script refused by the budget", checked) } + for _, c := range checked[1:] { + if c.GetResult() != touchstonev1.CheckedLocation_RESULT_REFUSED { + t.Errorf("%v: want every request after the target refused by the budget", c) + } + } if rep.GetRequestsUsed() != 1 { t.Errorf("requests_used = %d, want 1", rep.GetRequestsUsed()) } diff --git a/web/src/scanForm.test.ts b/web/src/scanForm.test.ts new file mode 100644 index 0000000..4ee6c1f --- /dev/null +++ b/web/src/scanForm.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { SCAN_FORM_FIELDS, buildScanRequest } from "./scanForm"; + +describe("scan form", () => { + it("has a URL field and an upload field for the reviewer's SBOM/VEX", () => { + const byId = Object.fromEntries(SCAN_FORM_FIELDS.map((f) => [f.id, f])); + expect(byId["sbom-url"]?.type).toBe("url"); + expect(byId["sbom-file"]?.type).toBe("file"); + expect(byId["url"]?.required).toBe(true); + }); + + it("sends sbomUrl and the uploaded file as base64 sbomDocument", async () => { + const file = new File(['{"bomFormat":"CycloneDX"}'], "sbom.json"); + const body = await buildScanRequest({ + url: " https://vendor.example ", + sbomUrl: "https://vendor.example/sbom.json", + sbomFile: file, + }); + expect(body).toEqual({ + url: "https://vendor.example", + sbomUrl: "https://vendor.example/sbom.json", + sbomDocument: btoa('{"bomFormat":"CycloneDX"}'), + }); + }); + + it("omits the optional fields when they are empty", async () => { + expect(await buildScanRequest({ url: "https://vendor.example", sbomUrl: "" })).toEqual({ + url: "https://vendor.example", + }); + }); + + it("requires the vendor URL", async () => { + await expect(buildScanRequest({ url: " ", sbomUrl: "" })).rejects.toThrow(); + }); +}); diff --git a/web/src/scanForm.ts b/web/src/scanForm.ts new file mode 100644 index 0000000..ead9d2c --- /dev/null +++ b/web/src/scanForm.ts @@ -0,0 +1,69 @@ +import { create, toJson, type JsonValue } from "@bufbuild/protobuf"; +import { ScanRequestSchema } from "./gen/touchstone/v1/scan_pb"; + +// The reviewer-supplied SBOM/VEX inputs (design decision D1): a vendor +// questionnaire answer is usually the only way to get a High-tier SBOM. +// An uploaded file is capped like a fetched response (2 MiB). +export const MAX_SBOM_BYTES = 2 << 20; + +export const SCAN_FORM_FIELDS = [ + { id: "url", label: "Vendor URL", type: "url", required: true }, + { id: "sbom-url", label: "SBOM/VEX URL (optional)", type: "url", required: false }, + { id: "sbom-file", label: "SBOM/VEX file (optional)", type: "file", required: false }, +] as const; + +export interface ScanFormValues { + url: string; + sbomUrl: string; + sbomFile?: File | undefined; +} + +// buildScanRequest returns the JSON body for POST /v1/scans. +export async function buildScanRequest(v: ScanFormValues): Promise { + const url = v.url.trim(); + if (url === "") throw new Error("Enter the vendor URL to scan."); + if (v.sbomFile && v.sbomFile.size > MAX_SBOM_BYTES) { + throw new Error(`The SBOM/VEX file is larger than ${MAX_SBOM_BYTES} bytes.`); + } + const msg = create(ScanRequestSchema, { + url, + sbomUrl: v.sbomUrl.trim(), + sbomDocument: v.sbomFile ? new Uint8Array(await v.sbomFile.arrayBuffer()) : new Uint8Array(), + }); + return toJson(ScanRequestSchema, msg); +} + +// renderScanForm builds the scan form: the vendor URL, plus a URL field +// and an upload field for a reviewer-supplied SBOM/VEX. +export function renderScanForm( + doc: Document, + onSubmit: (values: ScanFormValues) => void, +): HTMLFormElement { + const form = doc.createElement("form"); + for (const f of SCAN_FORM_FIELDS) { + const label = doc.createElement("label"); + label.htmlFor = f.id; + label.textContent = f.label; + const input = doc.createElement("input"); + input.id = f.id; + input.name = f.id; + input.type = f.type; + input.required = f.required; + if (f.type === "file") input.accept = ".json,application/json"; + form.append(label, input); + } + const button = doc.createElement("button"); + button.type = "submit"; + button.textContent = "Scan"; + form.append(button); + form.addEventListener("submit", (e) => { + e.preventDefault(); + const field = (id: string) => form.querySelector(`#${id}`); + onSubmit({ + url: field("url")?.value ?? "", + sbomUrl: field("sbom-url")?.value ?? "", + sbomFile: field("sbom-file")?.files?.[0], + }); + }); + return form; +}