From eda2d564a5eac4dac878e757545c5ca1899966f1 Mon Sep 17 00:00:00 2001 From: August Vi Date: Sun, 18 Oct 2026 20:51:04 -0700 Subject: [PATCH] Add the OpenSSF Scorecard workflow, pin CI actions by commit SHA, and fuzz the SQL scanner. --- .github/workflows/ci.yml | 12 +- .github/workflows/scorecard.yml | 42 +++++++ fuzz_test.go | 194 ++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/scorecard.yml create mode 100644 fuzz_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39b9ef9..a861618 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: stable - name: go vet @@ -81,8 +81,8 @@ jobs: SQLX_POSTGRES_DSN: postgres://postgres:sqlx@localhost:5432/postgres?sslmode=disable SQLX_MYSQL_DSN: root:sqlx@tcp(127.0.0.1:3306)/sqlx steps: - - uses: actions/checkout@v7 - - uses: actions/setup-go@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - name: Test with the race detector against Postgres, MySQL and SQLite @@ -100,10 +100,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: stable - name: Export the v1.4.0 API under the fork's module path diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..d0bacfe --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,42 @@ +name: Scorecard + +on: + push: + branches: + - master + schedule: + - cron: "27 4 * * 1" + branch_protection_rule: {} + workflow_dispatch: {} + +permissions: read-all + +jobs: + analysis: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # Upload the results to code scanning. + security-events: write + # Publish the results to the Scorecard API for the badge. + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Run Scorecard + uses: ossf/scorecard-action@fa7117db83c52d787ae5a89f350999701505d78e # v2.5.0 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: Keep the SARIF file + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: scorecard-sarif + path: results.sarif + retention-days: 5 + - name: Upload to code scanning + uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + with: + sarif_file: results.sarif diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..88b08f7 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,194 @@ +package sqlx + +import ( + "strconv" + "strings" + "testing" +) + +// fuzzSeeds covers each construct the scanner treats specially: placeholders, +// the '??' escape, named parameters, casts, literals, quoted identifiers, +// comments, dollar quotes and the backslash-quote cases from the audit. +var fuzzSeeds = []string{ + "SELECT * FROM t WHERE a = ? AND b = ?", + "SELECT ?? FROM t WHERE a = ?", + "SELECT * FROM t WHERE id IN (?) AND x = ?", + "INSERT INTO t (a, b) VALUES (:a, :b)", + "SELECT :data::jsonb AS boundary", + "SELECT :first:name", + "SELECT * FROM t WHERE name = ':not' AND id = :id", + `SELECT "a?b", ` + "`c:d`" + `, [e?] FROM t WHERE x = ?`, + "SELECT 1 -- a ? comment\nWHERE a = ?", + "SELECT /* outer /* inner ? */ :x */ :y", + "SELECT $$ ? :x $$, $tag$ ? $tag$, ?", + "SELECT E'it\\'s ?' , ?", + `SELECT * FROM t WHERE p LIKE ? ESCAPE '\' AND q = ? -- don't`, + `SELECT * FROM t WHERE path = 'C:\' AND name = ? /* it's */`, + `SELECT * FROM t WHERE s = 'don\'t' AND c = ? -- it's`, + "SELECT :place.id, :ünï, :a_", + "'unterminated ? :x", + "", +} + +var fuzzBindTypes = []int{UNKNOWN, QUESTION, DOLLAR, NAMED, AT} + +type fuzzToken struct { + kind tokKind + start, end int +} + +// scanTokens returns every token in q, failing the test if the scanner does not +// make progress or reports a token that breaks its documented shape. +func scanTokens(t *testing.T, q string, opt scanOpts) []fuzzToken { + t.Helper() + sc := sqlScanner{q: q, opt: opt} + var toks []fuzzToken + prevEnd := 0 + for i := 0; ; i++ { + if i > len(q)+1 { + t.Fatalf("scanner did not reach EOF after %d tokens in %q", i, q) + } + kind, start, end := sc.next() + if kind == tokEOF { + return toks + } + if start < prevEnd || end <= start || end > len(q) { + t.Fatalf("token %d [%d,%d) out of order or bounds (prev end %d, len %d) in %q", i, start, end, prevEnd, len(q), q) + } + switch kind { + case tokQuestion: + if q[start:end] != "?" { + t.Fatalf("tokQuestion covers %q in %q", q[start:end], q) + } + case tokQuestionPair: + if q[start:end] != "??" { + t.Fatalf("tokQuestionPair covers %q in %q", q[start:end], q) + } + case tokName: + if q[start] != ':' || end-start < 2 || !isNameStart(q[start+1]) { + t.Fatalf("tokName covers %q in %q", q[start:end], q) + } + default: + t.Fatalf("unknown token kind %d in %q", kind, q) + } + toks = append(toks, fuzzToken{kind, start, end}) + prevEnd = end + } +} + +func countKind(toks []fuzzToken, kind tokKind) int { + n := 0 + for _, tok := range toks { + if tok.kind == kind { + n++ + } + } + return n +} + +func FuzzScanner(f *testing.F) { + for _, q := range fuzzSeeds { + f.Add(q, false) + f.Add(q, true) + } + f.Fuzz(func(t *testing.T, q string, brackets bool) { + var opt scanOpts + if brackets { + opt = optBrackets + } + scanTokens(t, q, opt) + }) +} + +func FuzzRebind(f *testing.F) { + for _, q := range fuzzSeeds { + f.Add(q) + } + f.Fuzz(func(t *testing.T, q string) { + for _, bt := range fuzzBindTypes { + out := Rebind(bt, q) + toks := scanTokens(t, q, scanOptsFor(bt)) + n := countKind(toks, tokQuestion) + pairs := countKind(toks, tokQuestionPair) + switch bt { + case QUESTION, UNKNOWN: + if len(out) != len(q)-pairs { + t.Fatalf("Rebind(%d, %q) = %q: want %d bytes, one per '??' fewer", bt, q, out, len(q)-pairs) + } + case DOLLAR: + if n > 0 && !strings.Contains(out, "$"+strconv.Itoa(n)) { + t.Fatalf("Rebind(DOLLAR, %q) = %q: missing $%d", q, out, n) + } + case AT: + if n > 0 && !strings.Contains(out, "@p"+strconv.Itoa(n)) { + t.Fatalf("Rebind(AT, %q) = %q: missing @p%d", q, out, n) + } + } + } + }) +} + +func FuzzCompileNamedQuery(f *testing.F) { + for _, q := range fuzzSeeds { + f.Add(q) + } + f.Fuzz(func(t *testing.T, q string) { + for _, bt := range fuzzBindTypes { + out, names, err := compileNamedQuery(q, bt) + if err != nil { + continue + } + for _, name := range names { + if name == "" || !isNameStart(name[0]) { + t.Fatalf("compileNamedQuery(%q, %d) returned name %q", q, bt, name) + } + } + switch bt { + case NAMED: + if out != q { + t.Fatalf("compileNamedQuery(%q, NAMED) = %q: want the query unchanged", q, out) + } + case DOLLAR: + if len(names) > 0 && !strings.Contains(out, "$"+strconv.Itoa(len(names))) { + t.Fatalf("compileNamedQuery(%q, DOLLAR) = %q: missing $%d", q, out, len(names)) + } + } + } + }) +} + +func FuzzIn(f *testing.F) { + for _, q := range fuzzSeeds { + f.Add(q, uint8(3)) + } + f.Fuzz(func(t *testing.T, q string, width uint8) { + // One argument per '?': non-empty slices of 1 to 4 ints alternating + // with scalars, so the expansion is never the NULL literal. + n := countKind(scanTokens(t, q, 0), tokQuestion) + args := make([]interface{}, n) + want := 0 + for i := range args { + if i%2 == 0 { + s := make([]int, int(width)%4+1) + args[i] = s + want += len(s) + } else { + args[i] = i + want++ + } + } + out, newArgs, err := In(q, args...) + if err != nil { + t.Fatalf("In(%q) with %d arguments: %v", q, n, err) + } + if n == 0 { + return + } + if len(newArgs) != want { + t.Fatalf("In(%q) returned %d arguments, want %d", q, len(newArgs), want) + } + if got := countKind(scanTokens(t, out, 0), tokQuestion); got != want { + t.Fatalf("In(%q) = %q has %d placeholders for %d arguments", q, out, got, want) + } + }) +}