Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: ci

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
- run: go run honnef.co/go/tools/cmd/staticcheck@2026.2.2 ./...
- run: go test ./...

proto:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: bufbuild/buf-action@v1
with:
setup_only: true
version: 1.75.0
- run: buf lint
- name: buf breaking
run: |
if git cat-file -e origin/main:buf.lock 2>/dev/null; then
buf breaking --against '.git#branch=origin/main'
else
echo "main has no buf module yet; nothing to compare against"
fi
- name: generated code is current
run: |
make generate
git add -A gen web/src/gen openapi schema
git diff --cached --exit-code -- gen web/src/gen openapi schema

web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
# package.json pins exact versions; no lockfile is committed yet.
- run: npm install --no-package-lock
- run: npx tsc --noEmit
- run: npx vitest run
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
24 changes: 24 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.PHONY: generate lint breaking test web-test

# Requires buf (https://buf.build): regenerates gen/, web/src/gen/,
# openapi/ and schema/ from proto/touchstone/v1/scan.proto, using the
# googleapis version pinned in buf.lock (`buf dep update` to bump it).
# The jsonschema plugin names its output after the message, so the
# published ScanReport schema is moved to its stable, documented name.
generate:
buf generate
mv schema/touchstone.v1.ScanReport.jsonschema.bundle.json schema/scan-report.v1.json

lint:
buf lint

breaking:
buf breaking --against '.git#branch=main'

test:
go vet ./...
staticcheck ./...
go test ./...

web-test:
cd web && npm install --no-package-lock && npx tsc --noEmit && npx vitest run
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ labels every finding with how sure it is:
would send, plus well-known discovery paths such as `/.well-known/security.txt`.
It never sends exploit or CVE-test payloads.

**Status:** planning. Nothing is implemented yet. See the PRD at
[`docs/product/touchstone.md`](docs/product/touchstone.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
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/`,
`openapi/` and `schema/`. Generated code is committed and CI fails if it is
stale. `make test` and `make web-test` run the Go and web suites. See the design at
[`docs/architecture/touchstone.md`](docs/architecture/touchstone.md) and
the PRD at [`docs/product/touchstone.md`](docs/product/touchstone.md).

## License

Expand Down
41 changes: 41 additions & 0 deletions buf.gen.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Regenerate with `make generate` (wraps `buf generate`). Output under
# gen/, web/src/gen/, openapi/ and schema/ is committed and never hand
# edited; CI fails if `buf generate` would change it
# (.github/workflows/ci.yml, job "proto"). Plugin versions are pinned so
# that check only fails on a real contract change; bump them together
# with the matching Go and npm runtime versions.
version: v2
# Wipe each output directory first so a removed message can't leave a
# stale file behind for the CI diff check to miss.
clean: true
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.12
out: gen/go
opt: paths=source_relative
- remote: buf.build/grpc/go:v1.6.2
out: gen/go
opt: paths=source_relative
- remote: buf.build/grpc-ecosystem/gateway:v2.32.0
out: gen/go
opt: paths=source_relative
- remote: buf.build/grpc-ecosystem/openapiv2:v2.32.0
out: openapi
opt:
- output_format=json
- allow_merge=true
- merge_file_name=touchstone_v1
# include_imports also generates google/api/*_pb.ts, which scan_pb.ts
# imports for the http annotation (well-known types come from
# @bufbuild/protobuf/wkt instead).
- remote: buf.build/bufbuild/es:v2.16.0
out: web/src/gen
include_imports: true
opt: target=ts
# Only ScanReport and the messages it references get a schema;
# `make generate` renames ScanReport's bundle (which inlines the rest)
# to schema/scan-report.v1.json, the published schema.
- remote: buf.build/bufbuild/protoschema-jsonschema:v0.6.0
out: schema
opt: target=json-bundle
types:
- touchstone.v1.ScanReport
6 changes: 6 additions & 0 deletions buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Generated by buf. DO NOT EDIT.
version: v2
deps:
- name: buf.build/googleapis/googleapis
commit: 8ac61b9e502343289c8c42b17aa5b245
digest: b5:0318e73c2dec49c5742f0c119642a5cb7fcd9e2d5200a2b478bc1efd93f7aaa06d5431f5e1e5713095101fc990b026a806fa429af80432ec8d1ceefbe07bb817
16 changes: 16 additions & 0 deletions buf.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
version: v2
modules:
- path: proto
deps:
- buf.build/googleapis/googleapis
lint:
use:
- STANDARD
except:
# The design's Contracts section names the RPC response ScanReport,
# not ScanResponse, so this repo's rpc intentionally breaks the
# standard-name convention.
- RPC_RESPONSE_STANDARD_NAME
breaking:
use:
- FILE
41 changes: 41 additions & 0 deletions cmd/touchstone/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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.
package main

import (
"context"
"errors"
"fmt"
"io"
"os"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/FootprintAI/touchstone/internal/service"
"google.golang.org/protobuf/encoding/protojson"
)

const usage = "usage: touchstone scan <url>"

func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, &service.Stub{}); err != nil {
fmt.Fprintln(os.Stderr, "touchstone:", err)
os.Exit(2)
}
}

func run(ctx context.Context, args []string, out io.Writer, svc touchstonev1.ScanServiceServer) error {
if len(args) != 2 || args[0] != "scan" {
return errors.New(usage)
}
report, err := svc.Scan(ctx, &touchstonev1.ScanRequest{Url: args[1]})
if err != nil {
return err
}
b, err := protojson.MarshalOptions{Multiline: true, Indent: " "}.Marshal(report)
if err != nil {
return err
}
_, err = fmt.Fprintln(out, string(b))
return err
}
94 changes: 94 additions & 0 deletions cmd/touchstone/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package main

import (
"bytes"
"context"
"encoding/json"
"flag"
"os"
"path/filepath"
"testing"
"time"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/FootprintAI/touchstone/internal/service"
"github.com/santhosh-tekuri/jsonschema/v6"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)

var update = flag.Bool("update", false, "rewrite testdata/report.golden.json")

var (
schemaPath = filepath.Join("..", "..", "schema", "scan-report.v1.json")
goldenPath = filepath.Join("..", "..", "testdata", "report.golden.json")
)

func fixedStub() *service.Stub {
return &service.Stub{Now: func() time.Time { return time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC) }}
}

func TestScanPrintsReportValidAgainstSchema(t *testing.T) {
var out bytes.Buffer
if err := run(context.Background(), []string{"scan", "https://example.com"}, &out, fixedStub()); err != nil {
t.Fatalf("run: %v", err)
}

c := jsonschema.NewCompiler()
sch, err := c.Compile(schemaPath)
if err != nil {
t.Fatalf("compile %s: %v", schemaPath, err)
}
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(out.Bytes()))
if err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out.String())
}
if err := sch.Validate(doc); err != nil {
t.Fatalf("output does not validate against %s: %v\n%s", schemaPath, err, out.String())
}
if err := sch.Validate(map[string]any{"scanStatus": "NOT_A_STATUS"}); err == nil {
t.Fatal("schema accepted an invalid report; validation is not effective")
}

var got touchstonev1.ScanReport
if err := protojson.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("protojson.Unmarshal: %v", err)
}
if got.GetTargetUrl() != "https://example.com" {
t.Errorf("target_url = %q, want https://example.com", got.GetTargetUrl())
}
if got.GetSchemaVersion() != "1" {
t.Errorf("schema_version = %q, want 1", got.GetSchemaVersion())
}

// testdata/report.golden.json is also decoded by web/src/report.test.ts,
// so drift between the Go and TS sides of the contract fails a test.
if *update {
var indented bytes.Buffer
if err := json.Indent(&indented, out.Bytes(), "", " "); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(goldenPath, indented.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
golden, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run go test ./cmd/touchstone -update): %v", err)
}
var want touchstonev1.ScanReport
if err := protojson.Unmarshal(golden, &want); err != nil {
t.Fatalf("golden: %v", err)
}
if !proto.Equal(&got, &want) {
t.Errorf("report differs from %s (run go test ./cmd/touchstone -update)\ngot:\n%s", goldenPath, out.String())
}
}

func TestScanRejectsBadArgs(t *testing.T) {
for _, args := range [][]string{nil, {"scan"}, {"serve", "https://example.com"}, {"scan", "ftp://example.com"}, {"scan", "example.com"}} {
if err := run(context.Background(), args, &bytes.Buffer{}, fixedStub()); err == nil {
t.Errorf("run(%q) = nil error, want error", args)
}
}
}
Loading
Loading