Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1b057b0
ci(release): pin release-critical CI dependencies
graybuton Sep 8, 2026
ae107c1
ci(release): harden supply-chain verification gate
graybuton Sep 8, 2026
2ac1039
ci(release): close supply-chain review gaps
graybuton Sep 11, 2026
cc67a2c
ci(release): bind supply-chain trust roots
graybuton Sep 12, 2026
5222df3
ci(release): bound TinyGo download surface
graybuton Sep 12, 2026
8fbadcf
ci(release): remove TinyGo scan exemptions
graybuton Sep 12, 2026
49db574
ci(release): harden checksum execution
graybuton Sep 13, 2026
b158987
ci(release): restore Bash 3.2 scalar parsing
graybuton Sep 13, 2026
35c1d70
ci(release): fix checksum oracle portability
graybuton Sep 13, 2026
cfad7ee
ci(release): bind TinyGo install boundary
graybuton Sep 13, 2026
8995ea0
ci(release): separate TinyGo install smoke
graybuton Sep 13, 2026
7c79571
ci(release): close authored CI policy gaps
graybuton Sep 15, 2026
6dc44d2
ci(release): bind TinyGo job execution
graybuton Sep 15, 2026
15b4458
ci(release): close final supply-chain blind spots
graybuton Sep 19, 2026
d0422f1
ci(release): unify authored CI execution policy
graybuton Sep 20, 2026
ce03c52
ci(release): close execution indirection gaps
graybuton Sep 20, 2026
a39b8b2
ci(release): eliminate opaque interpreter execution
graybuton Sep 21, 2026
1a2d8e1
ci(release): analyze shell execution context
graybuton Sep 27, 2026
76364ef
ci(release): close oracle servers despite idle Chrome connections
graybuton Oct 3, 2026
5e81614
ci(release): bound custom-index smoke harness
graybuton Oct 3, 2026
9eaab40
docs(ci): document Browser Smoke limits and bounded harness
graybuton Oct 3, 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
3,092 changes: 3,092 additions & 0 deletions .github/scripts/ci-supply-chain-check.tests.sh

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions .github/tools/shell-context/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module github.com/graybuton/goframe/.github/tools/shell-context

go 1.26.0

require mvdan.cc/sh/v3 v3.14.1
2 changes: 2 additions & 0 deletions .github/tools/shell-context/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
240 changes: 240 additions & 0 deletions .github/tools/shell-context/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
package main

import (
"bytes"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"

"mvdan.cc/sh/v3/syntax"
)

type commandEvent struct {
line uint
pipeInput bool
redirectInput bool
assigns int
words []string
command string
}

func sourceText(src []byte, node syntax.Node) (string, error) {
start, end := int(node.Pos().Offset()), int(node.End().Offset())
if start < 0 || end < start || end > len(src) {
return "", fmt.Errorf("invalid shell source span %d:%d", start, end)
}
return string(src[start:end]), nil
}

func policyPartsText(src []byte, start, end int, parts []syntax.WordPart) (string, error) {
if start < 0 || end < start || end > len(src) {
return "", fmt.Errorf("invalid shell word span %d:%d", start, end)
}
cursor := start
var text strings.Builder
var literal strings.Builder
// Bash removes continued newlines from literal syntax, but not single-quoted data.
flushLiteral := func() {
text.WriteString(strings.ReplaceAll(literal.String(), "\\\n", ""))
literal.Reset()
}
for _, part := range parts {
partStart, partEnd := int(part.Pos().Offset()), int(part.End().Offset())
if partStart < cursor || partEnd < partStart || partEnd > end {
return "", fmt.Errorf("invalid shell word part span %d:%d", partStart, partEnd)
}
literal.Write(src[cursor:partStart])
raw := src[partStart:partEnd]
switch node := part.(type) {
case *syntax.Lit:
literal.Write(raw)
case *syntax.SglQuoted:
flushLiteral()
if node.Dollar && !strings.Contains(node.Value, "\\") {
if !bytes.HasPrefix(raw, []byte("$'")) {
return "", fmt.Errorf("invalid ANSI-C quoted word span %d:%d", partStart, partEnd)
}
text.Write(raw[1:])
} else {
text.Write(raw)
}
case *syntax.DblQuoted:
flushLiteral()
if node.Dollar {
text.Write(raw)
break
}
if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' {
return "", fmt.Errorf("invalid double-quoted word span %d:%d", partStart, partEnd)
}
inner, err := policyPartsText(src, partStart+1, partEnd-1, node.Parts)
if err != nil {
return "", err
}
text.WriteByte('"')
text.WriteString(inner)
text.WriteByte('"')
default:
flushLiteral()
text.Write(raw)
}
cursor = partEnd
}
literal.Write(src[cursor:end])
flushLiteral()
return text.String(), nil
}

func policyWordText(src []byte, word *syntax.Word) (string, error) {
return policyPartsText(src, int(word.Pos().Offset()), int(word.End().Offset()), word.Parts)
}

func directCommandText(src []byte, call *syntax.CallExpr) (string, error) {
start, end := int(call.Pos().Offset()), int(call.End().Offset())
if start < 0 || end < start || end > len(src) {
return "", fmt.Errorf("invalid shell command source span %d:%d", start, end)
}
type span struct{ start, end int }
var substitutions []span
syntax.Walk(call, func(node syntax.Node) bool {
switch node.(type) {
case *syntax.CmdSubst, *syntax.ProcSubst:
substitutions = append(substitutions, span{int(node.Pos().Offset()), int(node.End().Offset())})
return false
}
return true
})
sort.Slice(substitutions, func(i, j int) bool { return substitutions[i].start < substitutions[j].start })
var text bytes.Buffer
for _, substitution := range substitutions {
if substitution.start < start || substitution.end < substitution.start || substitution.end > end {
return "", fmt.Errorf("invalid nested shell source span %d:%d", substitution.start, substitution.end)
}
text.Write(src[start:substitution.start])
text.WriteString("$SUBSTITUTION")
start = substitution.end
}
text.Write(src[start:end])
return text.String(), nil
}

func analyze(src []byte, name string) ([]commandEvent, error) {
if bytes.IndexByte(src, 0) >= 0 {
return nil, fmt.Errorf("%s: NUL byte in shell source", name)
}
file, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(bytes.NewReader(src), name)
if err != nil {
return nil, err
}

piped := make(map[*syntax.CallExpr]bool)
redirected := make(map[*syntax.CallExpr]bool)
syntax.Walk(file, func(node syntax.Node) bool {
switch n := node.(type) {
case *syntax.BinaryCmd:
if n.Op == syntax.Pipe || n.Op == syntax.PipeAll {
syntax.Walk(n.Y, func(child syntax.Node) bool {
if call, ok := child.(*syntax.CallExpr); ok {
piped[call] = true
}
return true
})
}
case *syntax.Stmt:
if call, ok := n.Cmd.(*syntax.CallExpr); ok {
for _, redir := range n.Redirs {
switch redir.Op {
case syntax.RdrIn, syntax.RdrInOut, syntax.DplIn,
syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc:
redirected[call] = true
}
}
}
}
return true
})

var events []commandEvent
var spanErr error
syntax.Walk(file, func(node syntax.Node) bool {
if spanErr != nil {
return false
}
call, ok := node.(*syntax.CallExpr)
if !ok || (len(call.Args) == 0 && len(call.Assigns) == 0) {
return true
}
event := commandEvent{
line: call.Pos().Line(),
pipeInput: piped[call],
redirectInput: redirected[call],
assigns: len(call.Assigns),
}
for _, assign := range call.Assigns {
word, err := sourceText(src, assign)
if err != nil {
spanErr = err
return false
}
event.words = append(event.words, word)
}
for _, arg := range call.Args {
word, err := policyWordText(src, arg)
if err != nil {
spanErr = err
return false
}
event.words = append(event.words, word)
}
event.command, spanErr = directCommandText(src, call)
events = append(events, event)
return true
})
return events, spanErr
}

func writeEvents(w io.Writer, events []commandEvent) error {
field := func(value string) error {
_, err := io.WriteString(w, value+"\x00")
return err
}
for _, event := range events {
values := []string{"C", strconv.FormatUint(uint64(event.line), 10),
strconv.FormatBool(event.pipeInput), strconv.FormatBool(event.redirectInput),
strconv.Itoa(event.assigns), strconv.Itoa(len(event.words))}
values = append(values, event.words...)
values = append(values, event.command)
for _, value := range values {
if err := field(value); err != nil {
return err
}
}
}
if err := field("E"); err != nil {
return err
}
return field(strconv.Itoa(len(events)))
}

func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: shell-context <repository-source-name>")
os.Exit(2)
}
src, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s: read shell source: %v\n", os.Args[1], err)
os.Exit(1)
}
events, err := analyze(src, os.Args[1])
if err == nil {
err = writeEvents(os.Stdout, events)
}
if err != nil {
fmt.Fprintf(os.Stderr, "%s: inspect shell source: %v\n", os.Args[1], err)
os.Exit(1)
}
}
88 changes: 88 additions & 0 deletions .github/tools/shell-context/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

import (
"bytes"
"strings"
"testing"
)

func TestExecutableContext(t *testing.T) {
src := []byte("printf '%s\\n' 'bash'\ncat <<'EOF'\nbash\nEOF\n" +
"printf x |\nbash\nif sh; then :; fi\n" +
"cat <<EOF\n$(bash)\nEOF\n")
events, err := analyze(src, "fixture.sh")
if err != nil {
t.Fatal(err)
}
var commands []string
for _, event := range events {
commands = append(commands, event.command)
}
joined := strings.Join(commands, "|")
if len(events) != 8 || strings.Count(joined, "bash") != 3 ||
commands[1] != "cat" || commands[3] != "bash" ||
commands[4] != "sh" || commands[6] != "cat" || commands[7] != "bash" {
t.Fatalf("unexpected executable calls: %q", commands)
}
if !events[3].pipeInput {
t.Fatalf("pipeline receiver lost stdin context: %+v", events[3])
}
var output bytes.Buffer
if err := writeEvents(&output, events); err != nil {
t.Fatal(err)
}
if !bytes.HasSuffix(output.Bytes(), []byte("E\x008\x00")) {
t.Fatalf("missing complete event trailer: %q", output.Bytes())
}
}

func TestInvalidSourceFailsClosed(t *testing.T) {
for _, src := range []string{"if then", "bash\x00"} {
if _, err := analyze([]byte(src), "invalid.sh"); err == nil {
t.Fatalf("accepted invalid source %q", src)
}
}
}

func TestNestedCallIsNotDoubleCountedAsOuterProvenance(t *testing.T) {
events, err := analyze([]byte("result=\"$(curl https://example.invalid/file)\"\n"), "fixture.sh")
if err != nil {
t.Fatal(err)
}
if len(events) != 2 || strings.Contains(events[0].command, "curl") ||
!strings.Contains(events[1].command, "curl") {
t.Fatalf("nested command provenance is not isolated: %+v", events)
}
}

func TestLiteralDollarQuotedWordsRetainCommandIdentity(t *testing.T) {
events, err := analyze([]byte("$'bash'\nba$'sh'\nprintf '%s\\n' $'bash'\n"), "fixture.sh")
if err != nil {
t.Fatal(err)
}
if len(events) != 3 || events[0].words[0] != "'bash'" ||
events[1].words[0] != "ba'sh'" || events[2].words[2] != "'bash'" {
t.Fatalf("literal ANSI-C quoting changed command identity: %+v", events)
}
}

func TestLineContinuationRetainsCommandIdentity(t *testing.T) {
events, err := analyze([]byte("ba\\\nsh\nprintf '%s\\n' ba\\\nsh\n"), "fixture.sh")
if err != nil {
t.Fatal(err)
}
if len(events) != 2 || events[0].words[0] != "bash" || events[1].words[2] != "bash" {
t.Fatalf("line continuation changed command identity: %+v", events)
}
}

func TestQuotedLineContinuationFollowsShellContext(t *testing.T) {
events, err := analyze([]byte("\"ba\\\nsh\"\nprintf '%s\\n' 'ba\\\nsh'\n"), "fixture.sh")
if err != nil {
t.Fatal(err)
}
if len(events) != 2 || events[0].words[0] != "\"bash\"" ||
events[1].words[2] != "'ba\\\nsh'" {
t.Fatalf("quoted line continuation lost shell context: %+v", events)
}
}
25 changes: 15 additions & 10 deletions .github/workflows/ci-browser-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,43 +10,48 @@ on:
permissions:
contents: read

env:
TINYGO_VERSION: 0.42.0

jobs:
browser-smoke:
name: Browser smoke
runs-on: ubuntu-latest
timeout-minutes: 30

steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Go
uses: actions/setup-go@v6
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: '1.26.6'
cache: true

- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: '24.18.1'

- name: Setup Chrome
id: setup-chrome
uses: browser-actions/setup-chrome@v2
uses: browser-actions/setup-chrome@48ad923757ca74d66703209fe939badbdf80f2f4 # v2.2.0

- name: Install smoke dependencies
run: |
sudo apt-get update
sudo apt-get install -y brotli zstd curl

- name: Install TinyGo
shell: /usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0}
run: |
curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb"
sudo apt-get install -y /tmp/tinygo.deb
tinygo version
trap '/usr/bin/sudo /usr/bin/rm -f /tmp/goframe-tinygo-0.42.0-verified.deb' EXIT
/usr/bin/curl -fsSL -o /tmp/tinygo.deb "https://github.com/tinygo-org/tinygo/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"
/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb /tmp/goframe-tinygo-0.42.0-verified.deb
/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563 /tmp/goframe-tinygo-0.42.0-verified.deb' || exit 1
/usr/bin/sudo /usr/bin/apt-get install -y /tmp/goframe-tinygo-0.42.0-verified.deb

- name: Verify TinyGo
shell: /bin/bash --noprofile --norc -p -e -o pipefail {0}
run: /usr/local/bin/tinygo version

- name: Install goxc
run: go install ./cmd/goxc
Expand Down
Loading
Loading