Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
45eb07a
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
18533f8
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
6dbea49
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
d9da4e8
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
faa0b89
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
6bf6229
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
38b72b5
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
b702499
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
c19bf9d
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
0f993bf
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
ef9db82
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
f6f16f6
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
e1f2818
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
1b073bf
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
a32a44b
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
3833439
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
3891726
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
49b87ae
feat(vuln): NVD CPE lookup, KEV/EPSS enrichment, ranking, lookup_stat…
containarium-ci[bot] Oct 24, 2026
4eb5888
feat(vuln): NVD CPE fallback, KEV and EPSS ranking, rebased on serve …
containarium-ci[bot] Oct 30, 2026
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
9 changes: 9 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.git
.github
node_modules
**/node_modules
web/.next
web/next-env.d.ts
web/out/*
!web/out/.gitkeep
test/e2e/out
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
node_modules/
# next build output and its generated type shim; web/out is embedded at build time
web/.next/
web/next-env.d.ts
web/out/*
!web/out/.gitkeep
# e2e output (test/e2e/run.sh): reports, access log, compose logs
test/e2e/out/
28 changes: 28 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# One image with two commands: `touchstone serve` (the page and POST /v1/scans)
# and `touchstone scan <url>` (the CLI). See docs/architecture/touchstone.md,
# "Deployment shape".

# The page is embedded at build time (web/embed.go), so it is built first.
FROM node:20-alpine AS web
RUN apk add --no-cache make
WORKDIR /src
COPY Makefile ./
COPY web ./web
RUN make web-build

FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /src/web/out/ ./web/out/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/touchstone ./cmd/touchstone

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/touchstone /touchstone
# serve listens on all interfaces inside the container; the default is loopback.
ENV TOUCHSTONE_ADDR=0.0.0.0:8080
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/touchstone"]
CMD ["serve"]
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: generate lint breaking test web-test
.PHONY: generate lint breaking test web-test web-build

# Requires buf (https://buf.build): regenerates gen/, web/src/gen/,
# openapi/ and schema/ from proto/touchstone/v1/scan.proto, using the
Expand All @@ -22,3 +22,8 @@ test:

web-test:
cd web && npm install --no-package-lock && npx tsc --noEmit && npx vitest run

# Exports the page to web/out, which web/embed.go embeds into touchstone serve.
# Build it before `go build ./cmd/touchstone`.
web-build:
cd web && npm install --no-package-lock && npx next build
7 changes: 7 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Touchstone
Copyright FootprintAI

This product includes signature data derived from Retire.js
(https://github.com/RetireJS/retire.js), jsrepository-v4.json,
licensed under the Apache License, Version 2.0
(http://www.apache.org/licenses/LICENSE-2.0).
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ stale. `make test` and `make web-test` run the Go and web suites. See the design
[`docs/architecture/touchstone.md`](docs/architecture/touchstone.md) and
the PRD at [`docs/product/touchstone.md`](docs/product/touchstone.md).

### Web page: `touchstone serve`

`touchstone serve` runs the web page and `POST /v1/scans` on one port.

```sh
make web-build # exports the page to web/out
go build ./cmd/touchstone
./touchstone serve --addr 127.0.0.1:8080
```

- `--addr host:port` sets the listen address. The default is `127.0.0.1:8080`.
- `TOUCHSTONE_ADDR` sets the same address from the environment. The flag wins when both are set.
- Run `make web-build` before `go build`. The page is embedded at build time, so a binary built
without it serves 404 at `/`.
- The server has no authentication. When the address is not loopback (for example `0.0.0.0:8080`
or `:8080`), `serve` prints a warning on stderr. Bind to `127.0.0.1` unless you mean to expose it.

## License

Apache 2.0, see [LICENSE](LICENSE).
22 changes: 18 additions & 4 deletions cmd/touchstone/main.go
Original file line number Diff line number Diff line change
@@ -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).
// (--format md), or serves the web page and POST /v1/scans (serve, slice
// S9). The scan fetches the page and its scripts passively and looks
// components up in OSV (by purl) and NVD (by CPE), enriched with KEV and
// EPSS.
package main

import (
Expand All @@ -20,7 +22,7 @@ import (
"google.golang.org/protobuf/encoding/protojson"
)

const usage = "usage: touchstone scan [--allow-private] [--format json|md] <url>"
const usage = "usage: touchstone scan [--allow-private] [--format json|md] <url>\n touchstone serve [--addr host:port]"

// newService builds the ScanService for one CLI invocation. The fetch
// options carry CLI-only settings such as --allow-private, which the
Expand All @@ -29,7 +31,16 @@ type newService func(fetch.Options) touchstonev1.ScanServiceServer

func main() {
newSvc := func(o fetch.Options) touchstonev1.ScanServiceServer {
return &service.Scanner{Fetch: o, Vulns: vuln.NewOSV()}
// NVD_API_KEY is optional; without it NVD lookups are capped.
nvdKey := os.Getenv("NVD_API_KEY")
return &service.Scanner{
Fetch: o,
Vulns: vuln.NewOSV(),
CPE: vuln.NewNVD(nvdKey),
CPEMax: vuln.NVDMaxLookups(nvdKey),
KEV: vuln.NewKEV(),
EPSS: vuln.NewEPSS(),
}
}
if err := run(context.Background(), os.Args[1:], os.Stdout, newSvc); err != nil {
fmt.Fprintln(os.Stderr, "touchstone:", err)
Expand All @@ -38,6 +49,9 @@ func main() {
}

func run(ctx context.Context, args []string, out io.Writer, newSvc newService) error {
if len(args) > 0 && args[0] == "serve" {
return serve(ctx, args[1:], out, os.Stderr, newSvc)
}
if len(args) == 0 || args[0] != "scan" {
return errors.New(usage)
}
Expand Down
119 changes: 119 additions & 0 deletions cmd/touchstone/serve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package main

import (
"context"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
"time"

touchstonev1 "github.com/FootprintAI/touchstone/gen/go/touchstone/v1"
"github.com/FootprintAI/touchstone/internal/fetch"
"github.com/FootprintAI/touchstone/internal/service"
"github.com/FootprintAI/touchstone/web"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/protobuf/encoding/protojson"
)

const (
defaultAddr = "127.0.0.1:8080"

// scanTimeout bounds one scan request end to end.
scanTimeout = 180 * time.Second
)

// serve runs the embedded page and POST /v1/scans on one port. It has no
// --allow-private: the fetch options carry only the tool version, so the
// server always refuses loopback and private targets. It has no
// authentication either, so a non-loopback address gets a warning on errOut.
func serve(ctx context.Context, args []string, out, errOut io.Writer, newSvc newService) error {
flags := flag.NewFlagSet("serve", flag.ContinueOnError)
flags.SetOutput(io.Discard)
addr := flags.String("addr", envOr("TOUCHSTONE_ADDR", defaultAddr), "listen address (env TOUCHSTONE_ADDR)")
if err := flags.Parse(args); err != nil || flags.NArg() != 0 {
return errors.New(usage)
}
handler, err := newHandler(ctx, newSvc(fetch.Options{Version: service.ToolVersion}), web.Page())
if err != nil {
return err
}
ln, err := net.Listen("tcp", *addr)
if err != nil {
return err
}
if warning := nonLoopbackWarning(*addr); warning != "" {
fmt.Fprint(errOut, warning)
}
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
WriteTimeout: scanTimeout + 10*time.Second,
}
fmt.Fprintf(out, "touchstone: listening on http://%s\n", ln.Addr())

errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
select {
case err := <-errc:
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
}

// newHandler routes /v1/ to the ScanService through grpc-gateway and
// everything else to the static page.
func newHandler(ctx context.Context, svc touchstonev1.ScanServiceServer, page fs.FS) (http.Handler, error) {
// Default protojson options, so the REST body has the same shape as the
// CLI's JSON that the page decodes with the generated fromJson.
gw := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{},
UnmarshalOptions: protojson.UnmarshalOptions{DiscardUnknown: true},
}))
if err := touchstonev1.RegisterScanServiceHandlerServer(ctx, gw, svc); err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("/v1/", withTimeout(gw, scanTimeout))
mux.Handle("/", http.FileServerFS(page))
return mux, nil
}

func withTimeout(next http.Handler, d time.Duration) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// nonLoopbackWarning returns the stderr warning for an address that other
// hosts can reach, or "" for a loopback address. An empty host such as ":8080"
// listens on every interface, so it warns.
func nonLoopbackWarning(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return "" // net.Listen reports the malformed address
}
if host == "localhost" {
return ""
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return ""
}
return fmt.Sprintf("touchstone: warning: serving on %s, which other hosts can reach; the server has no authentication. Use a loopback address such as 127.0.0.1 unless that is intended.\n", addr)
}

func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Loading