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
7 changes: 7 additions & 0 deletions .github/taskgate-allow.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Workflow steps that are not one task, each with why (go tool taskgate -allow).
#
# Task is installed from its pin in versions.yaml, so the step that reads the pin runs before there
# is a Task. Task as a go.mod tool would have none, but go-tools is a library: its tools' modules
# would be in the module graph of every repository that imports it.
.github/workflows/ci.yml/gate/Task's pin # read before Task is installed
.github/workflows/pages.yml/build/Task's pin # read before Task is installed
38 changes: 6 additions & 32 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# The gate on every change: `task lint` and `task test`, the targets a developer runs, and not a
# list of steps here that goes out of step with them.
# The gate on every change: `task lint`, `task test` and `task mutate`, the targets a developer
# runs, and not a list of steps here that goes out of step with them - taskgate holds every step
# to one task.
name: CI

on:
Expand Down Expand Up @@ -52,24 +53,7 @@ jobs:
# always(): a failed suite is when the per-function numbers are wanted.
- name: Coverage summary
if: always()
run: |
if [ ! -f coverage.out ]; then
echo "coverage.out was not produced; the tests may have failed before coverage ran." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
{
echo "## Go coverage"
echo
echo "**$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}')** total"
echo
echo '<details><summary>Function detail</summary>'
echo
echo '```'
go tool cover -func=coverage.out
echo '```'
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"
go tool cover -html=coverage.out -o coverage.html
run: task coverage

- name: Upload Go coverage HTML
if: always()
Expand All @@ -81,18 +65,8 @@ jobs:
if-no-files-found: ignore

# The tests are green; this asks what they would have refused. On a pull request the diff is
# against what it merges into, on a push against what was there before.
# against what it merges into, on a push against what was there before (task mutate says how).
- name: What this change broke, and whether a test noticed
env:
EVENT: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [ "$EVENT" = "pull_request" ]; then
base="origin/$BASE_REF"
else
base="$BEFORE"
fi
git rev-parse --verify "$base^{commit}" >/dev/null 2>&1 || base=HEAD~1
task mutate BASE="$base"
run: task mutate
30 changes: 19 additions & 11 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
name: Pages

# Builds the documentation site (site/) on every change to it, and publishes main to GitHub
# Pages. The mutate page's example is a test's testdata, so a change there rebuilds it too.
# Builds the documentation site (site/) on every change to it, with `task site` as a developer
# does, and publishes main to GitHub Pages. The mutate page's example is a test's testdata, so a
# change there rebuilds it too, and the firewall's pins change how it installs.

on:
push:
branches: [main]
paths: ['site/**', 'cmd/mutate/testdata/example/**', '.github/workflows/pages.yml']
paths: ['site/**', 'cmd/mutate/testdata/example/**', 'cmd/sfw/**', 'internal/sfw/**', 'Taskfile.yml', '.github/workflows/pages.yml']
pull_request:
paths: ['site/**', 'cmd/mutate/testdata/example/**', '.github/workflows/pages.yml']
paths: ['site/**', 'cmd/mutate/testdata/example/**', 'cmd/sfw/**', 'internal/sfw/**', 'Taskfile.yml', '.github/workflows/pages.yml']
workflow_dispatch:

permissions:
Expand All @@ -21,24 +22,31 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Every commit, so each page's "Last updated" is its own last change, not the checkout's.
fetch-depth: 0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
# Task is pinned in versions.yaml like every other input: TASK_VERSION and TASK_SHA256.
- name: Task's pin
run: go run ./cmd/versions env task >> "$GITHUB_ENV"
- uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
with:
version: ${{ env.TASK_VERSION }}
checksum: ${{ env.TASK_SHA256 }}
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
package_json_file: site/package.json
# No store cache: a package restored from one is never asked of the registry, so the
# firewall, which stands between the two, would not see it.
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
cache: pnpm
cache-dependency-path: site/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm build
# What a developer runs: the install through Socket Firewall, then the build.
- run: task site
- if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ their `go.mod`; each fails CI with a specific finding and says how to resolve it
| [`refs`](https://spin-stack.github.io/go-tools/refs/) | paths, tasks and packages that AGENTS.md or CLAUDE.md names and the tree no longer has |
| [`vulngate`](https://spin-stack.github.io/go-tools/vulngate/) | vulnerabilities the code calls and that have a fix |
| [`versions`](https://spin-stack.github.io/go-tools/versions/) | pins written outside `versions.yaml`, and pins behind their upstream |
| [`sfw`](https://spin-stack.github.io/go-tools/sfw/) | npm, npx or yarn, and a pnpm install that does not go through Socket Firewall |
| [`taskgate`](https://spin-stack.github.io/go-tools/taskgate/) | a workflow step that is not one `task`, so CI and a developer run the same thing |
| [`provenance`, `sbom`, `attestgate`, `trustroot`](https://spin-stack.github.io/go-tools/supply-chain/) | releases published without SLSA provenance or an SBOM |

```sh
Expand Down
68 changes: 53 additions & 15 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
#
# task test the Go tests, under the race detector, held to a coverage floor
# task lint golangci-lint with spin's rules, at spin's version, this repository's own
# gates run on itself: ctxlife, testquality, refs, and actionlint over .github/
# gates run on itself: ctxlife, testquality, refs, sfw, taskgate, and actionlint
# task mutate break what the change against BASE (origin/main) touched, and ask the tests
# task coverage coverage.out's numbers, to the terminal or the workflow run's summary
# task versions which pins in versions.yaml are behind; task bump NAME=... moves one
# task site the documentation site under site/, built into site/dist

Expand Down Expand Up @@ -54,6 +55,8 @@ tasks:
- go run ./cmd/testquality ./...
- go run ./cmd/refs
- go run ./cmd/mutate -stale-exempts
- go run ./cmd/sfw check
- go run ./cmd/taskgate -allow .github/taskgate-allow.txt
# A vulnerability the code calls and that has a fix fails, the standard library's too: the
# answer is the upgrade. It asks vuln.go.dev, so lint needs the network.
- go run ./cmd/vulngate ./...
Expand All @@ -75,34 +78,69 @@ tasks:
fi

mutate:
desc: Break what the change against BASE touched and fail on an edit no test refuses (BASE=origin/main).
vars:
BASE: '{{.BASE | default "origin/main"}}'
desc: Break what the change against BASE touched and fail on an edit no test refuses (BASE=origin/main; in CI, what the run's event diffs against).
cmds:
- go run ./cmd/mutate -base {{.BASE}} -max 200
# In a workflow, the base is the event's: a pull request's is what it merges into, a push's
# what the branch was before it (BEFORE, which the workflow hands in), or the commit before
# when that is not in the clone - a branch's first push.
- |
base="{{.BASE}}"
if [ -z "$base" ] && [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then
base="origin/$GITHUB_BASE_REF"
elif [ -z "$base" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then
base="${BEFORE:-}"
git rev-parse --verify -q "$base^{commit}" >/dev/null || base=HEAD~1
fi
go run ./cmd/mutate -base "${base:-origin/main}" -max 200

coverage:
desc: Write coverage.out's total and per-function numbers to the run's summary ($GITHUB_STEP_SUMMARY, or the terminal) and coverage.html.
cmds:
- |
out="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
if [ ! -f coverage.out ]; then
echo "coverage.out was not produced; the tests may have failed before coverage ran." >> "$out"
exit 0
fi
{
echo "## Go coverage"
echo
echo "**$(go tool cover -func=coverage.out | awk '/^total:/ {print $3}')** total"
echo
printf '<details><summary>Function detail</summary>\n\n```\n'
go tool cover -func=coverage.out
printf '```\n</details>\n'
} >> "$out"
go tool cover -html=coverage.out -o coverage.html

site:
desc: Build the documentation site (site/, Astro Starlight) into site/dist; task site:dev serves it with live reload.
dir: site
cmds:
- pnpm install --frozen-lockfile
- pnpm build
# From the root, where ./cmd/sfw is: site/ is a module of its own (site/go.mod says why).
- go run ./cmd/sfw pnpm --dir site install --frozen-lockfile
- pnpm --dir site build

site:dev:
desc: Serve the documentation site with live reload.
dir: site
cmds:
- pnpm install --frozen-lockfile
- pnpm dev
- go run ./cmd/sfw pnpm --dir site install --frozen-lockfile
- pnpm --dir site dev

versions:
desc: Say which of versions.yaml's pins are behind their upstream (exits 1 when any is).
desc: Say which of versions.yaml's pins are behind their upstream, and of the firewall's cmd/sfw carries (exits 1 when any is).
cmds:
- '{{.VERSIONS}} check'
# Both, whichever is behind: the second file is the pins go tool sfw embeds for every repository.
- |
status=0
{{.VERSIONS}} check || status=1
{{.VERSIONS}} check -file cmd/sfw/versions.yaml || status=1
exit $status

bump:
desc: Pin one entry of versions.yaml at a version, the newest by default (task bump NAME=golangci-lint [VERSION=v2.14.0]).
desc: Pin one entry of versions.yaml at a version, the newest by default (task bump NAME=golangci-lint [VERSION=v2.14.0]; FILE=cmd/sfw/versions.yaml for the firewall's).
requires:
vars: [NAME]
vars:
FILE: '{{.FILE | default "versions.yaml"}}'
cmds:
- '{{.VERSIONS}} bump {{.NAME}} {{.VERSION}}'
- '{{.VERSIONS}} bump -file {{.FILE}} {{.NAME}} {{.VERSION}}'
21 changes: 14 additions & 7 deletions cmd/mutate/example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -84,19 +85,25 @@ func TestTheExampleTheSiteShows(t *testing.T) {
}

// abridged is what a run says about the edits, without what differs from one run to the next:
// how it bounds the tests' memory, how its package was built, and where the time went.
// how it bounds the tests' memory, how its package was built, where the time went, and the order
// the edits finished in - each is said as it is answered, and two answered at once are said in
// either order. They are sorted, so a survivor comes first.
func abridged(out string) string {
var kept []string
var head, edits, tail []string
verdict := false
for _, line := range strings.Split(out, "\n") {
switch {
case verdict, strings.Contains(line, " planned across "),
strings.HasPrefix(line, " SURVIVED "), strings.HasPrefix(line, " refused "):
kept = append(kept, line)
case verdict:
tail = append(tail, line)
case strings.Contains(line, " planned across "):
head = append(head, line)
case strings.HasPrefix(line, " SURVIVED "), strings.HasPrefix(line, " refused "):
edits = append(edits, line)
case strings.HasPrefix(line, "mutate: ") && strings.Contains(line, " refused, "):
verdict = true
kept = append(kept, line)
tail = append(tail, line)
}
}
return strings.Join(kept, "\n")
slices.Sort(edits)
return strings.Join(slices.Concat(head, edits, tail), "\n")
}
2 changes: 1 addition & 1 deletion cmd/mutate/testdata/example/answered.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
mutate: 2 mutation(s) planned across 1 file(s); running 2
refused limiter.go:22 (Allow): the field is not written
refused limiter.go:19 (Allow): < becomes >=
refused limiter.go:22 (Allow): the field is not written
mutate: 2 refused, 0 survived, 0 did not build, 0 in packages with no tests
OK: every change this broke, a test refused.
112 changes: 112 additions & 0 deletions cmd/sfw/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Command sfw runs pnpm through Socket Firewall Free, at the binary this module pins for the
// platform (versions.yaml beside this file), and is the gate that pnpm is the one package manager
// a repository uses and every install goes through the firewall:
//
// go tool sfw pnpm install --frozen-lockfile # the install, through the firewall
// go tool sfw check [dir] # fails on npm, yarn, their lockfiles, and an install that skips it
//
// The binary is fetched the first time into the user's cache and checked against its pin on
// every run. Free needs no account; it refuses what Socket has confirmed as malware and warns of
// what it suspects.
package main

import (
"context"
_ "embed"
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"

"github.com/spin-stack/go-tools/internal/sfw"
"github.com/spin-stack/go-tools/versions"
)

//go:embed versions.yaml
var pins []byte

const usage = `sfw - package installs through Socket Firewall Free

Usage:
sfw <package manager> <args>... run it through the firewall: sfw pnpm install --frozen-lockfile
sfw check [dir] fail on npm, npx, yarn, their lockfiles, a package.json not on pnpm, and a pnpm install not through it
`

func main() {
args := os.Args[1:]
switch {
case len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help":
fmt.Print(usage)
if len(args) == 0 {
os.Exit(2)
}
case args[0] == "check":
os.Exit(check(args[1:]))
default:
os.Exit(run(args))
}
}

func check(args []string) int {
root := "."
if len(args) > 0 {
root = args[0]
}
findings, err := sfw.Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
for _, f := range findings {
fmt.Println(f)
}
if len(findings) > 0 {
fmt.Printf("sfw: %d finding(s): pnpm is the package manager, and it installs through `go tool sfw`\n", len(findings))
return 1
}
fmt.Println("OK: pnpm is the one package manager, and every install goes through Socket Firewall.")
return 0
}

// run is the package manager's command through the firewall, and its exit status.
func run(args []string) int {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
v, err := versions.Parse(pins)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
e, err := sfw.Entry(v, runtime.GOOS, runtime.GOARCH)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
cache, err := os.UserCacheDir()
if err != nil {
fmt.Fprintf(os.Stderr, "sfw: %v\n", err)
return 2
}
bin, err := sfw.Binary(ctx, e, filepath.Join(cache, "spin-stack", "sfw"))
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
// The terminal's interrupt reaches the firewall and the package manager as it reaches this:
// they are one process group. This waits for them to end rather than leaving them running.
stop()
signal.Ignore(os.Interrupt)
cmd := exec.Command(bin, args...) //nolint:gosec // the pinned firewall, running what the caller asked
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
return exit.ExitCode()
}
fmt.Fprintf(os.Stderr, "sfw: %v\n", err)
return 2
}
return 0
}
Loading
Loading