Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`,
Expand Down
34 changes: 29 additions & 5 deletions cmd/touchstone/main.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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] <url>"
const usage = "usage: touchstone scan [--allow-private] [--format json|md] <url>"

// newService builds the ScanService for one CLI invocation. The fetch
// options carry CLI-only settings such as --allow-private, which the
Expand All @@ -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
}
Expand Down
62 changes: 59 additions & 3 deletions cmd/touchstone/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading