diff --git a/README.md b/README.md index d583193..110ed40 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,10 @@ It never sends exploit or CVE-test payloads. Terms are defined in the [glossary](docs/glossary.md). **Status:** S0 (contract first). `go run ./cmd/touchstone scan https://example.com` prints a canned `ScanReport` from a stub service; it validates against -[`schema/scan-report.v1.json`](schema/scan-report.v1.json). The API contract +[`schema/scan-report.v1.json`](schema/scan-report.v1.json). Add `--format md` +to get the same report as Markdown for a vendor-review ticket: every +vendor string is escaped, and a report whose vulnerability lookup did not +complete says "lookup incomplete" instead of showing a count. The API contract lives in [`proto/touchstone/v1/scan.proto`](proto/touchstone/v1/scan.proto); run `make generate` (requires [buf](https://buf.build)) to regenerate the Go, TypeScript, OpenAPI and JSON Schema outputs under `gen/`, `web/src/gen/`, diff --git a/cmd/touchstone/main.go b/cmd/touchstone/main.go index 932be1b..b8fddd7 100644 --- a/cmd/touchstone/main.go +++ b/cmd/touchstone/main.go @@ -1,6 +1,7 @@ // Command touchstone scans a vendor URL and prints an evidence-graded -// ScanReport as protojson. In slice S0 the scan is served by the stub -// ScanService, so the report is canned. +// ScanReport as protojson (--format json, the default) or as Markdown +// (--format md). In slice S0 the scan is served by the stub ScanService, +// so the report is canned. package main import ( @@ -13,11 +14,12 @@ import ( touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" "github.com/FootprintAI/touchstone/internal/fetch" + "github.com/FootprintAI/touchstone/internal/report" "github.com/FootprintAI/touchstone/internal/service" "google.golang.org/protobuf/encoding/protojson" ) -const usage = "usage: touchstone scan [--allow-private] " +const usage = "usage: touchstone scan [--allow-private] [--format json|md] " // newService builds the ScanService for one CLI invocation. The fetch // options carry CLI-only settings such as --allow-private, which the @@ -39,15 +41,37 @@ func run(ctx context.Context, args []string, out io.Writer, newSvc newService) e fs := flag.NewFlagSet("scan", flag.ContinueOnError) 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)") if err := fs.Parse(args[1:]); err != nil || fs.NArg() != 1 { return errors.New(usage) } + render, err := renderer(*format) + if err != nil { + return err + } svc := newSvc(fetch.Options{AllowPrivate: *allowPrivate, Version: service.ToolVersion}) - report, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: fs.Arg(0)}) + rep, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: fs.Arg(0)}) if err != nil { return err } - b, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(report) + return render(out, rep) +} + +// renderer picks the output format. Unknown formats are rejected before +// the scan runs, so a typo never costs a scan. +func renderer(format string) (func(io.Writer, *touchstonev1.ScanReport) error, error) { + switch format { + case "json": + return writeJSON, nil + case "md", "markdown": + return report.WriteMarkdown, nil + default: + return nil, fmt.Errorf("unknown --format %q, want json or md\n%s", format, usage) + } +} + +func writeJSON(out io.Writer, rep *touchstonev1.ScanReport) error { + b, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(rep) if err != nil { return err } diff --git a/cmd/touchstone/main_test.go b/cmd/touchstone/main_test.go index 9e5f3d7..3bb0aa5 100644 --- a/cmd/touchstone/main_test.go +++ b/cmd/touchstone/main_test.go @@ -18,11 +18,12 @@ import ( "google.golang.org/protobuf/proto" ) -var update = flag.Bool("update", false, "rewrite testdata/report.golden.json") +var update = flag.Bool("update", false, "rewrite testdata/report.golden.json and testdata/report.golden.md") var ( - schemaPath = filepath.Join("..", "..", "schema", "scan-report.v1.json") - goldenPath = filepath.Join("..", "..", "testdata", "report.golden.json") + schemaPath = filepath.Join("..", "..", "schema", "scan-report.v1.json") + goldenPath = filepath.Join("..", "..", "testdata", "report.golden.json") + goldenMarkdown = filepath.Join("..", "..", "testdata", "report.golden.md") ) func fixedStub(fetch.Options) touchstonev1.ScanServiceServer { @@ -108,10 +109,65 @@ func TestScanPrintsReportValidAgainstSchema(t *testing.T) { } } +// TestScanMarkdownMatchesGolden renders the canned report with +// --format md and compares it to testdata/report.golden.md, the Markdown +// twin of report.golden.json. +func TestScanMarkdownMatchesGolden(t *testing.T) { + var out bytes.Buffer + if err := run(context.Background(), []string{"scan", "--format", "md", "https://example.com"}, &out, fixedStub); err != nil { + t.Fatalf("run: %v", err) + } + if *update { + if err := os.WriteFile(goldenMarkdown, out.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(goldenMarkdown) + if err != nil { + t.Fatalf("read golden (run go test ./cmd/touchstone -update): %v", err) + } + if out.String() != string(want) { + t.Errorf("Markdown differs from %s (run go test ./cmd/touchstone -update)\ngot:\n%s", goldenMarkdown, out.String()) + } + for _, s := range []string{ + "# Touchstone scan report", "- **Target:** https://example.com", + "- **Started:** 2026-10-22T00:00:00Z", "- **Tool version:** " + service.ToolVersion, + "- **Vulnerability data as of:** 2026-10-22T00:00:00Z", "> **Disclaimer.** " + service.Disclaimer, + } { + if !bytes.Contains(out.Bytes(), []byte(s)) { + t.Errorf("Markdown lacks %q:\n%s", s, out.String()) + } + } + if bytes.HasPrefix(out.Bytes(), []byte("{")) { + t.Error("--format md printed JSON") + } +} + +func TestScanFormatFlag(t *testing.T) { + for _, tt := range []struct { + args []string + isJSON bool + }{ + {[]string{"scan", "https://example.com"}, true}, + {[]string{"scan", "--format", "json", "https://example.com"}, true}, + {[]string{"scan", "--format=md", "https://example.com"}, false}, + {[]string{"scan", "--format", "markdown", "--allow-private", "http://127.0.0.1:8080"}, false}, + } { + var out bytes.Buffer + if err := run(context.Background(), tt.args, &out, fixedStub); err != nil { + t.Fatalf("run(%q): %v", tt.args, err) + } + if got := json.Valid(out.Bytes()); got != tt.isJSON { + t.Errorf("run(%q): output is JSON = %v, want %v\n%s", tt.args, got, tt.isJSON, out.String()) + } + } +} + func TestScanRejectsBadArgs(t *testing.T) { for _, args := range [][]string{ nil, {"scan"}, {"serve", "https://example.com"}, {"scan", "ftp://example.com"}, {"scan", "example.com"}, {"scan", "--allow-private"}, {"scan", "--no-such-flag", "https://example.com"}, {"scan", "https://a.example", "https://b.example"}, + {"scan", "--format", "xml", "https://example.com"}, {"scan", "--format", "https://example.com"}, } { if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub); err == nil { t.Errorf("run(%q) = nil error, want error", args) diff --git a/internal/report/markdown.go b/internal/report/markdown.go new file mode 100644 index 0000000..cd19327 --- /dev/null +++ b/internal/report/markdown.go @@ -0,0 +1,358 @@ +// Package report renders a touchstone.v1.ScanReport for people. JSON is +// protojson of the proto and needs no code here; Markdown is rendered by +// this package so a report can be pasted into a vendor-review ticket. +// +// Every string that originates outside Touchstone (the URL the user typed, +// vendor pages, SBOMs, repos, vulnerability feeds) passes through escape +// before it reaches the Markdown, so a hostile component name or evidence +// string cannot inject Markdown or HTML into the ticket it is pasted in. +package report + +import ( + "fmt" + "io" + "strconv" + "strings" + "time" + "unicode" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// Disclaimer is the fixed text carried by every ScanReport and printed in +// every Markdown report. It is a constant here, not something read from +// the report, so a report can never be rendered without it. +const Disclaimer = "Touchstone reports publicly observable evidence only. " + + "Findings are graded by confidence and are not proof that a vulnerability is exploitable. " + + "Low-confidence findings are inferred from passive fingerprints and are unverified." + +// unknown stands in for metadata the report does not carry, so the +// metadata lines are present in every rendering. +const unknown = "unknown" + +// Markdown renders r as a Markdown document. +func Markdown(r *touchstonev1.ScanReport) string { + var b strings.Builder + _ = WriteMarkdown(&b, r) + return b.String() +} + +// WriteMarkdown renders r as a Markdown document to w. +func WriteMarkdown(w io.Writer, r *touchstonev1.ScanReport) error { + m := &markdown{w: w, complete: r.GetLookupStatus() == touchstonev1.LookupStatus_LOOKUP_STATUS_COMPLETE} + m.header(r) + m.components(r) + m.conflicts(r.GetConflicts()) + m.checked(r.GetChecked()) + m.sources(r.GetSources()) + return m.err +} + +type markdown struct { + w io.Writer + err error + complete bool // lookup_status == COMPLETE; counts are rendered only then +} + +func (m *markdown) printf(format string, args ...any) { + if m.err != nil { + return + } + _, m.err = fmt.Fprintf(m.w, format, args...) +} + +func (m *markdown) header(r *touchstonev1.ScanReport) { + m.printf("# Touchstone scan report\n\n") + m.printf("- **Target:** %s\n", orUnknown(escape(r.GetTargetUrl()))) + m.printf("- **Scan status:** %s\n", scanStatus(r.GetScanStatus())) + m.printf("- **Started:** %s\n", timestamp(r.GetStartedAt())) + m.printf("- **Completed:** %s\n", timestamp(r.GetCompletedAt())) + m.printf("- **Tool version:** %s\n", orUnknown(escape(r.GetToolVersion()))) + m.printf("- **Vulnerability data as of:** %s\n", timestamp(r.GetVulnDataAsOf())) + m.printf("- **Vulnerability lookup:** %s\n", lookupStatus(r.GetLookupStatus())) + m.printf("- **Requests used:** %d of %d\n", r.GetRequestsUsed(), r.GetRequestBudget()) + m.printf("- **Schema version:** %s\n", orUnknown(escape(r.GetSchemaVersion()))) + m.printf("\n> **Disclaimer.** %s\n", Disclaimer) +} + +// tiers lists the confidence tiers in the order they are rendered, with +// the meaning each one has in the PRD. +var tiers = []struct { + confidence touchstonev1.Confidence + heading string +}{ + {touchstonev1.Confidence_CONFIDENCE_HIGH, "High confidence (declared by the vendor in an SBOM or VEX)"}, + {touchstonev1.Confidence_CONFIDENCE_MEDIUM, "Medium confidence (public source repo at its latest release; may differ from what is deployed)"}, + {touchstonev1.Confidence_CONFIDENCE_LOW, "Low confidence (passive fingerprint; inferred and unverified)"}, + {touchstonev1.Confidence_CONFIDENCE_UNSPECIFIED, "Unspecified confidence"}, +} + +func (m *markdown) components(r *touchstonev1.ScanReport) { + m.printf("\n## Components\n") + if r.GetScanStatus() == touchstonev1.ScanStatus_SCAN_STATUS_TARGET_UNREACHABLE { + m.printf("\nThe target could not be fetched, so no components were detected.\n") + return + } + if len(r.GetComponents()) == 0 { + m.printf("\nNo components were detected.\n") + return + } + for _, tier := range tiers { + var cs []*touchstonev1.Component + for _, c := range r.GetComponents() { + if c.GetConfidence() == tier.confidence { + cs = append(cs, c) + } + } + if len(cs) == 0 { + continue + } + m.printf("\n### %s\n", tier.heading) + for _, c := range cs { + m.component(c) + } + } +} + +func (m *markdown) component(c *touchstonev1.Component) { + name := orUnknown(escape(c.GetName())) + if v := escape(c.GetVersion()); v != "" { + name += " " + v + } + m.printf("\n#### %s\n\n", name) + if c.GetPurl() != "" { + m.printf("- **purl:** %s\n", escape(c.GetPurl())) + } + if c.GetCpe() != "" { + m.printf("- **CPE:** %s\n", escape(c.GetCpe())) + } + if len(c.GetCaveats()) > 0 { + m.printf("- **Caveats:** %s\n", escapeJoin(c.GetCaveats())) + } + m.printf("- **Evidence:**\n") + m.evidence(c.GetEvidence()) + m.vulnerabilities(c.GetVulnerabilities()) +} + +func (m *markdown) evidence(es []*touchstonev1.Evidence) { + if len(es) == 0 { + m.printf(" - none recorded\n") + return + } + for _, e := range es { + m.printf(" - %s: %s", enumLabel(e.GetKind().String(), "EVIDENCE_KIND_"), orUnknown(escape(e.GetLocation()))) + if e.GetDetail() != "" { + m.printf(" (%s)", escape(e.GetDetail())) + } + m.printf("\n") + } +} + +// vulnerabilities renders the vulnerability table. A count ("2 +// vulnerabilities", "no known vulnerabilities") appears only when the +// lookup completed; otherwise the heading says "lookup incomplete", so an +// empty table can never read as a clean bill of health. +func (m *markdown) vulnerabilities(vs []*touchstonev1.Vulnerability) { + var shown, suppressed []*touchstonev1.Vulnerability + for _, v := range vs { + if v.GetSuppressed() { + suppressed = append(suppressed, v) + } else { + shown = append(shown, v) + } + } + switch { + case !m.complete: + m.printf("- **Vulnerabilities:** lookup incomplete\n") + case len(shown) == 0: + m.printf("- **Vulnerabilities:** no known vulnerabilities\n") + default: + m.printf("- **Vulnerabilities:** %s\n", plural(len(shown), "vulnerability", "vulnerabilities")) + } + if len(shown) > 0 { + m.printf("\n| ID | Aliases | KEV | EPSS | CVSS | VEX | Summary |\n|---|---|---|---|---|---|---|\n") + for _, v := range shown { + m.printf("| %s | %s | %s | %s | %s | %s | %s |\n", + orUnknown(escape(v.GetId())), escapeJoin(v.GetAliases()), yesNo(v.GetKev()), + score(v.Epss), score(v.CvssBase), vexStatus(v), escape(v.GetSummary())) + } + } + if len(suppressed) > 0 { + if len(shown) > 0 { + m.printf("\n") // close the table before the next list + } + m.printf("- **Suppressed by VEX:**\n") + for _, v := range suppressed { + m.printf(" - %s: %s", orUnknown(escape(v.GetId())), vexStatus(v)) + if v.GetVexJustification() != "" { + m.printf(" (%s)", escape(v.GetVexJustification())) + } + m.printf("\n") + } + } +} + +func (m *markdown) conflicts(cs []*touchstonev1.Conflict) { + if len(cs) == 0 { + return + } + m.printf("\n## Version conflicts\n\n") + m.printf("Evidence tiers disagree on the version of these components; deployed may differ from declared.\n") + for _, c := range cs { + m.printf("\n- **%s:** %s\n", orUnknown(escape(c.GetComponentKey())), escapeJoin(c.GetVersions())) + m.evidence(c.GetEvidence()) + } +} + +func (m *markdown) checked(ls []*touchstonev1.CheckedLocation) { + m.printf("\n## Locations checked\n\n") + if len(ls) == 0 { + m.printf("No locations were recorded.\n") + return + } + m.printf("| URL | Reason | Result | Error |\n|---|---|---|---|\n") + for _, l := range ls { + m.printf("| %s | %s | %s | %s |\n", escape(l.GetUrl()), escape(l.GetReason()), + enumLabel(l.GetResult().String(), "RESULT_"), escape(l.GetError())) + } +} + +func (m *markdown) sources(ss []*touchstonev1.SourceStatus) { + m.printf("\n## Sources\n\n") + if len(ss) == 0 { + m.printf("No sources were recorded.\n") + return + } + m.printf("| Source | Status | Error |\n|---|---|---|\n") + for _, s := range ss { + st := "ok" + if !s.GetOk() { + st = "failed" + } + m.printf("| %s | %s | %s |\n", escape(s.GetSource()), st, escape(s.GetError())) + } +} + +// escape makes s safe to place inline in Markdown. HTML's & < > become +// entities so no tag survives; the characters that start Markdown +// constructs (emphasis, code spans, links, tables, strikethrough, +// backslash escapes) get a backslash; line breaks and other control +// characters, which could start a new block such as a heading, become a +// space. Callers never put an escaped string at the start of a line, so +// block-level markers (#, -, >, digits) need no escaping. +func escape(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '\\', '`', '*', '_', '[', ']', '|', '~': + b.WriteByte('\\') + b.WriteRune(r) + default: + if unicode.IsControl(r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) { + b.WriteByte(' ') + } else { + b.WriteRune(r) + } + } + } + return b.String() +} + +func escapeJoin(ss []string) string { + out := make([]string, len(ss)) + for i, s := range ss { + out[i] = escape(s) + } + return strings.Join(out, ", ") +} + +func orUnknown(s string) string { + if s == "" { + return unknown + } + return s +} + +func timestamp(ts *timestamppb.Timestamp) string { + if ts == nil || !ts.IsValid() { + return unknown + } + return ts.AsTime().UTC().Format(time.RFC3339) +} + +func scanStatus(s touchstonev1.ScanStatus) string { + switch s { + case touchstonev1.ScanStatus_SCAN_STATUS_OK: + return "ok" + case touchstonev1.ScanStatus_SCAN_STATUS_TARGET_UNREACHABLE: + return "target unreachable" + default: + return unknown + } +} + +// lookupStatus renders COMPLETE as "complete" and everything else as +// "lookup incomplete", with the reported status for the reader. +func lookupStatus(s touchstonev1.LookupStatus) string { + switch s { + case touchstonev1.LookupStatus_LOOKUP_STATUS_COMPLETE: + return "complete" + case touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL: + return "lookup incomplete (partial: at least one vulnerability source failed)" + case touchstonev1.LookupStatus_LOOKUP_STATUS_FAILED: + return "lookup incomplete (failed: no vulnerability source answered)" + default: + return "lookup incomplete (status unknown)" + } +} + +func vexStatus(v *touchstonev1.Vulnerability) string { + switch v.GetVexStatus() { + case touchstonev1.VexStatus_VEX_STATUS_AFFECTED: + return "affected" + case touchstonev1.VexStatus_VEX_STATUS_NOT_AFFECTED: + return "not affected" + case touchstonev1.VexStatus_VEX_STATUS_FIXED: + return "fixed" + case touchstonev1.VexStatus_VEX_STATUS_UNDER_INVESTIGATION: + return "under investigation" + default: + return "n/a" + } +} + +// enumLabel turns an enum value name such as EVIDENCE_KIND_SBOM_PUBLISHED +// into "sbom published". Enum names come from the proto, not from vendors. +func enumLabel(name, prefix string) string { + name = strings.TrimPrefix(name, prefix) + return strings.ToLower(strings.ReplaceAll(name, "_", " ")) +} + +func score(f *float64) string { + if f == nil { + return "n/a" + } + return strconv.FormatFloat(*f, 'g', -1, 64) +} + +func yesNo(b bool) string { + if b { + return "yes" + } + return "no" +} + +func plural(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return strconv.Itoa(n) + " " + many +} diff --git a/internal/report/markdown_test.go b/internal/report/markdown_test.go new file mode 100644 index 0000000..461067b --- /dev/null +++ b/internal/report/markdown_test.go @@ -0,0 +1,342 @@ +package report + +import ( + "bytes" + "flag" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var update = flag.Bool("update", false, "rewrite the golden files under testdata/") + +// fixtures are the reports rendered by the golden tests. Each one is +// built in code so the test is the only place that defines it; the +// rendered Markdown lives in testdata/.golden.md. +var fixtures = map[string]func() *touchstonev1.ScanReport{ + // complete: every tier, a VEX-suppressed vulnerability, a version + // conflict, checked locations and sources. lookup_status is COMPLETE, + // so counts are rendered. + "complete": func() *touchstonev1.ScanReport { + epss, cvss := 0.97, 9.8 + r := base() + r.Components = []*touchstonev1.Component{ + { + Name: "lodash", Version: "4.17.20", Purl: "pkg:npm/lodash@4.17.20", + Confidence: touchstonev1.Confidence_CONFIDENCE_HIGH, + Evidence: []*touchstonev1.Evidence{{ + Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_SBOM_PUBLISHED, Location: "https://vendor.example/.well-known/sbom.cdx.json", + }}, + Vulnerabilities: []*touchstonev1.Vulnerability{ + {Id: "CVE-2021-23337", Aliases: []string{"GHSA-35jh-r3h4-6jhm"}, Summary: "Command injection via template", Kev: true, Epss: &epss, CvssBase: &cvss}, + {Id: "CVE-2020-28500", Summary: "ReDoS in toNumber, trim and trimEnd", VexStatus: touchstonev1.VexStatus_VEX_STATUS_NOT_AFFECTED, VexJustification: "vulnerable_code_not_in_execute_path", Suppressed: true}, + }, + }, + { + Name: "express", Version: "4.18.2", Purl: "pkg:npm/express@4.18.2", + Confidence: touchstonev1.Confidence_CONFIDENCE_MEDIUM, + Evidence: []*touchstonev1.Evidence{{ + Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_REPO_LOCKFILE, Location: "github.com/vendor/app@v1.2.0:package-lock.json", + }}, + Caveats: []string{"DEFAULT_BRANCH_NOT_RELEASE"}, + }, + { + Name: "jquery", Version: "3.4.1", Purl: "pkg:npm/jquery@3.4.1", + Confidence: touchstonev1.Confidence_CONFIDENCE_LOW, + Evidence: []*touchstonev1.Evidence{{ + Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_ASSET, Location: "https://vendor.example/static/jquery-3.4.1.min.js", Detail: "jquery-3.4.1.min.js", + }}, + Vulnerabilities: []*touchstonev1.Vulnerability{{ + Id: "CVE-2020-11022", Aliases: []string{"GHSA-gxr4-xjj5-5px2"}, Summary: "Potential XSS vulnerability in jQuery.htmlPrefilter", + }}, + }, + { + Name: "Apache", Version: "2.4.38", Cpe: "cpe:2.3:a:apache:http_server:2.4.38:*:*:*:*:*:*:*", + Confidence: touchstonev1.Confidence_CONFIDENCE_LOW, + Evidence: []*touchstonev1.Evidence{{ + Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_HEADER, Location: "https://vendor.example/", Detail: "Server: Apache/2.4.38 (Debian)", + }}, + Caveats: []string{"MAY_BE_BACKPORTED"}, + }, + } + r.Conflicts = []*touchstonev1.Conflict{{ + ComponentKey: "pkg:npm/jquery", Versions: []string{"3.4.1", "3.7.1"}, + Evidence: []*touchstonev1.Evidence{ + {Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_ASSET, Location: "https://vendor.example/static/jquery-3.4.1.min.js", Detail: "jquery-3.4.1.min.js"}, + {Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_SBOM_PUBLISHED, Location: "https://vendor.example/.well-known/sbom.cdx.json", Detail: "pkg:npm/jquery@3.7.1"}, + }, + }} + r.Checked = []*touchstonev1.CheckedLocation{ + {Url: "https://vendor.example/", Reason: "TARGET", Result: touchstonev1.CheckedLocation_RESULT_FOUND}, + {Url: "https://vendor.example/.well-known/security.txt", Reason: "DISCOVERY_PATH", Result: touchstonev1.CheckedLocation_RESULT_NOT_FOUND}, + {Url: "https://vendor.example/static/jquery-3.4.1.min.js", Reason: "SUBRESOURCE", Result: touchstonev1.CheckedLocation_RESULT_FOUND}, + } + r.RequestsUsed = 3 + r.Sources = []*touchstonev1.SourceStatus{{Source: "osv", Ok: true}, {Source: "nvd", Ok: true}, {Source: "kev", Ok: true}, {Source: "epss", Ok: true}} + return r + }, + // partial: one vulnerability source failed. The report must say + // "lookup incomplete" and never print a vulnerability count, even + // for the component whose list is empty. + "partial": func() *touchstonev1.ScanReport { + r := base() + r.LookupStatus = touchstonev1.LookupStatus_LOOKUP_STATUS_PARTIAL + r.Components = []*touchstonev1.Component{ + { + Name: "jquery", Version: "3.4.1", Purl: "pkg:npm/jquery@3.4.1", + Confidence: touchstonev1.Confidence_CONFIDENCE_LOW, + Evidence: []*touchstonev1.Evidence{{Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_ASSET, Location: "https://vendor.example/static/jquery-3.4.1.min.js", Detail: "jquery-3.4.1.min.js"}}, + Vulnerabilities: []*touchstonev1.Vulnerability{{ + Id: "CVE-2020-11022", Summary: "Potential XSS vulnerability in jQuery.htmlPrefilter", + }}, + }, + { + Name: "Apache", Version: "2.4.38", Cpe: "cpe:2.3:a:apache:http_server:2.4.38:*:*:*:*:*:*:*", + Confidence: touchstonev1.Confidence_CONFIDENCE_LOW, + Evidence: []*touchstonev1.Evidence{{Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_HEADER, Location: "https://vendor.example/", Detail: "Server: Apache/2.4.38"}}, + Caveats: []string{"MAY_BE_BACKPORTED"}, + }, + } + r.RequestsUsed = 2 + r.Sources = []*touchstonev1.SourceStatus{{Source: "osv", Ok: true}, {Source: "nvd", Ok: false, Error: "Get \"https://services.nvd.nist.gov/rest/json/cves/2.0\": 503 Service Unavailable"}} + return r + }, + // failed: no vulnerability source answered and the vulnerability + // data date is unknown; the metadata lines are still all present. + "failed": func() *touchstonev1.ScanReport { + r := base() + r.LookupStatus = touchstonev1.LookupStatus_LOOKUP_STATUS_FAILED + r.VulnDataAsOf = nil + r.Components = []*touchstonev1.Component{{ + Name: "jquery", Version: "3.4.1", Purl: "pkg:npm/jquery@3.4.1", + Confidence: touchstonev1.Confidence_CONFIDENCE_LOW, + Evidence: []*touchstonev1.Evidence{{Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_ASSET, Location: "https://vendor.example/static/jquery-3.4.1.min.js", Detail: "jquery-3.4.1.min.js"}}, + }} + r.RequestsUsed = 2 + r.Sources = []*touchstonev1.SourceStatus{{Source: "osv", Ok: false, Error: "dial tcp: i/o timeout"}} + return r + }, + // unreachable: the target fetch failed, so there is nothing but + // metadata, the failed location and the disclaimer. + "unreachable": func() *touchstonev1.ScanReport { + r := base() + r.ScanStatus = touchstonev1.ScanStatus_SCAN_STATUS_TARGET_UNREACHABLE + r.LookupStatus = touchstonev1.LookupStatus_LOOKUP_STATUS_UNSPECIFIED + r.VulnDataAsOf = nil + r.Checked = []*touchstonev1.CheckedLocation{{Url: "https://vendor.example/", Reason: "TARGET", Result: touchstonev1.CheckedLocation_RESULT_ERROR, Error: "dial tcp: lookup vendor.example: no such host"}} + r.RequestsUsed = 1 + return r + }, + // injection: Markdown and HTML in every vendor-controlled string. + "injection": injection, +} + +func base() *touchstonev1.ScanReport { + ts := timestamppb.New(time.Date(2026, 10, 22, 9, 30, 0, 0, time.UTC)) + return &touchstonev1.ScanReport{ + SchemaVersion: "1", + TargetUrl: "https://vendor.example/", + ScanStatus: touchstonev1.ScanStatus_SCAN_STATUS_OK, + LookupStatus: touchstonev1.LookupStatus_LOOKUP_STATUS_COMPLETE, + StartedAt: ts, + CompletedAt: timestamppb.New(ts.AsTime().Add(12 * time.Second)), + ToolVersion: "0.0.0-dev", + VulnDataAsOf: timestamppb.New(time.Date(2026, 10, 21, 18, 0, 0, 0, time.UTC)), + RequestBudget: 50, + Disclaimer: Disclaimer, + } +} + +// injectionStrings are the raw vendor strings used by the injection +// fixture. None of them may appear verbatim in the rendered Markdown. +var injectionStrings = []string{ + "", + "[click me](javascript:alert(1))", + "**bold** _em_ ~~strike~~", + "| cell | ", + "`code` \\escape", + "\n# Injected heading\n\n", + "<b>entity</b> & tag", +} + +func injection() *touchstonev1.ScanReport { + r := base() + r.TargetUrl = "https://vendor.example/?q=" + injectionStrings[0] + r.ToolVersion = "0.0.0-dev" + injectionStrings[2] + r.Components = []*touchstonev1.Component{{ + Name: injectionStrings[0] + injectionStrings[1], + Version: injectionStrings[2], + Purl: "pkg:npm/" + injectionStrings[3], + Cpe: injectionStrings[4], + Confidence: touchstonev1.Confidence_CONFIDENCE_LOW, + Evidence: []*touchstonev1.Evidence{{ + Kind: touchstonev1.EvidenceKind_EVIDENCE_KIND_FINGERPRINT_ASSET, + Location: "https://vendor.example/" + injectionStrings[1], + Detail: injectionStrings[5], + }}, + Caveats: []string{injectionStrings[6]}, + Vulnerabilities: []*touchstonev1.Vulnerability{ + {Id: injectionStrings[3], Aliases: []string{injectionStrings[2]}, Summary: injectionStrings[0] + injectionStrings[5]}, + {Id: injectionStrings[1], Summary: injectionStrings[4], VexStatus: touchstonev1.VexStatus_VEX_STATUS_NOT_AFFECTED, VexJustification: injectionStrings[5], Suppressed: true}, + }, + }} + r.Conflicts = []*touchstonev1.Conflict{{ComponentKey: injectionStrings[2], Versions: []string{injectionStrings[3], injectionStrings[4]}}} + r.Checked = []*touchstonev1.CheckedLocation{{Url: injectionStrings[0], Reason: injectionStrings[3], Result: touchstonev1.CheckedLocation_RESULT_ERROR, Error: injectionStrings[5]}} + r.Sources = []*touchstonev1.SourceStatus{{Source: injectionStrings[6], Ok: false, Error: injectionStrings[0]}} + return r +} + +func TestMarkdownGolden(t *testing.T) { + for name, build := range fixtures { + t.Run(name, func(t *testing.T) { + got := Markdown(build()) + path := filepath.Join("testdata", name+".golden.md") + if *update { + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden (run go test ./internal/report -update): %v", err) + } + if got != string(want) { + t.Errorf("Markdown differs from %s (run go test ./internal/report -update)\ngot:\n%s", path, got) + } + }) + } +} + +func TestWriteMarkdownReturnsWriteError(t *testing.T) { + if err := WriteMarkdown(failingWriter{}, base()); err == nil { + t.Fatal("WriteMarkdown to a failing writer returned nil error") + } +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, os.ErrClosed } + +// TestMetadataAndDisclaimerAlwaysPresent checks every fixture, including +// an empty report, for the timestamp, tool version, vulnerability-data +// date and disclaimer lines. +func TestMetadataAndDisclaimerAlwaysPresent(t *testing.T) { + reports := map[string]*touchstonev1.ScanReport{"empty": {}, "nil": nil} + for name, build := range fixtures { + reports[name] = build() + } + for name, r := range reports { + got := Markdown(r) + for _, want := range []string{ + "- **Started:** ", "- **Completed:** ", "- **Tool version:** ", + "- **Vulnerability data as of:** ", "- **Vulnerability lookup:** ", + "> **Disclaimer.** " + Disclaimer + "\n", + } { + if !strings.Contains(got, want) { + t.Errorf("%s: rendering lacks %q:\n%s", name, want, got) + } + } + } + empty := Markdown(&touchstonev1.ScanReport{}) + for _, want := range []string{"- **Started:** unknown", "- **Tool version:** unknown", "- **Vulnerability data as of:** unknown"} { + if !strings.Contains(empty, want) { + t.Errorf("empty report lacks %q:\n%s", want, empty) + } + } +} + +// countPattern matches any rendered vulnerability count, including the +// zero case that must never look like a clean result. +var countPattern = regexp.MustCompile(`(?i)\b(\d+|no known|zero|none) (vulnerabilit|cve)`) + +func TestIncompleteLookupNeverRendersCount(t *testing.T) { + for _, name := range []string{"partial", "failed", "unreachable"} { + got := Markdown(fixtures[name]()) + if !strings.Contains(got, "- **Vulnerability lookup:** lookup incomplete") { + t.Errorf("%s: lookup line does not say \"lookup incomplete\":\n%s", name, got) + } + if strings.Contains(got, "**Vulnerability lookup:** complete") { + t.Errorf("%s: renders lookup as complete", name) + } + if m := countPattern.FindString(got); m != "" { + t.Errorf("%s: renders a vulnerability count %q with an incomplete lookup:\n%s", name, m, got) + } + if strings.Contains(got, "- **Vulnerabilities:** no known") || strings.Contains(got, "- **Vulnerabilities:** 1 ") { + t.Errorf("%s: per-component vulnerability line carries a count:\n%s", name, got) + } + } + for _, name := range []string{"partial", "failed"} { + if got := Markdown(fixtures[name]()); !strings.Contains(got, "- **Vulnerabilities:** lookup incomplete\n") { + t.Errorf("%s: component does not say \"lookup incomplete\":\n%s", name, got) + } + } + complete := Markdown(fixtures["complete"]()) + for _, want := range []string{"- **Vulnerability lookup:** complete\n", "- **Vulnerabilities:** 1 vulnerability\n", "- **Vulnerabilities:** no known vulnerabilities\n"} { + if !strings.Contains(complete, want) { + t.Errorf("complete: lacks %q:\n%s", want, complete) + } + } +} + +func TestInjectionIsEscaped(t *testing.T) { + got := Markdown(injection()) + for _, raw := range injectionStrings { + if strings.Contains(got, raw) { + t.Errorf("raw vendor string %q survives in the Markdown:\n%s", raw, got) + } + } + // Fragments that would still be live Markdown or HTML if they survived. + for _, raw := range []string{"", "\n# Injected", "| cell |"} { + if strings.Contains(got, raw) { + t.Errorf("fragment %q survives in the Markdown:\n%s", raw, got) + } + } + // The escaped forms the renderer must produce instead. + for _, want := range []string{"<script>alert('xss')</script>", `\[click me\](javascript:alert(1))`, `\*\*bold\*\* \_em\_ \~\~strike\~\~`, "\\`code\\` \\\\escape", `\| cell \| <img src=x onerror=alert(1)>`, "&lt;b&gt;entity&lt;/b&gt; &amp; <b>tag</b>", " # Injected heading <iframe"} { + if !strings.Contains(got, want) { + t.Errorf("escaped form %q missing from the Markdown:\n%s", want, got) + } + } + // Only the renderer's own blocks may start a line; a vendor string + // must never get to open a heading, quote or HTML block. + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(line, "<") || strings.HasPrefix(line, "# Injected") { + t.Errorf("vendor content starts a line: %q", line) + } + } +} + +func TestEscape(t *testing.T) { + for _, tt := range []struct{ in, want string }{ + {"plain 1.12.4", "plain 1.12.4"}, + {"&", "<b>&</b>"}, + {"*a* _b_ `c` ~d~ [e](f) g|h i\\j", `\*a\* \_b\_ \` + "`c\\`" + ` \~d\~ \[e\](f) g\|h i\\j`}, + {"line\r\nbreak\ttab\x00nul\u2028sep", "line break tab nul sep"}, + {"&", "&amp;"}, + {"", ""}, + } { + if got := escape(tt.in); got != tt.want { + t.Errorf("escape(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestMarkdownIsStable(t *testing.T) { + r := fixtures["complete"]() + var a, b bytes.Buffer + if err := WriteMarkdown(&a, r); err != nil { + t.Fatal(err) + } + if err := WriteMarkdown(&b, r); err != nil { + t.Fatal(err) + } + if a.String() != b.String() { + t.Error("two renderings of the same report differ") + } +} diff --git a/internal/report/testdata/complete.golden.md b/internal/report/testdata/complete.golden.md new file mode 100644 index 0000000..5fa1177 --- /dev/null +++ b/internal/report/testdata/complete.golden.md @@ -0,0 +1,87 @@ +# Touchstone scan report + +- **Target:** https://vendor.example/ +- **Scan status:** ok +- **Started:** 2026-10-22T09:30:00Z +- **Completed:** 2026-10-22T09:30:12Z +- **Tool version:** 0.0.0-dev +- **Vulnerability data as of:** 2026-10-21T18:00:00Z +- **Vulnerability lookup:** complete +- **Requests used:** 3 of 50 +- **Schema version:** 1 + +> **Disclaimer.** Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified. + +## Components + +### High confidence (declared by the vendor in an SBOM or VEX) + +#### lodash 4.17.20 + +- **purl:** pkg:npm/lodash@4.17.20 +- **Evidence:** + - sbom published: https://vendor.example/.well-known/sbom.cdx.json +- **Vulnerabilities:** 1 vulnerability + +| ID | Aliases | KEV | EPSS | CVSS | VEX | Summary | +|---|---|---|---|---|---|---| +| CVE-2021-23337 | GHSA-35jh-r3h4-6jhm | yes | 0.97 | 9.8 | n/a | Command injection via template | + +- **Suppressed by VEX:** + - CVE-2020-28500: not affected (vulnerable\_code\_not\_in\_execute\_path) + +### Medium confidence (public source repo at its latest release; may differ from what is deployed) + +#### express 4.18.2 + +- **purl:** pkg:npm/express@4.18.2 +- **Caveats:** DEFAULT\_BRANCH\_NOT\_RELEASE +- **Evidence:** + - repo lockfile: github.com/vendor/app@v1.2.0:package-lock.json +- **Vulnerabilities:** no known vulnerabilities + +### Low confidence (passive fingerprint; inferred and unverified) + +#### jquery 3.4.1 + +- **purl:** pkg:npm/jquery@3.4.1 +- **Evidence:** + - fingerprint asset: https://vendor.example/static/jquery-3.4.1.min.js (jquery-3.4.1.min.js) +- **Vulnerabilities:** 1 vulnerability + +| ID | Aliases | KEV | EPSS | CVSS | VEX | Summary | +|---|---|---|---|---|---|---| +| CVE-2020-11022 | GHSA-gxr4-xjj5-5px2 | no | n/a | n/a | n/a | Potential XSS vulnerability in jQuery.htmlPrefilter | + +#### Apache 2.4.38 + +- **CPE:** cpe:2.3:a:apache:http\_server:2.4.38:\*:\*:\*:\*:\*:\*:\* +- **Caveats:** MAY\_BE\_BACKPORTED +- **Evidence:** + - fingerprint header: https://vendor.example/ (Server: Apache/2.4.38 (Debian)) +- **Vulnerabilities:** no known vulnerabilities + +## Version conflicts + +Evidence tiers disagree on the version of these components; deployed may differ from declared. + +- **pkg:npm/jquery:** 3.4.1, 3.7.1 + - fingerprint asset: https://vendor.example/static/jquery-3.4.1.min.js (jquery-3.4.1.min.js) + - sbom published: https://vendor.example/.well-known/sbom.cdx.json (pkg:npm/jquery@3.7.1) + +## Locations checked + +| URL | Reason | Result | Error | +|---|---|---|---| +| https://vendor.example/ | TARGET | found | | +| https://vendor.example/.well-known/security.txt | DISCOVERY\_PATH | not found | | +| https://vendor.example/static/jquery-3.4.1.min.js | SUBRESOURCE | found | | + +## Sources + +| Source | Status | Error | +|---|---|---| +| osv | ok | | +| nvd | ok | | +| kev | ok | | +| epss | ok | | diff --git a/internal/report/testdata/failed.golden.md b/internal/report/testdata/failed.golden.md new file mode 100644 index 0000000..1f4d325 --- /dev/null +++ b/internal/report/testdata/failed.golden.md @@ -0,0 +1,34 @@ +# Touchstone scan report + +- **Target:** https://vendor.example/ +- **Scan status:** ok +- **Started:** 2026-10-22T09:30:00Z +- **Completed:** 2026-10-22T09:30:12Z +- **Tool version:** 0.0.0-dev +- **Vulnerability data as of:** unknown +- **Vulnerability lookup:** lookup incomplete (failed: no vulnerability source answered) +- **Requests used:** 2 of 50 +- **Schema version:** 1 + +> **Disclaimer.** Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified. + +## Components + +### Low confidence (passive fingerprint; inferred and unverified) + +#### jquery 3.4.1 + +- **purl:** pkg:npm/jquery@3.4.1 +- **Evidence:** + - fingerprint asset: https://vendor.example/static/jquery-3.4.1.min.js (jquery-3.4.1.min.js) +- **Vulnerabilities:** lookup incomplete + +## Locations checked + +No locations were recorded. + +## Sources + +| Source | Status | Error | +|---|---|---| +| osv | failed | dial tcp: i/o timeout | diff --git a/internal/report/testdata/injection.golden.md b/internal/report/testdata/injection.golden.md new file mode 100644 index 0000000..8049a16 --- /dev/null +++ b/internal/report/testdata/injection.golden.md @@ -0,0 +1,52 @@ +# Touchstone scan report + +- **Target:** https://vendor.example/?q=<script>alert('xss')</script> +- **Scan status:** ok +- **Started:** 2026-10-22T09:30:00Z +- **Completed:** 2026-10-22T09:30:12Z +- **Tool version:** 0.0.0-dev\*\*bold\*\* \_em\_ \~\~strike\~\~ +- **Vulnerability data as of:** 2026-10-21T18:00:00Z +- **Vulnerability lookup:** complete +- **Requests used:** 0 of 50 +- **Schema version:** 1 + +> **Disclaimer.** Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified. + +## Components + +### Low confidence (passive fingerprint; inferred and unverified) + +#### <script>alert('xss')</script>\[click me\](javascript:alert(1)) \*\*bold\*\* \_em\_ \~\~strike\~\~ + +- **purl:** pkg:npm/\| cell \| <img src=x onerror=alert(1)> +- **CPE:** \`code\` \\escape +- **Caveats:** &lt;b&gt;entity&lt;/b&gt; &amp; <b>tag</b> +- **Evidence:** + - fingerprint asset: https://vendor.example/\[click me\](javascript:alert(1)) ( # Injected heading <iframe src="https://evil.example"></iframe>) +- **Vulnerabilities:** 1 vulnerability + +| ID | Aliases | KEV | EPSS | CVSS | VEX | Summary | +|---|---|---|---|---|---|---| +| \| cell \| <img src=x onerror=alert(1)> | \*\*bold\*\* \_em\_ \~\~strike\~\~ | no | n/a | n/a | n/a | <script>alert('xss')</script> # Injected heading <iframe src="https://evil.example"></iframe> | + +- **Suppressed by VEX:** + - \[click me\](javascript:alert(1)): not affected ( # Injected heading <iframe src="https://evil.example"></iframe>) + +## Version conflicts + +Evidence tiers disagree on the version of these components; deployed may differ from declared. + +- **\*\*bold\*\* \_em\_ \~\~strike\~\~:** \| cell \| <img src=x onerror=alert(1)>, \`code\` \\escape + - none recorded + +## Locations checked + +| URL | Reason | Result | Error | +|---|---|---|---| +| <script>alert('xss')</script> | \| cell \| <img src=x onerror=alert(1)> | error | # Injected heading <iframe src="https://evil.example"></iframe> | + +## Sources + +| Source | Status | Error | +|---|---|---| +| &lt;b&gt;entity&lt;/b&gt; &amp; <b>tag</b> | failed | <script>alert('xss')</script> | diff --git a/internal/report/testdata/partial.golden.md b/internal/report/testdata/partial.golden.md new file mode 100644 index 0000000..64c0540 --- /dev/null +++ b/internal/report/testdata/partial.golden.md @@ -0,0 +1,47 @@ +# Touchstone scan report + +- **Target:** https://vendor.example/ +- **Scan status:** ok +- **Started:** 2026-10-22T09:30:00Z +- **Completed:** 2026-10-22T09:30:12Z +- **Tool version:** 0.0.0-dev +- **Vulnerability data as of:** 2026-10-21T18:00:00Z +- **Vulnerability lookup:** lookup incomplete (partial: at least one vulnerability source failed) +- **Requests used:** 2 of 50 +- **Schema version:** 1 + +> **Disclaimer.** Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified. + +## Components + +### Low confidence (passive fingerprint; inferred and unverified) + +#### jquery 3.4.1 + +- **purl:** pkg:npm/jquery@3.4.1 +- **Evidence:** + - fingerprint asset: https://vendor.example/static/jquery-3.4.1.min.js (jquery-3.4.1.min.js) +- **Vulnerabilities:** lookup incomplete + +| ID | Aliases | KEV | EPSS | CVSS | VEX | Summary | +|---|---|---|---|---|---|---| +| CVE-2020-11022 | | no | n/a | n/a | n/a | Potential XSS vulnerability in jQuery.htmlPrefilter | + +#### Apache 2.4.38 + +- **CPE:** cpe:2.3:a:apache:http\_server:2.4.38:\*:\*:\*:\*:\*:\*:\* +- **Caveats:** MAY\_BE\_BACKPORTED +- **Evidence:** + - fingerprint header: https://vendor.example/ (Server: Apache/2.4.38) +- **Vulnerabilities:** lookup incomplete + +## Locations checked + +No locations were recorded. + +## Sources + +| Source | Status | Error | +|---|---|---| +| osv | ok | | +| nvd | failed | Get "https://services.nvd.nist.gov/rest/json/cves/2.0": 503 Service Unavailable | diff --git a/internal/report/testdata/unreachable.golden.md b/internal/report/testdata/unreachable.golden.md new file mode 100644 index 0000000..4cfe451 --- /dev/null +++ b/internal/report/testdata/unreachable.golden.md @@ -0,0 +1,27 @@ +# Touchstone scan report + +- **Target:** https://vendor.example/ +- **Scan status:** target unreachable +- **Started:** 2026-10-22T09:30:00Z +- **Completed:** 2026-10-22T09:30:12Z +- **Tool version:** 0.0.0-dev +- **Vulnerability data as of:** unknown +- **Vulnerability lookup:** lookup incomplete (status unknown) +- **Requests used:** 1 of 50 +- **Schema version:** 1 + +> **Disclaimer.** Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified. + +## Components + +The target could not be fetched, so no components were detected. + +## Locations checked + +| URL | Reason | Result | Error | +|---|---|---|---| +| https://vendor.example/ | TARGET | error | dial tcp: lookup vendor.example: no such host | + +## Sources + +No sources were recorded. diff --git a/internal/service/scan.go b/internal/service/scan.go index 6642ad5..973a11d 100644 --- a/internal/service/scan.go +++ b/internal/service/scan.go @@ -10,6 +10,7 @@ import ( "time" touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1" + "github.com/FootprintAI/touchstone/internal/report" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" @@ -18,9 +19,9 @@ import ( // ToolVersion is reported in every ScanReport. const ToolVersion = "0.0.0-dev" -// Disclaimer is reported in every ScanReport. -const Disclaimer = "Touchstone reports publicly observable evidence only. " + - "Findings are graded by confidence and are not proof that a vulnerability is exploitable." +// Disclaimer is reported in every ScanReport. It is defined by the report +// package, which also prints it in every Markdown rendering. +const Disclaimer = report.Disclaimer const defaultRequestBudget = 50 diff --git a/testdata/report.golden.json b/testdata/report.golden.json index b018e5a..c189b4c 100644 --- a/testdata/report.golden.json +++ b/testdata/report.golden.json @@ -43,5 +43,5 @@ "ok": true } ], - "disclaimer": "Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable." + "disclaimer": "Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified." } diff --git a/testdata/report.golden.md b/testdata/report.golden.md new file mode 100644 index 0000000..e2b9a83 --- /dev/null +++ b/testdata/report.golden.md @@ -0,0 +1,39 @@ +# Touchstone scan report + +- **Target:** https://example.com +- **Scan status:** ok +- **Started:** 2026-10-22T00:00:00Z +- **Completed:** 2026-10-22T00:00:00Z +- **Tool version:** 0.0.0-dev +- **Vulnerability data as of:** 2026-10-22T00:00:00Z +- **Vulnerability lookup:** complete +- **Requests used:** 0 of 50 +- **Schema version:** 1 + +> **Disclaimer.** Touchstone reports publicly observable evidence only. Findings are graded by confidence and are not proof that a vulnerability is exploitable. Low-confidence findings are inferred from passive fingerprints and are unverified. + +## Components + +### Low confidence (passive fingerprint; inferred and unverified) + +#### jquery 1.12.4 + +- **purl:** pkg:npm/jquery@1.12.4 +- **Caveats:** CANNED\_STUB\_REPORT +- **Evidence:** + - fingerprint asset: https://example.com (jquery-1.12.4.min.js) +- **Vulnerabilities:** 1 vulnerability + +| ID | Aliases | KEV | EPSS | CVSS | VEX | Summary | +|---|---|---|---|---|---|---| +| CVE-2020-11022 | GHSA-gxr4-xjj5-5px2 | no | 0.0123 | 6.1 | n/a | Potential XSS vulnerability in jQuery.htmlPrefilter | + +## Locations checked + +No locations were recorded. + +## Sources + +| Source | Status | Error | +|---|---|---| +| stub | ok | |